Skip to content

Instantly share code, notes, and snippets.

@dan-myles
Created August 1, 2025 16:06
Show Gist options
  • Save dan-myles/2b15adc7d23c26794464965a8d6ce4b6 to your computer and use it in GitHub Desktop.
Save dan-myles/2b15adc7d23c26794464965a8d6ce4b6 to your computer and use it in GitHub Desktop.
React Context Keybind System
import {
createContext,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import type { Keybind, KeybindDefinition, KeybindId } from "./keybind.types"
import { getCurrentPlatform } from "@/app/lib/utils"
interface KeybindContextValue {
registerKeybind: (keybind: Keybind) => void
keybindList: Keybind[]
keybindMap: Map<string, Keybind>
}
const KEYBIND_LOCALSTORAGE_KEY = "keybind-store"
export const KeybindContext = createContext<KeybindContextValue | null>(null)
export function KeybindProvider({ children }: { children: ReactNode }) {
const [keybinds, setKeybinds] = useState<Map<KeybindId, KeybindDefinition>>(
() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(KEYBIND_LOCALSTORAGE_KEY)
return stored !== null ? JSON.parse(stored) : new Map()
}
return new Map()
},
)
const [callbacks, setCallbacks] = useState<Map<KeybindId, () => void>>(
new Map(),
)
const registerKeybind = useCallback((keybind: Keybind) => {
const { callback, ...definition } = keybind
setKeybinds((prev) => {
const newMap = new Map(prev)
newMap.set(keybind.id, definition)
return newMap
})
setCallbacks((prev) => {
const newMap = new Map(prev)
newMap.set(keybind.id, callback)
return newMap
})
}, [])
const keybindList = useMemo(() => {
const currentPlatform = getCurrentPlatform()
return Array.from(keybinds.values())
.filter((def) => def.keys[currentPlatform])
.map((def) => ({
...def,
callback: callbacks.get(def.id) || (() => {}),
}))
}, [keybinds, callbacks])
const keybindMap = useMemo(() => {
const currentPlatform = getCurrentPlatform()
const map = new Map<string, Keybind>()
keybinds.forEach((definition) => {
const platformKey = definition.keys[currentPlatform]
if (platformKey) {
const callback = callbacks.get(definition.id) || (() => {})
const normalizedKey = normalizeKeybindString(platformKey)
map.set(normalizedKey, { ...definition, callback })
}
})
return map
}, [keybinds, callbacks])
useEffect(() => {
try {
const serialized = Array.from(keybinds.entries())
localStorage.setItem(KEYBIND_LOCALSTORAGE_KEY, JSON.stringify(serialized))
} catch (error) {
console.warn("Failed to save keybind definitions to localStorage:", error)
}
}, [keybinds])
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement
if (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA"
) {
return
}
const eventKeybindString = createEventKeybindLookupString(event)
const matchedKeybind = keybindMap.get(eventKeybindString)
if (matchedKeybind) {
event.preventDefault()
event.stopPropagation()
matchedKeybind.callback()
}
}
document.addEventListener("keydown", handleKeyDown, true)
return () => document.removeEventListener("keydown", handleKeyDown, true)
}, [keybindMap])
const value = useMemo(
() => ({
registerKeybind,
keybindList,
keybindMap,
}),
[registerKeybind, keybindList, keybindMap],
)
return (
<KeybindContext.Provider value={value}>{children}</KeybindContext.Provider>
)
}
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/\s+/g, "")
}
function normalizeKeybindString(keybindString: string): string {
const parts = keybindString
.toLowerCase()
.split("+")
.map((s) => s.trim())
const key = parts.pop()
const modifiers = parts.filter((p) => p !== key).sort()
return [...modifiers, key].join("+")
}
function createEventKeybindLookupString(event: KeyboardEvent): string {
const eventModifiers: string[] = []
if (event.ctrlKey) eventModifiers.push("ctrl")
if (event.metaKey) eventModifiers.push("cmd")
if (event.altKey) eventModifiers.push("alt")
if (event.shiftKey) eventModifiers.push("shift")
const eventKey = normalizeKey(event.key)
const sortedModifiers = eventModifiers.sort()
return [...sortedModifiers, eventKey].join("+")
}
export type KeybindId =
| "toggle-sidebar"
| "toggle-command-menu"
| "toggle-light-dark-mode"
| "navigate-settings"
type Modifier = "ctrl" | "cmd" | "alt" | "shift"
type Letter =
| "a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z"
type Number = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
type FunctionKey =
| "f1"
| "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12"
type SpecialKey =
| "escape"
| "enter"
| "space"
| "tab"
| "backspace"
| "delete"
| "arrowup"
| "arrowdown"
| "arrowleft"
| "arrowright"
type Symbol = "," | "." | "/" | ";" | "'" | "[" | "]" | "\\" | "`" | "-" | "="
type Key = Letter | Number | FunctionKey | SpecialKey | Symbol
export type KeybindString =
| Key
| `${Modifier}+${Key}`
| `${Modifier}+${Modifier}+${Key}`
| `${Modifier}+${Modifier}+${Modifier}+${Key}`
export type Platform = "darwin" | "win32" | "linux"
export interface KeybindDefinition {
id: KeybindId
keys: {
darwin?: KeybindString
win32?: KeybindString
linux?: KeybindString
}
description: string
override?: boolean
}
export interface Keybind extends KeybindDefinition {
callback: () => void
}
import { use, useEffect } from "react"
import { KeybindContext } from "@/app/components/providers/keybind.provider"
import { Keybind } from "@/app/components/providers/keybind.types"
export function useKeybindMap() {
const ctx = use(KeybindContext)
if (!ctx) {
throw new Error("useKeybind must be used within a KeybindProvider")
}
return ctx.keybindMap
}
export function useKeybindList() {
const ctx = use(KeybindContext)
if (!ctx) {
throw new Error("useKeybind must be used within a KeybindProvider")
}
return ctx.keybindList
}
export function useRegisterKeybind(keybind: Keybind) {
const ctx = use(KeybindContext)
if (!ctx) {
throw new Error("useKeybind must be used within a KeybindProvider")
}
useEffect(() => {
ctx.registerKeybind(keybind)
}, [ctx.registerKeybind, keybind.callback, JSON.stringify(keybind)])
}
export function getCurrentPlatform(): "darwin" | "win32" | "linux" {
if (typeof __DARWIN__ !== "undefined" && __DARWIN__) return "darwin"
if (typeof __WIN32__ !== "undefined" && __WIN32__) return "win32"
if (typeof __LINUX__ !== "undefined" && __LINUX__) return "linux"
if (typeof navigator !== "undefined") {
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes("mac")) return "darwin"
if (userAgent.includes("win")) return "win32"
return "linux"
}
return "linux"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment