Created
August 1, 2025 16:06
-
-
Save dan-myles/2b15adc7d23c26794464965a8d6ce4b6 to your computer and use it in GitHub Desktop.
React Context Keybind System
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("+") | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)]) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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