Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126import 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<string, Hotkey[]>(); 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<string, Hotkey[]>; 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<string, Hotkey[]> = {}; 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<string, string[]> { const bindings = new Map<string, string[]>();
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<string, string[]>(); for (const [key, actionIds] of bindings) { if (actionIds.length > 1) { conflicts.set(key, actionIds); } } return conflicts; }}