1234567891011121314151617export 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 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 45 scope: "entry" | "list" | "global";46 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};818283export function getActiveBindings(84 overrides: Partial<Record<ShortcutAction, Keybinding>> | undefined85): Record<ShortcutAction, Keybinding> {86 if (!overrides) return { ...DEFAULT_BINDINGS };87 return { ...DEFAULT_BINDINGS, ...overrides };88}899091export 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}99100101102103104105export 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}116117118119120121122123export 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}157158159160161162export 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}175176177function 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}