diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 1946922f59dc4..2ec24ad3ab3cb 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -404,8 +404,9 @@ function titleCaseCommandSegment(segment: string): string { function recordedLayoutShortcutKey( event: Pick, + platform: string, ): string { - const physicalKey = shortcutKeyFromEvent(event); + const physicalKey = shortcutKeyFromEvent(event, platform); const layoutKey = event.key.toLowerCase(); if (event.altKey || layoutKey.length !== 1) return physicalKey; // Normalize only US shifted glyphs at their US position. Other layouts can @@ -452,7 +453,9 @@ export function keybindingFromKeyboardEvent( }, ): string | null { const keyToken = normalizeShortcutKeyToken( - options?.layoutCharacter ? recordedLayoutShortcutKey(event) : shortcutKeyFromEvent(event), + options?.layoutCharacter + ? recordedLayoutShortcutKey(event, platform) + : shortcutKeyFromEvent(event, platform), ); if (!keyToken) return null; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 3b596f838fa0d..adb79b2be804e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -757,6 +757,77 @@ describe("resolveShortcutCommand", () => { ); }); + it("matches Option symbols against the active Latin layout rather than the US position", () => { + const keybindings = compile([ + { shortcut: modShortcut("a", { altKey: true, shiftKey: true }), command: "appearance.cycle" }, + { shortcut: modShortcut("q", { altKey: true, shiftKey: true }), command: "sidebar.toggle" }, + ]); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Æ", code: "KeyQ", metaKey: true, altKey: true, shiftKey: true }), + keybindings, + { platform: "MacIntel", keyboardLayoutMap: new Map([["KeyQ", "a"]]) }, + ), + "appearance.cycle", + ); + }); + + it("preserves physical punctuation alongside an Option-modified mapped letter", () => { + for (const key of [";", "m"]) { + const keybindings = compile([ + { shortcut: modShortcut(key, { altKey: true }), command: "diff.toggle" }, + ]); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "µ", code: "Semicolon", metaKey: true, altKey: true }), + keybindings, + { platform: "MacIntel", keyboardLayoutMap: new Map([["Semicolon", "m"]]) }, + ), + "diff.toggle", + ); + } + }); + + it("keeps Latin event keys and unsupported layout fallbacks", () => { + const keybindings = compile([ + { shortcut: modShortcut("a", { altKey: true }), command: "appearance.cycle" }, + { shortcut: modShortcut("q", { altKey: true }), command: "sidebar.toggle" }, + ]); + const options = { platform: "MacIntel", keyboardLayoutMap: new Map([["KeyQ", "a"]]) }; + assert.strictEqual( + resolveShortcutCommand( + event({ key: "q", code: "KeyQ", metaKey: true, altKey: true }), + keybindings, + options, + ), + "sidebar.toggle", + ); + for (const keyboardLayoutMap of [new Map(), new Map([["KeyQ", "й"]])]) { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Æ", code: "KeyQ", metaKey: true, altKey: true }), + keybindings, + { ...options, keyboardLayoutMap }, + ), + "sidebar.toggle", + ); + } + assert.strictEqual( + resolveShortcutCommand( + event({ + key: "Æ", + code: "KeyQ", + ctrlKey: true, + altKey: true, + getModifierState: () => true, + }), + keybindings, + { ...options, platform: "Win32" }, + ), + null, + ); + }); + it("matches non-Latin layout letters using the physical key code", () => { const keybindings = compile([{ shortcut: modShortcut("d"), command: "diff.toggle" }]); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index d2f6a5e650417..df954cbbadcb6 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -17,13 +17,24 @@ import { resolveEventKeys, type ShortcutEventLike as SharedShortcutEventLike, type ShortcutModifierStateLike, + type ShortcutKeyboardLayoutMap, + shortcutKeyFromEvent as sharedShortcutKeyFromEvent, } from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; import { isEditableFocused } from "./lib/editableFocus"; import { projectScriptIdFromCommand } from "./projectScripts"; +import { getKeyboardLayoutMap } from "./keyboardLayout"; export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; -export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; +export function shortcutKeyFromEvent( + event: Pick, + platform: string, +) { + return sharedShortcutKeyFromEvent( + event, + isMacPlatform(platform) && event.altKey ? getKeyboardLayoutMap() : undefined, + ); +} export interface ShortcutEventLike extends SharedShortcutEventLike { target?: EventTarget | null; @@ -42,6 +53,7 @@ export interface ShortcutMatchContext { interface ShortcutMatchOptions { platform?: string; + keyboardLayoutMap?: ShortcutKeyboardLayoutMap; context?: Partial; } @@ -191,7 +203,15 @@ export function resolveShortcutCommand( const binding = keybindings[index]; if (!binding) continue; if (!matchesWhenClause(binding.whenAst, context)) continue; - if (!matchesKeybindingShortcut(event, shortcutForContext(binding, context), platform)) continue; + if ( + !matchesKeybindingShortcut( + event, + shortcutForContext(binding, context), + platform, + options?.keyboardLayoutMap ?? getKeyboardLayoutMap(), + ) + ) + continue; return binding.command; } return null; diff --git a/apps/web/src/keyboardLayout.test.ts b/apps/web/src/keyboardLayout.test.ts new file mode 100644 index 0000000000000..c19c438c541ed --- /dev/null +++ b/apps/web/src/keyboardLayout.test.ts @@ -0,0 +1,85 @@ +import { afterEach, assert, it } from "vite-plus/test"; +import { getKeyboardLayoutMap, trackKeyboardLayout } from "./keyboardLayout"; +import { keybindingFromKeyboardEvent } from "./components/settings/KeybindingsSettings.logic"; + +let stop: (() => void) | undefined; +afterEach(() => stop?.()); + +function pendingLayout() { + let resolve!: (layout: Map) => void; + const promise = new Promise>((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +it.each([true, false])( + "records Option symbols and refreshes the current layout (keyboard events: %s)", + async (supportsEvents) => { + let layout = Promise.resolve(new Map([["KeyQ", "a"]])); + const keyboard = Object.assign(supportsEvents ? new EventTarget() : {}, { + getLayoutMap: () => layout, + }); + const focusTarget = new EventTarget(); + stop = trackKeyboardLayout(keyboard, focusTarget); + await keyboard.getLayoutMap(); + const event = { + key: "Æ", + code: "KeyQ", + metaKey: true, + ctrlKey: false, + altKey: true, + shiftKey: true, + }; + assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+a"); + assert.strictEqual( + keybindingFromKeyboardEvent(event, "MacIntel", { layoutCharacter: true }), + "mod+alt+shift+a", + ); + const pending = pendingLayout(); + layout = pending.promise; + focusTarget.dispatchEvent(new Event("focus")); + assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+a"); + pending.resolve(new Map([["KeyQ", "q"]])); + await layout; + assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+q"); + layout = Promise.reject(new Error("Permission denied")); + focusTarget.dispatchEvent(new Event("focus")); + assert.strictEqual(getKeyboardLayoutMap()?.get("KeyQ"), "q"); + await layout.catch(() => undefined); + assert.strictEqual(getKeyboardLayoutMap(), undefined); + layout = Promise.resolve(new Map([["KeyQ", "a"]])); + stop(); + focusTarget.dispatchEvent(new Event("focus")); + await keyboard.getLayoutMap(); + assert.strictEqual(getKeyboardLayoutMap(), undefined); + }, +); + +it("ignores an obsolete layout result and clears the map when access fails", async () => { + const pending = pendingLayout(); + let result = pending.promise; + const keyboard = Object.assign(new EventTarget(), { getLayoutMap: () => result }); + stop = trackKeyboardLayout(keyboard); + result = Promise.resolve(new Map([["KeyQ", "q"]])); + keyboard.dispatchEvent(new Event("layoutchange")); + assert.strictEqual(getKeyboardLayoutMap(), undefined); + await result; + pending.resolve(new Map([["KeyQ", "a"]])); + await pending.promise; + assert.strictEqual(getKeyboardLayoutMap()?.get("KeyQ"), "q"); + result = Promise.reject(new Error("Permission denied")); + keyboard.dispatchEvent(new Event("layoutchange")); + await result.catch(() => undefined); + assert.strictEqual(getKeyboardLayoutMap(), undefined); +}); + +it("does not publish a pending result after disposal", async () => { + const pending = pendingLayout(); + const keyboard = Object.assign(new EventTarget(), { getLayoutMap: () => pending.promise }); + stop = trackKeyboardLayout(keyboard); + stop(); + pending.resolve(new Map([["KeyQ", "a"]])); + await pending.promise; + assert.strictEqual(getKeyboardLayoutMap(), undefined); +}); diff --git a/apps/web/src/keyboardLayout.ts b/apps/web/src/keyboardLayout.ts new file mode 100644 index 0000000000000..61fd6e24a1b57 --- /dev/null +++ b/apps/web/src/keyboardLayout.ts @@ -0,0 +1,44 @@ +import type { ShortcutKeyboardLayoutMap } from "@t3tools/shared/keybindings"; + +interface KeyboardLayoutSource extends Partial< + Pick +> { + getLayoutMap(): Promise; +} + +let keyboardLayoutMap: ShortcutKeyboardLayoutMap | undefined; + +export function getKeyboardLayoutMap() { + return keyboardLayoutMap; +} + +// Option changes event.key to a symbol on macOS; the layout map recovers the +// letter printed on that key without assuming a US physical layout. +export function trackKeyboardLayout( + keyboard = (navigator as Navigator & { keyboard?: KeyboardLayoutSource }).keyboard, + focusTarget: EventTarget | undefined = typeof window === "undefined" ? undefined : window, +) { + let revision = 0; + const refresh = async (clearMap: boolean) => { + const request = ++revision; + if (clearMap) keyboardLayoutMap = undefined; + try { + const layout = await keyboard?.getLayoutMap(); + if (request === revision) keyboardLayoutMap = layout; + } catch { + // Unsupported or permission-blocked browsers keep the existing fallback. + if (request === revision) keyboardLayoutMap = undefined; + } + }; + const onLayoutChange = () => void refresh(true); + const onFocus = () => void refresh(false); + void refresh(true); + keyboard?.addEventListener?.("layoutchange", onLayoutChange); + focusTarget?.addEventListener("focus", onFocus); + return () => { + revision++; + keyboardLayoutMap = undefined; + keyboard?.removeEventListener?.("layoutchange", onLayoutChange); + focusTarget?.removeEventListener("focus", onFocus); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4469f9cbf6ba3..be54c7610f0e8 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client"; import "@fontsource-variable/inter"; import "./index.css"; +import { trackKeyboardLayout } from "./keyboardLayout"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { isElectron } from "./env"; import { startAppServices } from "./appInit"; @@ -11,6 +12,9 @@ import { AppRoot } from "./AppRoot"; import { whenLocaleReady } from "./i18n/runtime"; import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +const stopTrackingKeyboardLayout = trackKeyboardLayout(); +import.meta.hot?.dispose(stopTrackingKeyboardLayout); + const router = startAppServices(); const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; diff --git a/packages/shared/src/keybindingMatching.ts b/packages/shared/src/keybindingMatching.ts index e6525b53d8777..06ae1c761569b 100644 --- a/packages/shared/src/keybindingMatching.ts +++ b/packages/shared/src/keybindingMatching.ts @@ -56,11 +56,16 @@ export function normalizeEventKey(key: string): string { return normalized; } +export type ShortcutKeyboardLayoutMap = Pick, "get">; + export function shortcutKeyFromEvent( event: Pick, + keyboardLayoutMap?: ShortcutKeyboardLayoutMap, ): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; + const mappedKey = event.code ? keyboardLayoutMap?.get(event.code)?.toLowerCase() : undefined; + if (mappedKey && /^[a-z]$/.test(mappedKey)) return mappedKey; // An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic); Czech types it on Digit1. const isZoomPlus = layoutKey === "+" && !event.shiftKey && !event.altKey; if (isZoomPlus && !event.code?.startsWith("Digit")) return layoutKey; @@ -68,13 +73,19 @@ export function shortcutKeyFromEvent( return physicalKey ?? layoutKey; } -export function resolveEventKeys(event: ShortcutEventLike): Set { +export function resolveEventKeys( + event: ShortcutEventLike, + keyboardLayoutMap?: ShortcutKeyboardLayoutMap, +): Set { const layoutKey = normalizeEventKey(event.key); const keys = new Set([layoutKey]); const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; - if (letterCode && !/^[a-z]$/.test(layoutKey)) { + const shortcutKey = shortcutKeyFromEvent(event, keyboardLayoutMap); + if (letterCode && !/^[a-z]$/.test(shortcutKey)) { keys.add(letterCode.toLowerCase()); } + keys.add(shortcutKey); + // Layout letters supplement, rather than replace, physical punctuation shortcuts. keys.add(shortcutKeyFromEvent(event)); return keys; } @@ -99,6 +110,7 @@ export function matchesKeybindingShortcut( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform: string, + keyboardLayoutMap?: ShortcutKeyboardLayoutMap, ): boolean { if ( !isMacPlatform(platform) && @@ -107,5 +119,8 @@ export function matchesKeybindingShortcut( ) return false; if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false; - return resolveEventKeys(event).has(shortcut.key); + return resolveEventKeys( + event, + isMacPlatform(platform) && event.altKey ? keyboardLayoutMap : undefined, + ).has(shortcut.key); } diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 9a67264d8bd1f..fbcbbfa8a8606 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,6 +20,7 @@ export { shortcutKeyFromEvent, type ShortcutEventLike, type ShortcutModifierStateLike, + type ShortcutKeyboardLayoutMap, } from "./keybindingMatching.ts"; export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap = new Map([