Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237import 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<string, string>(); /** Map of action ID -> normalized hotkeys */ private actionToHotkeys = new Map<string, Hotkey[]>(); /** 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<string, string> { 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); }}