"use client"; import { useEffect, useCallback, useRef, useMemo } from "react"; export interface KeyboardShortcut { key: string; description: string; action: () => void; category: string; meta?: boolean; // Cmd/Ctrl shift?: boolean; alt?: boolean; ctrl?: boolean; // When to disable this shortcut (e.g., when typing in input) disabledWhen?: () => boolean; } export interface ShortcutCategory { name: string; shortcuts: KeyboardShortcut[]; } interface UseKeyboardShortcutsOptions { shortcuts: KeyboardShortcut[]; enabled?: boolean; } export function useKeyboardShortcuts({ shortcuts, enabled = true }: UseKeyboardShortcutsOptions) { const shortcutsRef = useRef(shortcuts); useEffect(() => { shortcutsRef.current = shortcuts; }, [shortcuts]); const isInputFocused = useCallback(() => { const active = document.activeElement; if (!active) return false; const tag = active.tagName.toLowerCase(); return ( tag === "input" || tag === "textarea" || tag === "select" || (active as HTMLElement).isContentEditable || (active as HTMLElement).closest('[data-radix-popper-content-wrapper], [role="dialog"], [role="menu"]') !== null ); }, []); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (!enabled) return; // Allow shortcuts with meta/ctrl even in inputs for some cases const inInput = isInputFocused(); for (const shortcut of shortcutsRef.current) { const { key, meta, shift, alt, ctrl, disabledWhen } = shortcut; // Check if shortcut should be disabled in current context if (disabledWhen && disabledWhen()) continue; // Don't trigger shortcuts when typing in inputs (unless explicitly allowed) if (inInput && !(meta || ctrl)) continue; const keyMatches = event.key.toLowerCase() === key.toLowerCase(); const metaMatches = !!meta === (event.metaKey || event.ctrlKey); const shiftMatches = !!shift === event.shiftKey; const altMatches = !!alt === event.altKey; const ctrlMatches = !!ctrl === (event.ctrlKey || event.metaKey); if (keyMatches && metaMatches && shiftMatches && altMatches && ctrlMatches) { event.preventDefault(); event.stopPropagation(); shortcut.action(); break; } } }, [enabled, isInputFocused] ); useEffect(() => { if (!enabled) return; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [enabled, handleKeyDown]); // Return categorized shortcuts for help modal const categorized = useMemo(() => { const categories: Record = {}; for (const shortcut of shortcuts) { if (!categories[shortcut.category]) { categories[shortcut.category] = []; } categories[shortcut.category].push(shortcut); } return Object.entries(categories).map(([name, shortcuts]) => ({ name, shortcuts })); }, [shortcuts]); return { categorized }; } export function formatShortcut(shortcut: KeyboardShortcut): string { const parts: string[] = []; if (shortcut.meta) parts.push(navigator.platform.includes("Mac") ? "⌘" : "Ctrl"); if (shortcut.ctrl && !shortcut.meta) parts.push("Ctrl"); if (shortcut.shift) parts.push("Shift"); if (shortcut.alt) parts.push(navigator.platform.includes("Mac") ? "⌥" : "Alt"); parts.push(shortcut.key.toUpperCase()); return parts.join(" + "); }