import { useEffect, useRef } from 'react'; export function isTextField(el: EventTarget | null): boolean { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (el.isContentEditable) return true; return false; } type Handler = (e: KeyboardEvent) => void; /** * Fires when the given key is pressed outside any text field. * Pass modifiers as part of the key spec, e.g. `mod+k` for Ctrl/Meta+K. * Single-char keys ignore shift unless explicitly `shift+x`. */ export function useShortcut(spec: string | string[], handler: Handler, deps: unknown[] = []) { const handlerRef = useRef(handler); handlerRef.current = handler; useEffect(() => { const specs = (Array.isArray(spec) ? spec : [spec]).map(parseSpec); const onKey = (e: KeyboardEvent) => { if (isTextField(e.target)) { // Allow mod+key even inside text fields (e.g. ⌘K) if (!specs.some((s) => s.mod && matches(s, e))) return; } for (const s of specs) { if (matches(s, e)) { handlerRef.current(e); return; } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } interface ParsedSpec { key: string; mod: boolean; shift: boolean; } function parseSpec(s: string): ParsedSpec { const parts = s.toLowerCase().split('+'); const key = parts[parts.length - 1]; return { key, mod: parts.includes('mod'), shift: parts.includes('shift'), }; } function matches(s: ParsedSpec, e: KeyboardEvent): boolean { if (e.key.toLowerCase() !== s.key) return false; if (s.mod !== (e.metaKey || e.ctrlKey)) return false; if (s.shift && !e.shiftKey) return false; return true; } /** * Tracks a two-key "g" prefix and fires the matching handler when the second * key is pressed within the timeout window. Any other key cancels. */ export function useLeaderShortcut( leader: string, mapping: Record void>, timeoutMs = 1000, ) { const state = useRef<{ armed: boolean; timer: number | null }>({ armed: false, timer: null }); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (isTextField(e.target)) return; if (e.metaKey || e.ctrlKey || e.altKey) { state.current.armed = false; return; } const key = e.key.toLowerCase(); if (!state.current.armed) { if (key === leader) { state.current.armed = true; if (state.current.timer) window.clearTimeout(state.current.timer); state.current.timer = window.setTimeout(() => { state.current.armed = false; }, timeoutMs); e.preventDefault(); } return; } // armed: expect second key state.current.armed = false; if (state.current.timer) { window.clearTimeout(state.current.timer); state.current.timer = null; } const fn = mapping[key]; if (fn) { e.preventDefault(); fn(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [leader, timeoutMs, JSON.stringify(Object.keys(mapping))]); }