/** * shortcuts.ts — keybinding schema, defaults, matcher, formatter. * * Shared across popup views and Settings. The popup reads * `getActiveBindings(settings)` once on init and on every settings update; * each handler tests `event` against the binding it cares about via * `matchKeybinding(event, binding)`. Capture-key flow in Settings uses * `keybindingFromEvent(event)` to convert a captured keydown into a * Keybinding object. * * Bindings store `event.key` (layout-aware), not `event.code`. That keeps * the displayed shortcut accurate for the user's keyboard layout — a * QWERTY user sees "C", an AZERTY user typing the same physical key sees * what their layout produces. */ export type ShortcutAction = | "copy_password" | "copy_username" | "copy_totp" | "search" | "new_entry" | "edit_entry" | "delete_entry" | "lock_vault" | "show_help" | "nav_prev" | "nav_next" | "jump_vault" | "jump_settings"; export interface Keybinding { /** event.key value — case-preserved for letters but matched case-insensitively. */ key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean; } export interface ShortcutDescriptor { action: ShortcutAction; label: string; /** Where this shortcut is meaningful — used for grouping in the help dialog. */ scope: "entry" | "list" | "global"; /** Hidden from the rebind UI when true (Escape, etc. — not user-customizable). */ fixed?: boolean; } export const SHORTCUT_DESCRIPTORS: ShortcutDescriptor[] = [ { action: "copy_password", label: "Copy password", scope: "entry" }, { action: "copy_username", label: "Copy username", scope: "entry" }, { action: "copy_totp", label: "Copy 2FA code", scope: "entry" }, { action: "edit_entry", label: "Edit entry", scope: "entry" }, { action: "delete_entry", label: "Move entry to trash", scope: "entry" }, { action: "nav_prev", label: "Previous entry", scope: "entry" }, { action: "nav_next", label: "Next entry", scope: "entry" }, { action: "search", label: "Search vault", scope: "global" }, { action: "new_entry", label: "New entry", scope: "global" }, { action: "lock_vault", label: "Lock vault", scope: "global" }, { action: "jump_vault", label: "Jump to vault", scope: "global" }, { action: "jump_settings", label: "Jump to settings", scope: "global" }, { action: "show_help", label: "Show keyboard shortcuts", scope: "global" }, ]; export const DEFAULT_BINDINGS: Record = { copy_password: { key: "c" }, copy_username: { key: "u" }, copy_totp: { key: "t" }, search: { key: "/" }, new_entry: { key: "n" }, edit_entry: { key: "e" }, delete_entry: { key: "d" }, lock_vault: { key: "l", ctrl: true }, show_help: { key: "?", shift: true }, nav_prev: { key: "ArrowLeft" }, nav_next: { key: "ArrowRight" }, jump_vault: { key: "1", ctrl: true }, jump_settings: { key: ",", ctrl: true }, }; /** Merge user overrides over defaults, returning a complete bindings map. */ export function getActiveBindings( overrides: Partial> | undefined ): Record { if (!overrides) return { ...DEFAULT_BINDINGS }; return { ...DEFAULT_BINDINGS, ...overrides }; } /** True when the keyboard event matches the binding (case-insensitive on key). */ export function matchKeybinding(event: KeyboardEvent, binding: Keybinding): boolean { if (event.key.toLowerCase() !== binding.key.toLowerCase()) return false; if (Boolean(event.ctrlKey) !== Boolean(binding.ctrl)) return false; if (Boolean(event.metaKey) !== Boolean(binding.meta)) return false; if (Boolean(event.shiftKey) !== Boolean(binding.shift)) return false; if (Boolean(event.altKey) !== Boolean(binding.alt)) return false; return true; } /** * Build a Keybinding from a captured keydown event. Returns null when the * event is a bare modifier (Shift, Ctrl, etc.) — the rebind UI keeps * listening until a real key arrives. */ export function keybindingFromEvent(event: KeyboardEvent): Keybinding | null { const k = event.key; if (k === "Shift" || k === "Control" || k === "Meta" || k === "Alt") return null; return { key: k, ctrl: event.ctrlKey || undefined, meta: event.metaKey || undefined, shift: event.shiftKey || undefined, alt: event.altKey || undefined, }; } /** * Format a binding for display. Uses platform conventions: * macOS: ⌘ ⌥ ⌃ ⇧ * other: Ctrl Alt Shift Meta * Caller passes `isMac` so this stays SSR/test-safe with no globals. */ export function formatKeybinding(binding: Keybinding, isMac = detectMac()): string { const parts: string[] = []; if (isMac) { if (binding.ctrl) parts.push("⌃"); if (binding.alt) parts.push("⌥"); if (binding.shift) parts.push("⇧"); if (binding.meta) parts.push("⌘"); } else { if (binding.ctrl) parts.push("Ctrl"); if (binding.alt) parts.push("Alt"); if (binding.shift) parts.push("Shift"); if (binding.meta) parts.push("Meta"); } parts.push(formatKey(binding.key)); return isMac ? parts.join("") : parts.join("+"); } function formatKey(key: string): string { if (key === " ") return "Space"; if (key === "ArrowUp") return "↑"; if (key === "ArrowDown") return "↓"; if (key === "ArrowLeft") return "←"; if (key === "ArrowRight") return "→"; if (key === "Escape") return "Esc"; if (key === "Enter") return "↵"; if (key === "Tab") return "⇥"; if (key.length === 1) return key.toUpperCase(); return key; } function detectMac(): boolean { if (typeof navigator === "undefined") return false; return /Mac|iPhone|iPad/.test(navigator.platform); } /** * Find the first action whose binding matches. Returns null on no match. * Used by the rebind UI's conflict-detection step. */ export function findConflict( bindings: Record, candidate: Keybinding, excludeAction?: ShortcutAction, ): ShortcutAction | null { for (const action of Object.keys(bindings) as ShortcutAction[]) { if (action === excludeAction) continue; if (matchKeybinding(eventLikeFromBinding(bindings[action]), candidate)) { return action; } } return null; } /** Synthesize a KeyboardEvent-shaped object so matchKeybinding can compare two bindings. */ function eventLikeFromBinding(b: Keybinding): KeyboardEvent { return { key: b.key, ctrlKey: Boolean(b.ctrl), metaKey: Boolean(b.meta), shiftKey: Boolean(b.shift), altKey: Boolean(b.alt), } as KeyboardEvent; }