import type { Hotkey, RegisteredAction } from './types'; import type { ActionRegistry } from './registry'; import { normalizeHotkey, hotkeyToString } from './hotkey-utils'; const STORAGE_KEY = 'textile.hotkeys.v2'; /** * Persists user hotkey overrides and resolves effective hotkeys. * * Each action has default hotkeys from its ActionSet (an array). Users can * replace the entire array, clear it (unbind), or revert to defaults. * Overrides are stored in localStorage and survive app restarts. */ export class HotkeyStore { /** Map of action full ID -> override array ([] = explicitly unbound) */ private overrides = new Map(); private listeners = new Set<() => void>(); constructor() { this.load(); } private load(): void { if (typeof localStorage === 'undefined') return; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const data = JSON.parse(raw) as Record; for (const [id, hotkeys] of Object.entries(data)) { if (Array.isArray(hotkeys)) { this.overrides.set(id, hotkeys); } } } catch { // Ignore corrupt storage } } private save(): void { if (typeof localStorage === 'undefined') return; const data: Record = {}; for (const [id, hotkeys] of this.overrides) { data[id] = hotkeys; } localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } private notify(): void { for (const listener of this.listeners) { listener(); } } /** * Get the user override for an action, if any. * Returns `[]` if the user explicitly unbound it. * Returns `undefined` if there is no override. */ getOverride(actionId: string): Hotkey[] | undefined { return this.overrides.get(actionId); } /** * Set the full override array for an action. * Pass `[]` to explicitly unbind. */ setOverride(actionId: string, hotkeys: Hotkey[]): void { this.overrides.set(actionId, hotkeys); this.save(); this.notify(); } /** Remove any override for an action (revert to defaults). */ removeOverride(actionId: string): void { this.overrides.delete(actionId); this.save(); this.notify(); } /** * Get all effective hotkeys for an action. * Checks user override first, then falls back to the action's defaults. */ getEffectiveHotkeys(action: RegisteredAction): Hotkey[] { const override = this.overrides.get(action.id); if (override !== undefined) return override; return action.hotkeys ?? []; } /** Subscribe to hotkey override changes. Returns an unsubscribe function. */ onChange(callback: () => void): () => void { this.listeners.add(callback); return () => { this.listeners.delete(callback); }; } /** * Find hotkey conflicts among all registered actions using effective hotkeys. * Returns a map of normalized hotkey string -> array of conflicting action IDs. */ getConflicts( registry: ActionRegistry, isMac: boolean, ): Map { const bindings = new Map(); for (const action of registry.getAll()) { for (const hotkey of this.getEffectiveHotkeys(action)) { const key = hotkeyToString(normalizeHotkey(hotkey, isMac)); const existing = bindings.get(key) ?? []; existing.push(action.id); bindings.set(key, existing); } } const conflicts = new Map(); for (const [key, actionIds] of bindings) { if (actionIds.length > 1) { conflicts.set(key, actionIds); } } return conflicts; } }