// lib/shortcuts.ts — keyboard shortcut registry + key parsing. // // Combos use a normalized form: "mod+k", "mod+enter", "mod+1", "?". // `mod` = ⌘ on macOS, Ctrl elsewhere. Auto-detected. export type ActionGroup = "Navigation" | "Actions" | "View" | "Help"; export interface Action { id: string; label: string; combo?: string; // "mod+k" etc. Optional — palette-only actions skip this. group: ActionGroup; hint?: string; // Sub-text shown in palette run: () => void | Promise; } const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform); export const MOD_KEY = isMac ? "⌘" : "Ctrl"; // Normalize a KeyboardEvent into our combo string. // Note: for symbol keys like "?" "!" "@", browser already encodes the shift, // so we omit the shift modifier to avoid double-counting. export function eventToCombo(e: KeyboardEvent): string { const parts: string[] = []; if (e.metaKey || e.ctrlKey) parts.push("mod"); if (e.altKey) parts.push("alt"); const key = e.key === " " ? "space" : e.key.toLowerCase(); const isAlphanumeric = key.length === 1 && /[a-z0-9]/.test(key); // Shift only encoded explicitly for alphanumerics; symbols already imply it. if (e.shiftKey && isAlphanumeric) parts.push("shift"); if (!["meta", "control", "alt", "shift"].includes(key)) parts.push(key); return parts.join("+"); } // Pretty-print for help UI: "mod+k" → "⌘ K" export function prettyCombo(combo: string): string { return combo .split("+") .map((p) => { if (p === "mod") return MOD_KEY; if (p === "shift") return "⇧"; if (p === "alt") return isMac ? "⌥" : "Alt"; if (p === "enter") return "↵"; if (p === "escape" || p === "esc") return "Esc"; if (p === "space") return "Space"; if (p === "?") return "?"; return p.length === 1 ? p.toUpperCase() : p; }) .join(" "); } // Singleton registry. Components register on mount, unregister on unmount. const registry = new Map(); const subscribers = new Set<() => void>(); // Cached snapshot for useSyncExternalStore. Must return same reference between // changes — rebuilding the array each call triggers React's infinite-loop guard. let cachedList: Action[] = []; function rebuildSnapshot() { cachedList = Array.from(registry.values()); } export const shortcuts = { register(action: Action): () => void { registry.set(action.id, action); rebuildSnapshot(); notify(); return () => { registry.delete(action.id); rebuildSnapshot(); notify(); }; }, list(): Action[] { return cachedList; }, byCombo(combo: string): Action | undefined { for (const a of registry.values()) if (a.combo === combo) return a; return undefined; }, subscribe(fn: () => void): () => void { subscribers.add(fn); return () => { subscribers.delete(fn); }; }, }; function notify() { for (const fn of subscribers) fn(); } // Should this event be ignored because user is typing in an input? // Allow `mod+*` combos through — they're explicit shortcuts the user meant. export function shouldIgnore(e: KeyboardEvent): boolean { const target = e.target as HTMLElement | null; if (!target) return false; if (e.metaKey || e.ctrlKey) return false; const tag = target.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; if (target.isContentEditable) return true; return false; }