mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* fix(app): record shortcuts using the keyboard layout character Shortcut capture stored the QWERTY position while dispatch matched the logical character, so a Dvorak B became Cmd+N. Use the existing logical key mappings for capture and preserve code fallback for named and rewritten Option keys. * fix(app): keep named shortcut keys on their supported codes Character capture must not advertise unsupported named keys. Restrict the logical lookup to printable mappings so keypad Enter keeps its existing unsupported behavior. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
287 lines
8.2 KiB
TypeScript
287 lines
8.2 KiB
TypeScript
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<string, KeyMapping> = {};
|
|
|
|
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<string, string> = {};
|
|
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<string, string> = {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Whether a keydown carries a modifier key itself rather than a key pressed
|
|
* with one. The browser emits one of these before every combo that holds a
|
|
* modifier, and it is the prefix of a combo, never a combo of its own.
|
|
*/
|
|
export function isModifierKeyCode(code: string): boolean {
|
|
return MODIFIER_CODES.has(code);
|
|
}
|
|
|
|
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
|
if (isModifierKeyCode(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");
|
|
|
|
// Capture the same logical character that dispatch matches. Keep the code
|
|
// fallback for named keys and Option combinations whose key is rewritten.
|
|
const eventKey = event.key.toLowerCase();
|
|
const logicalKey = Object.entries(KEY_MAP).find(
|
|
([, mapping]) =>
|
|
mapping.key?.length === 1 &&
|
|
(mapping.key.toLowerCase() === eventKey ||
|
|
(event.shiftKey && mapping.shiftedKey === event.key)),
|
|
)?.[0];
|
|
const humanKey = logicalKey ?? CODE_TO_KEY[event.code];
|
|
if (humanKey) {
|
|
parts.push(humanKey);
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
return parts.join("+");
|
|
}
|