import type { Hotkey } from './types'; import type { ActionRegistry } from './registry'; import { normalizeHotkey, hotkeyToString, eventToHotkey } from './hotkey-utils'; /** * Information about a hotkey conflict. * Occurs when an action tries to register a hotkey already bound to another action. */ export interface HotkeyConflict { /** The hotkey string (e.g. "Ctrl+P") */ hotkeyString: string; /** The action ID that was rejected (tried to register but lost) */ rejectedActionId: string; /** The action ID that currently holds the binding (winner) */ existingActionId: string; } /** * Manages global keyboard shortcuts for actions. * * Features: * - Platform-aware Mod key expansion (Cmd on Mac, Ctrl elsewhere) * - Conflict detection when registering overlapping hotkeys * - Error isolation — callback errors don't crash the app * - Automatic cleanup on destroy */ export class HotkeyManager { /** Map of normalized hotkey string -> action ID */ private bindings = new Map(); /** Map of action ID -> normalized hotkeys */ private actionToHotkeys = new Map(); /** Tracked conflicts for display in settings UI */ private conflicts: HotkeyConflict[] = []; /** Whether we're on Mac (affects Mod expansion) */ private readonly isMac: boolean; /** Reference to action registry for callback lookup */ private readonly registry: ActionRegistry; /** Bound event handler for cleanup */ private readonly handleKeyDown: (e: KeyboardEvent) => void; /** Whether the keydown listener is currently attached */ private _attached = false; /** Unsubscribe from registry action-removal notifications */ private readonly unsubscribeActionRemoved: () => void; /** Callback invoked when a hotkey matches an action */ private readonly executeAction: (actionId: string) => void; constructor( registry: ActionRegistry, isMac: boolean, executeAction: (actionId: string) => void, ) { this.registry = registry; this.isMac = isMac; this.executeAction = executeAction; this.handleKeyDown = this.onKeyDown.bind(this); this.unsubscribeActionRemoved = registry.onActionRemoved((actionId) => { this.removeHotkey(actionId); }); window.addEventListener('keydown', this.handleKeyDown); this._attached = true; } /** * Register a hotkey for an action. * * @returns The action ID of a conflicting binding, or null if no conflict */ registerHotkey(actionId: string, hotkey: Hotkey): string | null { const normalized = normalizeHotkey(hotkey, this.isMac); const key = hotkeyToString(normalized); // Check for conflict const existing = this.bindings.get(key); if (existing && existing !== actionId) { // Track the conflict for display in settings if ( !this.conflicts.some( (conflict) => conflict.hotkeyString === key && conflict.rejectedActionId === actionId && conflict.existingActionId === existing, ) ) { this.conflicts.push({ hotkeyString: key, rejectedActionId: actionId, existingActionId: existing, }); } return existing; } const existingHotkeys = this.actionToHotkeys.get(actionId) ?? []; if (existingHotkeys.some((h) => hotkeyToString(h) === key)) { return null; } this.bindings.set(key, actionId); this.actionToHotkeys.set(actionId, [...existingHotkeys, normalized]); return null; } /** * Remove a hotkey binding for an action. * Also clears any conflicts where this action was either the winner or the loser. */ removeHotkey(actionId: string): void { const hotkeys = this.actionToHotkeys.get(actionId); if (hotkeys) { for (const hotkey of hotkeys) { const key = hotkeyToString(hotkey); this.bindings.delete(key); } this.actionToHotkeys.delete(actionId); } // Always clear conflicts where this action was involved this.conflicts = this.conflicts.filter( (c) => c.existingActionId !== actionId && c.rejectedActionId !== actionId, ); } /** * Remove all hotkey bindings for a source (e.g. plugin). */ removeSourceHotkeys(source: string): void { const toRemove: string[] = []; for (const actionId of this.actionToHotkeys.keys()) { if (actionId.startsWith(source + ':')) { toRemove.push(actionId); } } for (const actionId of toRemove) { this.removeHotkey(actionId); } } /** * Get the hotkey registered for an action. */ getHotkeyForAction(actionId: string): Hotkey | undefined { return this.actionToHotkeys.get(actionId)?.[0]; } /** * Get all current hotkey bindings. */ getAllBindings(): Map { return new Map(this.bindings); } /** * Get all tracked hotkey conflicts. */ getConflicts(): HotkeyConflict[] { return [...this.conflicts]; } /** * Detach the keydown listener. Call reattach() to restore it. * Does NOT clear bindings — they survive detach/reattach cycles * (important for React StrictMode unmount/remount). */ detach(): void { if (!this._attached) return; window.removeEventListener('keydown', this.handleKeyDown); this._attached = false; } /** * Re-attach the keydown listener after detach(). * Idempotent — safe to call if already attached. */ reattach(): void { if (this._attached) return; window.addEventListener('keydown', this.handleKeyDown); this._attached = true; } /** * Permanently clean up. Removes listener and clears all bindings. * After destroy(), the instance cannot be reattached. */ destroy(): void { window.removeEventListener('keydown', this.handleKeyDown); this._attached = false; this.unsubscribeActionRemoved(); this.bindings.clear(); this.actionToHotkeys.clear(); this.conflicts = []; } /** Keys that are modifiers themselves — skip these to avoid spurious lookups */ private static readonly MODIFIER_KEYS = new Set([ 'Control', 'Shift', 'Alt', 'Meta', ]); /** * Handle keydown events and dispatch to matching actions. */ private onKeyDown(event: KeyboardEvent): void { // Skip modifier-only keypresses (e.g. pressing just Shift) if (HotkeyManager.MODIFIER_KEYS.has(event.key)) return; // Ignore events when typing in inputs (unless it's a modifier combo) const target = event.target as HTMLElement | null; if (target && target.tagName) { const tagName = target.tagName.toLowerCase(); const isInput = tagName === 'input' || tagName === 'textarea' || target.isContentEditable; if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) { return; } } const hotkey = eventToHotkey(event); const key = hotkeyToString(hotkey); const actionId = this.bindings.get(key); if (!actionId) return; const action = this.registry.get(actionId); if (!action) return; event.preventDefault(); event.stopImmediatePropagation(); this.executeAction(actionId); } }