shortcuts.ts

6.7 KB · raw

1/**2 * shortcuts.ts — keybinding schema, defaults, matcher, formatter.3 *4 * Shared across popup views and Settings. The popup reads5 * `getActiveBindings(settings)` once on init and on every settings update;6 * each handler tests `event` against the binding it cares about via7 * `matchKeybinding(event, binding)`. Capture-key flow in Settings uses8 * `keybindingFromEvent(event)` to convert a captured keydown into a9 * Keybinding object.10 *11 * Bindings store `event.key` (layout-aware), not `event.code`. That keeps12 * the displayed shortcut accurate for the user's keyboard layout — a13 * QWERTY user sees "C", an AZERTY user typing the same physical key sees14 * what their layout produces.15 */1617export type ShortcutAction =18  | "copy_password"19  | "copy_username"20  | "copy_totp"21  | "search"22  | "new_entry"23  | "edit_entry"24  | "delete_entry"25  | "lock_vault"26  | "show_help"27  | "nav_prev"28  | "nav_next"29  | "jump_vault"30  | "jump_settings";3132export interface Keybinding {33  /** event.key value — case-preserved for letters but matched case-insensitively. */34  key: string;35  ctrl?: boolean;36  meta?: boolean;37  shift?: boolean;38  alt?: boolean;39}4041export interface ShortcutDescriptor {42  action: ShortcutAction;43  label: string;44  /** Where this shortcut is meaningful — used for grouping in the help dialog. */45  scope: "entry" | "list" | "global";46  /** Hidden from the rebind UI when true (Escape, etc. — not user-customizable). */47  fixed?: boolean;48}4950export const SHORTCUT_DESCRIPTORS: ShortcutDescriptor[] = [51  { action: "copy_password",  label: "Copy password",          scope: "entry"  },52  { action: "copy_username",  label: "Copy username",          scope: "entry"  },53  { action: "copy_totp",      label: "Copy 2FA code",          scope: "entry"  },54  { action: "edit_entry",     label: "Edit entry",             scope: "entry"  },55  { action: "delete_entry",   label: "Move entry to trash",    scope: "entry"  },56  { action: "nav_prev",       label: "Previous entry",         scope: "entry"  },57  { action: "nav_next",       label: "Next entry",             scope: "entry"  },58  { action: "search",         label: "Search vault",           scope: "global" },59  { action: "new_entry",      label: "New entry",              scope: "global" },60  { action: "lock_vault",     label: "Lock vault",             scope: "global" },61  { action: "jump_vault",     label: "Jump to vault",          scope: "global" },62  { action: "jump_settings",  label: "Jump to settings",       scope: "global" },63  { action: "show_help",      label: "Show keyboard shortcuts", scope: "global" },64];6566export const DEFAULT_BINDINGS: Record<ShortcutAction, Keybinding> = {67  copy_password:  { key: "c" },68  copy_username:  { key: "u" },69  copy_totp:      { key: "t" },70  search:         { key: "/" },71  new_entry:      { key: "n" },72  edit_entry:     { key: "e" },73  delete_entry:   { key: "d" },74  lock_vault:     { key: "l", ctrl: true },75  show_help:      { key: "?", shift: true },76  nav_prev:       { key: "ArrowLeft" },77  nav_next:       { key: "ArrowRight" },78  jump_vault:     { key: "1", ctrl: true },79  jump_settings:  { key: ",", ctrl: true },80};8182/** Merge user overrides over defaults, returning a complete bindings map. */83export function getActiveBindings(84  overrides: Partial<Record<ShortcutAction, Keybinding>> | undefined85): Record<ShortcutAction, Keybinding> {86  if (!overrides) return { ...DEFAULT_BINDINGS };87  return { ...DEFAULT_BINDINGS, ...overrides };88}8990/** True when the keyboard event matches the binding (case-insensitive on key). */91export function matchKeybinding(event: KeyboardEvent, binding: Keybinding): boolean {92  if (event.key.toLowerCase() !== binding.key.toLowerCase()) return false;93  if (Boolean(event.ctrlKey)  !== Boolean(binding.ctrl))  return false;94  if (Boolean(event.metaKey)  !== Boolean(binding.meta))  return false;95  if (Boolean(event.shiftKey) !== Boolean(binding.shift)) return false;96  if (Boolean(event.altKey)   !== Boolean(binding.alt))   return false;97  return true;98}99100/**101 * Build a Keybinding from a captured keydown event. Returns null when the102 * event is a bare modifier (Shift, Ctrl, etc.) — the rebind UI keeps103 * listening until a real key arrives.104 */105export function keybindingFromEvent(event: KeyboardEvent): Keybinding | null {106  const k = event.key;107  if (k === "Shift" || k === "Control" || k === "Meta" || k === "Alt") return null;108  return {109    key: k,110    ctrl:  event.ctrlKey  || undefined,111    meta:  event.metaKey  || undefined,112    shift: event.shiftKey || undefined,113    alt:   event.altKey   || undefined,114  };115}116117/**118 * Format a binding for display. Uses platform conventions:119 *   macOS: ⌘ ⌥ ⌃ ⇧120 *   other: Ctrl Alt Shift Meta121 * Caller passes `isMac` so this stays SSR/test-safe with no globals.122 */123export function formatKeybinding(binding: Keybinding, isMac = detectMac()): string {124  const parts: string[] = [];125  if (isMac) {126    if (binding.ctrl)  parts.push("⌃");127    if (binding.alt)   parts.push("⌥");128    if (binding.shift) parts.push("⇧");129    if (binding.meta)  parts.push("⌘");130  } else {131    if (binding.ctrl)  parts.push("Ctrl");132    if (binding.alt)   parts.push("Alt");133    if (binding.shift) parts.push("Shift");134    if (binding.meta)  parts.push("Meta");135  }136  parts.push(formatKey(binding.key));137  return isMac ? parts.join("") : parts.join("+");138}139140function formatKey(key: string): string {141  if (key === " ") return "Space";142  if (key === "ArrowUp")    return "↑";143  if (key === "ArrowDown")  return "↓";144  if (key === "ArrowLeft")  return "←";145  if (key === "ArrowRight") return "→";146  if (key === "Escape")     return "Esc";147  if (key === "Enter")      return "↵";148  if (key === "Tab")        return "⇥";149  if (key.length === 1) return key.toUpperCase();150  return key;151}152153function detectMac(): boolean {154  if (typeof navigator === "undefined") return false;155  return /Mac|iPhone|iPad/.test(navigator.platform);156}157158/**159 * Find the first action whose binding matches. Returns null on no match.160 * Used by the rebind UI's conflict-detection step.161 */162export function findConflict(163  bindings: Record<ShortcutAction, Keybinding>,164  candidate: Keybinding,165  excludeAction?: ShortcutAction,166): ShortcutAction | null {167  for (const action of Object.keys(bindings) as ShortcutAction[]) {168    if (action === excludeAction) continue;169    if (matchKeybinding(eventLikeFromBinding(bindings[action]), candidate)) {170      return action;171    }172  }173  return null;174}175176/** Synthesize a KeyboardEvent-shaped object so matchKeybinding can compare two bindings. */177function eventLikeFromBinding(b: Keybinding): KeyboardEvent {178  return {179    key: b.key,180    ctrlKey:  Boolean(b.ctrl),181    metaKey:  Boolean(b.meta),182    shiftKey: Boolean(b.shift),183    altKey:   Boolean(b.alt),184  } as KeyboardEvent;185}