Last active
August 15, 2026 18:59
-
-
Save Toyz/75e74a64b3df3b642899c5da3b4c0245 to your computer and use it in GitHub Desktop.
Quite literally useKeybinds in react that doesn't suck
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
| // ───────────────────────────────────────────────────────────────────────────── | |
| // useKeybinds — a small, self-contained React keyboard-shortcut hook. | |
| // Platform-stable chords, ⌘/Ctrl auto-swap, vim-style sequences, typing-aware | |
| // (won't fire while you're in an input/editor). Extracted from mininote. | |
| // | |
| // Quick start — global shortcuts: | |
| // useKeybinds([ | |
| // { keys: "mod+k", run: () => openPalette() }, // ⌘K / Ctrl+K | |
| // { keys: "mod+shift+p", run: () => openSwitcher() }, | |
| // { keys: "?", run: () => toggleCheatSheet() },// symbol implies shift | |
| // { keys: "g h", run: () => go("/home") }, // vim-style sequence | |
| // ]); | |
| // | |
| // Helpers: | |
| // label("mod+shift+p") -> "⌘⇧P" (mac) / "Ctrl+Shift+P" — cheat-sheet chip | |
| // captureChord(e) -> "mod+shift+p" | null — build a rebind UI | |
| // chordIsSafe(keys) -> reject binds that hijack typing | |
| // toCMKey("mod+b") -> "Mod-B" — feed a CodeMirror keymap | |
| // | |
| // The component-extensible registry at the BOTTOM (KeybindRegistryProvider / | |
| // useRegisterKeybinds) is OPTIONAL: it lets a mounted component contribute scoped | |
| // shortcuts into the same matcher/cheat-sheet. It needs a host to own the store and | |
| // render the provider — with no provider, useRegisterKeybinds is a no-op. Don't need | |
| // it? Delete from the "Component-extensible registry" divider down; the top half is | |
| // fully standalone. | |
| // | |
| // Public gist — copy, adapt, ship. No attribution needed. | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| import { createContext, useContext, useEffect, useRef } from "react"; | |
| // useKeybinds: mininote's own keyboard-shortcut hook. The ones in the wild | |
| // re-subscribe every render, fire while you're typing, and fumble ⌘-vs-Ctrl — | |
| // none of which is acceptable in an app whose whole point is a focused editor. | |
| // | |
| // Design: | |
| // - ONE window listener, mounted once. Handlers live in a ref, so passing fresh | |
| // closures every render never re-subscribes. | |
| // - Typing-aware: a bind is ignored while focus is in an <input>/<textarea>/ | |
| // <select>/contenteditable (the CodeMirror editor is contenteditable), unless | |
| // it sets whenTyping. So global nav keys never eat a keystroke mid-note. | |
| // - `mod` means ⌘ on macOS, Ctrl elsewhere — declared once here. | |
| // - Chords ("mod+k", "mod+shift+p", "/", "?") AND vim-style sequences ("g h") | |
| // with a short timeout between steps. | |
| export type KeyHandler = (e: KeyboardEvent) => void; | |
| export interface Keybind { | |
| /** | |
| * A chord like "mod+k" / "mod+shift+p" / "/" / "?", or a space-separated | |
| * sequence like "g h". `mod` = ⌘ (mac) or Ctrl. Symbol keys ("/", "?") imply | |
| * their shift state, so you don't write "shift+/". | |
| */ | |
| keys: string; | |
| run: KeyHandler; | |
| /** Fire even while typing in an input/editor. Default false. */ | |
| whenTyping?: boolean; | |
| /** preventDefault on match. Default true. */ | |
| preventDefault?: boolean; | |
| } | |
| export const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); | |
| // canHover = a device with a real pointer (desktop). Keyboard-only affordances | |
| // (the shortcut cheat-sheet) hide on touch where there's no keyboard. | |
| export const canHover = typeof window !== "undefined" && window.matchMedia("(hover: hover)").matches; | |
| // Platform modifier symbol for inline labels: "⌘" on mac, "Ctrl+" elsewhere. | |
| export const MOD = isMac ? "⌘" : "Ctrl+"; | |
| const SEQ_TIMEOUT_MS = 700; | |
| type Parsed = | |
| | { type: "chord"; mods: Set<string>; key: string } | |
| | { type: "seq"; steps: string[] }; | |
| function parse(keys: string): Parsed { | |
| const s = keys.trim().toLowerCase(); | |
| if (/\s/.test(s)) return { type: "seq", steps: s.split(/\s+/) }; | |
| const toks = s.split("+"); | |
| const key = toks.pop() ?? ""; | |
| return { type: "chord", mods: new Set(toks), key }; | |
| } | |
| // isTyping is true when focus is in an editable field — global binds yield to it. | |
| function isTyping(target: EventTarget | null): boolean { | |
| const el = target as HTMLElement | null; | |
| if (!el || !el.tagName) return false; | |
| const tag = el.tagName; | |
| if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; | |
| return !!el.isContentEditable; | |
| } | |
| // Physical-key → [base, shifted] US symbol map. event.code is layout/platform STABLE, | |
| // unlike event.key: macOS reports the UNSHIFTED symbol when ⌘ is held (⌘⇧/ → key="/" on | |
| // mac but "?" on Windows), so a captured chord wouldn't match across platforms or survive | |
| // cross-device sync. Deriving symbols from .code fixes both. | |
| const CODE_SYM: Record<string, [string, string]> = { | |
| Slash: ["/", "?"], Period: [".", ">"], Comma: [",", "<"], Semicolon: [";", ":"], | |
| Quote: ["'", "\""], BracketLeft: ["[", "{"], BracketRight: ["]", "}"], | |
| Backslash: ["\\", "|"], Minus: ["-", "_"], Equal: ["=", "+"], Backquote: ["`", "~"], | |
| Digit1: ["1", "!"], Digit2: ["2", "@"], Digit3: ["3", "#"], Digit4: ["4", "$"], | |
| Digit5: ["5", "%"], Digit6: ["6", "^"], Digit7: ["7", "&"], Digit8: ["8", "*"], | |
| Digit9: ["9", "("], Digit0: ["0", ")"], | |
| }; | |
| // The shifted-symbol chars (from CODE_SYM). label() shows ⇧ for a chord whose key already | |
| // encodes shift (e.g. "?" = ⇧/) — capture omits a redundant shift token for symbols, so this | |
| // keeps the DISPLAYED chord showing the shift the user pressed, consistently on every platform. | |
| const SHIFTED_SYMS = new Set(Object.values(CODE_SYM).map(([, s]) => s)); | |
| // normKey: the canonical, platform-stable base-key token for a keydown. Symbols + shifted | |
| // digits derive from event.code (so ⌘⇧/ is "?" everywhere); letters/space/named keys fall | |
| // back to the produced key. Used by BOTH capture and match so they always agree. | |
| function normKey(e: KeyboardEvent): string { | |
| const sym = CODE_SYM[e.code]; | |
| if (sym) return e.shiftKey ? sym[1] : sym[0]; | |
| return e.key === " " ? "space" : e.key.toLowerCase(); | |
| } | |
| function matchChord(p: Extract<Parsed, { type: "chord" }>, e: KeyboardEvent): boolean { | |
| const mod = isMac ? e.metaKey : e.ctrlKey; | |
| if (p.mods.has("mod") !== mod) return false; | |
| if (p.mods.has("alt") !== e.altKey) return false; | |
| const key = normKey(e); | |
| // A symbol char already encodes its shift (e.g. "?" = ⇧/). For a symbol chord | |
| // WITHOUT an explicit shift token (the built-in "?" / "/"), ignore shift. But a | |
| // captured chord that DOES carry shift (e.g. "mod+shift+?") enforces it like any | |
| // other — so the cheat-sheet can show ⇧ and rebinds round-trip exactly. | |
| const isSymbol = p.key.length === 1 && !/[a-z0-9]/.test(p.key); | |
| const ignoreShift = isSymbol && !p.mods.has("shift"); | |
| if (!ignoreShift && p.mods.has("shift") !== e.shiftKey) return false; | |
| return key === p.key; | |
| } | |
| // getExtra (optional) supplies component-registered binds LIVE at keydown time (see the | |
| // registry below) — resolved on each press so their `run` closures are always current, and | |
| // so a mounted component's bind takes precedence over the base list (extras are matched first). | |
| export function useKeybinds(binds: Keybind[], getExtra?: () => Keybind[]): void { | |
| const ref = useRef(binds); | |
| ref.current = binds; | |
| const extraRef = useRef(getExtra); | |
| extraRef.current = getExtra; | |
| const seq = useRef<{ keys: string[]; at: number }>({ keys: [], at: 0 }); | |
| useEffect(() => { | |
| const onKey = (e: KeyboardEvent) => { | |
| const typing = isTyping(e.target); | |
| const list = extraRef.current ? [...extraRef.current(), ...ref.current] : ref.current; | |
| // 1. chords | |
| for (const b of list) { | |
| const p = parse(b.keys); | |
| if (p.type !== "chord") continue; | |
| if (typing && !b.whenTyping) continue; | |
| if (matchChord(p, e)) { | |
| if (b.preventDefault !== false) e.preventDefault(); | |
| b.run(e); | |
| return; | |
| } | |
| } | |
| // 2. sequences — only bare single keys (no modifiers), and not while typing | |
| if (typing || e.metaKey || e.ctrlKey || e.altKey || e.key.length !== 1) return; | |
| const now = Date.now(); | |
| if (now - seq.current.at > SEQ_TIMEOUT_MS) seq.current.keys = []; | |
| seq.current.at = now; | |
| seq.current.keys.push(e.key.toLowerCase()); | |
| if (seq.current.keys.length > 4) seq.current.keys.shift(); | |
| const buf = seq.current.keys.join(" "); | |
| for (const b of list) { | |
| const p = parse(b.keys); | |
| if (p.type !== "seq") continue; | |
| if (buf.endsWith(p.steps.join(" "))) { | |
| if (b.preventDefault !== false) e.preventDefault(); | |
| seq.current.keys = []; | |
| b.run(e); | |
| return; | |
| } | |
| } | |
| }; | |
| window.addEventListener("keydown", onKey); | |
| return () => window.removeEventListener("keydown", onKey); | |
| }, []); | |
| } | |
| // ---- Component-extensible registry ----------------------------------------------------- | |
| // A component contributes its OWN shortcuts (scoped to while it's mounted) via | |
| // useRegisterKeybinds — flowing into the SAME matcher, cheat-sheet, and rebind/sync path as | |
| // the app's built-ins, instead of a bespoke window listener. The host (App) owns the store + | |
| // user overrides and supplies a `register` fn through this context. | |
| // A registered bind = a runnable Keybind plus the metadata the cheat-sheet + rebinder need. | |
| // `cat` is its display group; a module usually declares it ONCE via the `scope` arg of | |
| // useRegisterKeybinds rather than repeating it per bind. `scoped` marks a component-scoped | |
| // bind: it's only live while mounted, so a bare-key rebind ("j") can't hijack global typing → | |
| // the rebinder relaxes its safety check, and the cheat-sheet groups it as module-owned. | |
| export interface RegisteredBind extends Keybind { | |
| id: string; | |
| desc: string; | |
| cat?: string; // defaults to the hook's `scope` | |
| scoped?: boolean; // defaults true when a `scope` is given | |
| } | |
| export type RegisterKeybinds = (getBinds: () => RegisteredBind[]) => () => void; | |
| const RegistryCtx = createContext<RegisterKeybinds | null>(null); | |
| export const KeybindRegistryProvider = RegistryCtx.Provider; | |
| // useRegisterKeybinds registers a component's shortcuts for as long as it's mounted. Pass a | |
| // fresh array each render — it's read through a ref, so the binds' `run` closures stay current | |
| // without re-registering. `scope` is the module's OWN category: it names the group in the | |
| // cheat-sheet and marks the binds as component-scoped (module-owned). No-op outside a provider. | |
| export function useRegisterKeybinds(binds: RegisteredBind[], scope?: string): void { | |
| const register = useContext(RegistryCtx); | |
| const norm = scope ? binds.map((b) => ({ ...b, cat: b.cat ?? scope, scoped: b.scoped ?? true })) : binds; | |
| const ref = useRef(norm); | |
| ref.current = norm; | |
| useEffect(() => { | |
| if (!register) return; | |
| return register(() => ref.current); | |
| }, [register]); | |
| } | |
| // captureChord turns a keydown into the canonical chord string the matcher uses | |
| // ("mod+shift+p", "mod+e", "?"). Returns null for a modifier-only press (keep | |
| // listening). Records every held modifier (incl. shift) so the chip shows the | |
| // full chain. Used by the rebind UI. | |
| export function captureChord(e: KeyboardEvent): string | null { | |
| const k = e.key; | |
| if (k === "Shift" || k === "Control" || k === "Alt" || k === "Meta") return null; | |
| const key = normKey(e); // platform-stable (symbols from event.code → ⌘⇧/ = "?" everywhere) | |
| const mods: string[] = []; | |
| // Accept EITHER Ctrl or ⌘ as the platform mod and normalize to `mod` — so a Mac | |
| // user pressing Ctrl (or a PC user pressing ⌘) still maps, and `mod` renders as | |
| // the right glyph for their OS at runtime. This is the auto-swap. | |
| if (e.metaKey || e.ctrlKey) mods.push("mod"); | |
| if (e.altKey) mods.push("alt"); | |
| // A symbol char already ENCODES its shift ("?" = ⇧/), so only record shift for | |
| // non-symbol keys (letters/digits/named) — else "mod+shift+?" double-counts shift and | |
| // mismatches the symbol-implies-shift matcher. Keeps capture ↔ match consistent. | |
| const isSymbol = key.length === 1 && !/[a-z0-9]/.test(key); | |
| if (e.shiftKey && !isSymbol) mods.push("shift"); | |
| return [...mods, key].join("+"); | |
| } | |
| // chordIsSafe rejects a rebind that would hijack plain typing — a bare | |
| // alphanumeric with no modifier (e.g. "p") fires on every keypress. Allow it only | |
| // with a modifier, or if it's a symbol (?, /) which won't collide with nav typing. | |
| export function chordIsSafe(keys: string): boolean { | |
| const p = parse(keys); | |
| if (p.type === "seq") return true; | |
| if (p.mods.size > 0) return true; | |
| return p.key.length === 1 && !/[a-z0-9]/.test(p.key); // symbol-only is fine | |
| } | |
| // toCMKey converts our chord ("mod+shift+b") to CodeMirror's keymap format | |
| // ("Mod-Shift-B"). Sequences ("g h") aren't expressible as a CM key → null. Lets | |
| // editor shortcuts live in the SAME registry (one source of truth) yet dispatch | |
| // through CodeMirror so they stay editor-scoped + remappable. | |
| export function toCMKey(keys: string): string | null { | |
| if (/\s/.test(keys.trim())) return null; | |
| const toks = keys.toLowerCase().split("+"); | |
| const key = toks.pop() ?? ""; | |
| const parts: string[] = []; | |
| if (toks.includes("mod")) parts.push("Mod"); | |
| if (toks.includes("alt")) parts.push("Alt"); | |
| if (toks.includes("shift")) parts.push("Shift"); | |
| parts.push(key.length === 1 ? key.toUpperCase() : key); | |
| return parts.join("-"); | |
| } | |
| // Pretty names for named/whitespace keys, so a chip reads "←" / "Space" / "Esc" rather than | |
| // "Arrowleft". Single-char keys pass through (upper-cased); anything unmapped title-cases. | |
| const KEY_LABEL: Record<string, string> = { | |
| arrowleft: "←", arrowright: "→", arrowup: "↑", arrowdown: "↓", | |
| space: "Space", escape: "Esc", enter: "⏎", tab: "Tab", backspace: "⌫", delete: "Del", | |
| }; | |
| // label formats a bind's keys for display in a help overlay ("⌘K", "G then H"). | |
| export function label(keys: string): string { | |
| const p = parse(keys); | |
| if (p.type === "seq") return p.steps.map((s) => KEY_LABEL[s] ?? s.toUpperCase()).join(" "); | |
| const mod = isMac ? "⌘" : "Ctrl+"; | |
| const parts: string[] = []; | |
| if (p.mods.has("mod")) parts.push(mod); | |
| if (p.mods.has("alt")) parts.push(isMac ? "⌥" : "Alt+"); | |
| // Show ⇧ for an explicit shift token OR a shifted-symbol key ("?"=⇧/) — so the displayed | |
| // chord shows the shift the user pressed CONSISTENTLY across platforms (capture omits the | |
| // redundant shift token for symbols, but the user still expects to see ⇧). | |
| if (p.mods.has("shift") || (p.key.length === 1 && SHIFTED_SYMS.has(p.key))) parts.push(isMac ? "⇧" : "Shift+"); | |
| parts.push(KEY_LABEL[p.key] ?? (p.key.length === 1 ? p.key.toUpperCase() : p.key[0].toUpperCase() + p.key.slice(1))); | |
| return parts.join("").replace(/\+$/, ""); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment