/** * useKeyboardShortcuts - Global keyboard shortcut handler hook. * * Features: * - Single-key shortcuts (GitHub/Gmail pattern) * - Skips in text inputs and dialogs * - Doesn't fire when Ctrl/Cmd/Alt held (prevents browser conflicts) */ import { useEffect, useMemo } from "react"; export interface Shortcut { /** Key to match (case-insensitive) */ key: string; /** Require Ctrl key */ meta?: boolean; /** Require Shift key (undefined = don't care) */ shift?: boolean; /** Action to execute */ action: () => void; /** Skip when user is in text input (default: true) */ skipInInput?: boolean; /** Skip when inside a dialog (default: true) */ skipInDialog?: boolean; } /** * Check if event target is an input element */ function isInputElement(target: EventTarget | null): boolean { if (!target || !(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); if (tagName === "input" || tagName === "textarea") return true; if (target.isContentEditable) return true; // Check for CodeMirror if (target.closest(".cm-editor")) return true; return false; } /** * Check if event target is inside a dialog */ function isInDialog(target: EventTarget | null): boolean { if (!target || !(target instanceof HTMLElement)) return false; return Boolean(target.closest('[role="dialog"]')); } /** * Register keyboard shortcuts */ export function useKeyboardShortcuts(shortcuts: Shortcut[]): void { // Memoize to prevent unnecessary re-registrations const memoizedShortcuts = useMemo(() => shortcuts, [shortcuts]); useEffect(() => { const handler = (e: KeyboardEvent) => { for (const shortcut of memoizedShortcuts) { // Check key match first (most common failure) const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase(); if (!keyMatch) continue; // Skip inside dialogs (default: true) const skipDialog = shortcut.skipInDialog ?? true; if (skipDialog && isInDialog(e.target)) continue; // Skip in text inputs (default: true) const skipInput = shortcut.skipInInput ?? true; if (skipInput && isInputElement(e.target)) continue; // Modifier handling if (shortcut.meta) { // Allow Ctrl or Cmd for cross-platform meta shortcuts if (!e.ctrlKey && !e.metaKey) continue; } else { // Single-key shortcuts: don't fire when any modifier held // Prevents hijacking Cmd+N on macOS, Ctrl+K on Windows, etc. if (e.ctrlKey || e.metaKey || e.altKey) continue; } // Shift: only check if explicitly specified (undefined = don't care) // This allows Shift+N to still trigger 'n' shortcuts if (shortcut.shift !== undefined && e.shiftKey !== shortcut.shift) { continue; } e.preventDefault(); shortcut.action(); return; } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [memoizedShortcuts]); }