diff --git a/src/lib/keyboardUtils.test.ts b/src/lib/keyboardUtils.test.ts index 7858a26..a026b34 100644 --- a/src/lib/keyboardUtils.test.ts +++ b/src/lib/keyboardUtils.test.ts @@ -39,6 +39,24 @@ describe("normalizeCapturedKey", () => { expect(normalizeCapturedKey(keyEvent("F5", "F5"))).toBe("F5"); }); + it("resolves position-invariant keys from the code, not the character", () => { + expect( + normalizeCapturedKey(keyEvent("\u00A0", "Space", { altKey: true })), + ).toBe("Space"); + expect( + normalizeCapturedKey(keyEvent("ArrowUp", "ArrowUp", { altKey: true })), + ).toBe("Up"); + expect(normalizeCapturedKey(keyEvent("F5", "F5", { altKey: true }))).toBe( + "F5", + ); + }); + + it("keeps reading layout-dependent keys from the character", () => { + // Semicolon is `m` and Digit1 is `&` on AZERTY. + expect(normalizeCapturedKey(keyEvent("m", "Semicolon"))).toBe("M"); + expect(normalizeCapturedKey(keyEvent("&", "Digit1"))).toBe("&"); + }); + it("rejects dead keys and IME", () => { expect(normalizeCapturedKey(keyEvent("Dead", "KeyQ"))).toBeNull(); expect(normalizeCapturedKey(keyEvent("Process", "KeyA"))).toBeNull(); diff --git a/src/lib/keyboardUtils.ts b/src/lib/keyboardUtils.ts index af7378f..19242a4 100644 --- a/src/lib/keyboardUtils.ts +++ b/src/lib/keyboardUtils.ts @@ -9,6 +9,22 @@ const MODIFIER_MAP: Record = { AltRight: "Alt", }; +// Read before KeyboardEvent.key, since macOS Option composes: Opt+Space emits +// U+00A0, not " ". Position-invariant codes only — punctuation and digits move +// between layouts, so those must still come from the key. +const STABLE_LOGICAL_FROM_CODE: Record = { + Space: "Space", + Enter: "Enter", + Tab: "Tab", + Backspace: "Backspace", + Delete: "Delete", + Escape: "Escape", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", +}; + /** Stable logical names for non-printable keys from KeyboardEvent.key */ const LOGICAL_KEY_FROM_EVENT_KEY: Record = { " ": "Space", @@ -96,11 +112,16 @@ export function normalizeModifierFromCode(code: string): string | null { } /** - * Logical non-modifier key token from KeyboardEvent.key. + * Logical non-modifier key token, read from KeyboardEvent.key so it follows the + * active layout — except for position-invariant keys, which come from the code. * Returns null for modifiers, dead keys, IME composition, or unsupported keys. */ export function normalizeCapturedKey(event: KeyboardEvent): string | null { - const { key } = event; + const { key, code } = event; + + const stable = STABLE_LOGICAL_FROM_CODE[code]; + if (stable) return stable; + if (/^F\d{1,2}$/.test(code)) return code; if (key === "Dead" || key === "Process" || key === "Unidentified") { return null;