import type { ShortcutKey } from "@/utils/format-shortcut"; export interface KeyCombo { code: string; key?: string; shiftedKey?: string; codeFallback?: boolean; meta?: true; ctrl?: true; alt?: true; shift?: true; mod?: true; repeat?: false; } interface KeyMapping { code: string; key?: string; shiftedKey?: string; codeFallback?: boolean; } const KEY_MAP: Record = {}; for (let i = 0; i < 26; i++) { const letter = String.fromCharCode(65 + i); KEY_MAP[letter] = { code: `Key${letter}`, key: letter.toLowerCase() }; } for (let i = 0; i <= 9; i++) { KEY_MAP[String(i)] = { code: `Digit${i}`, key: String(i) }; } KEY_MAP["1"].shiftedKey = "!"; KEY_MAP["2"].shiftedKey = "@"; KEY_MAP["3"].shiftedKey = "#"; KEY_MAP["4"].shiftedKey = "$"; KEY_MAP["5"].shiftedKey = "%"; KEY_MAP["6"].shiftedKey = "^"; KEY_MAP["7"].shiftedKey = "&"; KEY_MAP["8"].shiftedKey = "*"; KEY_MAP["9"].shiftedKey = "("; KEY_MAP["0"].shiftedKey = ")"; KEY_MAP["Digit"] = { code: "Digit" }; KEY_MAP["-"] = { code: "Minus", key: "-", shiftedKey: "_" }; KEY_MAP["="] = { code: "Equal", key: "=", shiftedKey: "+" }; KEY_MAP["\\"] = { code: "Backslash", key: "\\", shiftedKey: "|" }; KEY_MAP["["] = { code: "BracketLeft", key: "[", shiftedKey: "{" }; KEY_MAP["]"] = { code: "BracketRight", key: "]", shiftedKey: "}" }; KEY_MAP[";"] = { code: "Semicolon", key: ";", shiftedKey: ":" }; KEY_MAP["'"] = { code: "Quote", key: "'", shiftedKey: '"' }; KEY_MAP[","] = { code: "Comma", key: ",", shiftedKey: "<" }; KEY_MAP["."] = { code: "Period", key: ".", shiftedKey: ">" }; KEY_MAP["`"] = { code: "Backquote", key: "`", shiftedKey: "~" }; KEY_MAP["/"] = { code: "Slash", key: "/", shiftedKey: "?" }; KEY_MAP["?"] = { code: "Slash", key: "?" }; KEY_MAP["Space"] = { code: "Space", key: " ", codeFallback: true }; KEY_MAP["Enter"] = { code: "Enter", key: "Enter", codeFallback: true }; KEY_MAP["Backspace"] = { code: "Backspace" }; KEY_MAP["Escape"] = { code: "Escape" }; KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" }; KEY_MAP["ArrowRight"] = { code: "ArrowRight" }; KEY_MAP["ArrowUp"] = { code: "ArrowUp" }; KEY_MAP["ArrowDown"] = { code: "ArrowDown" }; KEY_MAP["Tab"] = { code: "Tab" }; KEY_MAP["Delete"] = { code: "Delete" }; KEY_MAP["Home"] = { code: "Home" }; KEY_MAP["End"] = { code: "End" }; KEY_MAP["PageUp"] = { code: "PageUp" }; KEY_MAP["PageDown"] = { code: "PageDown" }; KEY_MAP["Insert"] = { code: "Insert" }; for (let i = 1; i <= 12; i++) { KEY_MAP[`F${i}`] = { code: `F${i}` }; } /** * Every key name a combo string can carry. Exported so the display path can be * driven over the whole vocabulary instead of a hand-copied sample that goes * stale the moment `KEY_MAP` gains an entry. */ export const SHORTCUT_KEY_NAMES: readonly string[] = Object.keys(KEY_MAP); const CODE_TO_KEY: Record = {}; for (const [humanKey, mapping] of Object.entries(KEY_MAP)) { if (!CODE_TO_KEY[mapping.code]) { CODE_TO_KEY[mapping.code] = humanKey; } } export function parseShortcutString(s: string): KeyCombo { const parts = s.split("+"); if (parts.length === 0 || parts.some((p) => p === "")) { throw new Error(`Invalid shortcut string: "${s}"`); } const combo: KeyCombo = { code: "" }; let keyPart: string | null = null; for (const part of parts) { switch (part) { case "Cmd": combo.meta = true; break; case "Ctrl": combo.ctrl = true; break; case "Alt": combo.alt = true; break; case "Shift": combo.shift = true; break; case "Mod": combo.mod = true; break; default: if (keyPart !== null) { throw new Error(`Invalid shortcut string: "${s}" - multiple key parts`); } keyPart = part; break; } } if (keyPart === null) { throw new Error(`Invalid shortcut string: "${s}" - no key part`); } const mapping = KEY_MAP[keyPart]; if (!mapping) { throw new Error(`Unknown key: "${keyPart}"`); } combo.code = mapping.code; if (mapping.key !== undefined) { combo.key = mapping.key; if (mapping.shiftedKey !== undefined) { combo.shiftedKey = mapping.shiftedKey; } if (mapping.codeFallback === true) { combo.codeFallback = true; } } return combo; } export function parseChordString(s: string): KeyCombo[] { return s.split(" ").map(parseShortcutString); } export function keyComboToString(combo: KeyCombo): string { const parts: string[] = []; if (combo.mod) { parts.push("Mod"); } if (combo.ctrl) parts.push("Ctrl"); if (combo.alt) parts.push("Alt"); if (combo.shift) parts.push("Shift"); if (combo.meta) parts.push("Cmd"); const humanKey = CODE_TO_KEY[combo.code]; if (humanKey) { parts.push(humanKey); } return parts.join("+"); } export function chordToString(chord: KeyCombo[]): string { return chord.map(keyComboToString).join(" "); } const MODIFIER_CODES = new Set([ "MetaLeft", "MetaRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "ShiftLeft", "ShiftRight", ]); /** * Key names as combo strings spell them, mapped to the names `KEY_DISPLAY` in * `format-shortcut` is keyed on. The two vocabularies were never reconciled: * `keyboardEventToComboString` emits `ArrowLeft` and `Escape` (from `KEY_MAP`), * while the display table only knows `Left` and `Esc`. Without this bridge an * override on a named key renders as a raw code — `ARROWLEFT`, not `←`. */ const DISPLAY_KEY_ALIASES: Record = { ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down", Escape: "Esc", }; export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] { const parts = comboString.split("+"); const keys: ShortcutKey[] = []; for (const part of parts) { switch (part) { case "Cmd": keys.push("mod"); break; // `parseShortcutString` accepts `Mod`, so the display path has to as well // or a combo it can match would render the literal word. case "Mod": keys.push("mod"); break; case "Ctrl": keys.push("ctrl"); break; case "Alt": keys.push("alt"); break; case "Shift": keys.push("shift"); break; default: // Pass the key through unchanged. `normalizeKey` already uppercases // single characters, and uppercasing here is what used to push // multi-character names past `KEY_DISPLAY` into raw output. keys.push(DISPLAY_KEY_ALIASES[part] ?? part); break; } } return keys; } export function chordStringToShortcutKeys(s: string): ShortcutKey[][] { return s.split(" ").map(comboStringToShortcutKeys); } export function heldModifiersFromEvent(event: KeyboardEvent): string | null { const parts: string[] = []; if (event.ctrlKey) parts.push("Ctrl"); if (event.altKey) parts.push("Alt"); if (event.shiftKey) parts.push("Shift"); if (event.metaKey) parts.push("Cmd"); return parts.length > 0 ? parts.join("+") : null; } export function keyboardEventToComboString(event: KeyboardEvent): string | null { if (MODIFIER_CODES.has(event.code)) { return null; } const parts: string[] = []; if (event.ctrlKey) parts.push("Ctrl"); if (event.altKey) parts.push("Alt"); if (event.shiftKey) parts.push("Shift"); if (event.metaKey) parts.push("Cmd"); const humanKey = CODE_TO_KEY[event.code]; if (humanKey) { parts.push(humanKey); } else { return null; } return parts.join("+"); }