From a913f02ae85c3f8a1a227d70135fabc1ca9831aa Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 17:05:55 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(web):=20=E6=8C=89=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E5=B8=83=E5=B1=80=E5=8C=B9=E9=85=8D=20macOS?= =?UTF-8?q?=20Option=20=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用键盘布局映射恢复 Option 符号对应的字母,并保持现有不支持场景的回退行为。 统一快捷键匹配和设置录入,处理布局切换、失败及过期异步结果。 通过 53 项定向测试、check:changed 和隔离浏览器修复前后回放验证。 --- .../settings/KeybindingsSettings.logic.ts | 7 +- apps/web/src/keybindings.test.ts | 55 +++++++++++++++ apps/web/src/keybindings.ts | 24 ++++++- apps/web/src/keyboardLayout.test.ts | 69 +++++++++++++++++++ apps/web/src/keyboardLayout.ts | 36 ++++++++++ apps/web/src/main.tsx | 3 + packages/shared/src/keybindings.ts | 25 +++++-- 7 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/keyboardLayout.test.ts create mode 100644 apps/web/src/keyboardLayout.ts diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 3ef6b6315abec..d363cb67043f6 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -392,8 +392,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 @@ -440,7 +441,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 2bbd8c5d5d117..2c834ed4ae715 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -684,6 +684,61 @@ 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("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 3fe5486aed106..fd815df645573 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -17,12 +17,23 @@ import { resolveEventKeys, type ShortcutEventLike, type ShortcutModifierStateLike, + type ShortcutKeyboardLayoutMap, + shortcutKeyFromEvent as sharedShortcutKeyFromEvent, } from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; import { projectScriptIdFromCommand } from "./projectScripts"; +import { getKeyboardLayoutMap } from "./keyboardLayout"; export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; -export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; +export function shortcutKeyFromEvent( + event: Pick & Partial>, + platform = navigator.platform, +) { + return sharedShortcutKeyFromEvent( + event, + isMacPlatform(platform) && event.altKey ? getKeyboardLayoutMap() : undefined, + ); +} export interface ShortcutMatchContext { terminalFocus: boolean; @@ -37,6 +48,7 @@ export interface ShortcutMatchContext { interface ShortcutMatchOptions { platform?: string; + keyboardLayoutMap?: ShortcutKeyboardLayoutMap; context?: Partial; } @@ -183,7 +195,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..01ca0c72e7565 --- /dev/null +++ b/apps/web/src/keyboardLayout.test.ts @@ -0,0 +1,69 @@ +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("records Option symbols using the current layout and updates after a layout change", async () => { + let layout = new Map([["KeyQ", "a"]]); + const keyboard = Object.assign(new EventTarget(), { + getLayoutMap: () => Promise.resolve(layout), + }); + stop = trackKeyboardLayout(keyboard); + 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", + ); + layout = new Map([["KeyQ", "q"]]); + keyboard.dispatchEvent(new Event("layoutchange")); + await keyboard.getLayoutMap(); + assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+q"); + stop(); + 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")); + 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..a90f2751dc6b0 --- /dev/null +++ b/apps/web/src/keyboardLayout.ts @@ -0,0 +1,36 @@ +import type { ShortcutKeyboardLayoutMap } from "@t3tools/shared/keybindings"; + +interface KeyboardLayoutSource extends EventTarget { + 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, +) { + let revision = 0; + const refresh = async () => { + const request = ++revision; + keyboardLayoutMap = undefined; + try { + const layout = await keyboard?.getLayoutMap(); + if (request === revision) keyboardLayoutMap = layout; + } catch { + // Unsupported or permission-blocked browsers keep the existing fallback. + } + }; + void refresh(); + keyboard?.addEventListener("layoutchange", refresh); + return () => { + revision++; + keyboardLayoutMap = undefined; + keyboard?.removeEventListener("layoutchange", refresh); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e2dfab60d81a0..9db5ff1b34f1d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; +import { trackKeyboardLayout } from "./keyboardLayout"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; @@ -16,6 +17,8 @@ import { AppRoot } from "./AppRoot"; import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; prepareProviderAuthDelivery(); +const stopTrackingKeyboardLayout = trackKeyboardLayout(); +import.meta.hot?.dispose(stopTrackingKeyboardLayout); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b511f830f69b5..53be211ccd592 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -87,21 +87,32 @@ export function normalizeEventKey(key: string): string { return normalized; } -export function shortcutKeyFromEvent(event: Pick): string { +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; const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; 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(shortcutKeyFromEvent(event)); + keys.add(shortcutKey); return keys; } @@ -125,6 +136,7 @@ export function matchesKeybindingShortcut( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform: string, + keyboardLayoutMap?: ShortcutKeyboardLayoutMap, ): boolean { if ( !isMacPlatform(platform) && @@ -133,7 +145,10 @@ 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); } type WhenToken = From fa95517b3a6dc834a9d6553382eacc05f658cece Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 17:32:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(web):=20=E4=BF=9D=E7=95=99=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=98=A0=E5=B0=84=E4=B8=8B=E7=9A=84=E7=89=A9=E7=90=86?= =?UTF-8?q?=E6=A0=87=E7=82=B9=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 布局映射新增字母候选时继续保留原有标点候选,不恢复错误的物理字母候选。 新增 Option 标点回归测试,54 项相关测试及 check:changed 全部通过。 --- apps/web/src/keybindings.test.ts | 16 ++++++++++++++++ packages/shared/src/keybindings.ts | 2 ++ 2 files changed, 18 insertions(+) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 2c834ed4ae715..655a13b51e6cc 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -699,6 +699,22 @@ describe("resolveShortcutCommand", () => { ); }); + 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" }, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 53be211ccd592..2b407a66e3e7c 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -113,6 +113,8 @@ export function resolveEventKeys( keys.add(letterCode.toLowerCase()); } keys.add(shortcutKey); + // Layout letters supplement, rather than replace, physical punctuation shortcuts. + keys.add(shortcutKeyFromEvent(event)); return keys; } From d985edb31fe19f5d25e4e72ac58a9b811a52f167 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 7 Nov 2026 21:29:47 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(web):=20=E5=85=BC=E5=AE=B9=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E5=B8=83=E5=B1=80=E4=BA=8B=E4=BB=B6=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=9A=84=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将键盘布局事件监听设为可选,避免 Chromium 启动崩溃。 - 窗口重新获得焦点时刷新布局,并清理监听和过期结果。 - 已通过定向回归测试、变更范围检查和隔离客户端启动及快捷键录制验证。 --- apps/web/src/keyboardLayout.test.ts | 61 +++++++++++++---------- apps/web/src/keyboardLayout.ts | 11 ++-- packages/shared/src/keybindingMatching.ts | 1 - 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/web/src/keyboardLayout.test.ts b/apps/web/src/keyboardLayout.test.ts index 01ca0c72e7565..6aa69cd13feb6 100644 --- a/apps/web/src/keyboardLayout.test.ts +++ b/apps/web/src/keyboardLayout.test.ts @@ -13,33 +13,40 @@ function pendingLayout() { return { promise, resolve }; } -it("records Option symbols using the current layout and updates after a layout change", async () => { - let layout = new Map([["KeyQ", "a"]]); - const keyboard = Object.assign(new EventTarget(), { - getLayoutMap: () => Promise.resolve(layout), - }); - stop = trackKeyboardLayout(keyboard); - 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", - ); - layout = new Map([["KeyQ", "q"]]); - keyboard.dispatchEvent(new Event("layoutchange")); - await keyboard.getLayoutMap(); - assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+q"); - stop(); - assert.strictEqual(getKeyboardLayoutMap(), undefined); -}); +it.each([true, false])( + "records Option symbols and refreshes the current layout (keyboard events: %s)", + async (supportsEvents) => { + let layout = new Map([["KeyQ", "a"]]); + const keyboard = Object.assign(supportsEvents ? new EventTarget() : {}, { + getLayoutMap: () => Promise.resolve(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", + ); + layout = new Map([["KeyQ", "q"]]); + if (keyboard instanceof EventTarget) keyboard.dispatchEvent(new Event("layoutchange")); + else focusTarget.dispatchEvent(new Event("focus")); + await keyboard.getLayoutMap(); + assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+q"); + 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(); diff --git a/apps/web/src/keyboardLayout.ts b/apps/web/src/keyboardLayout.ts index a90f2751dc6b0..56efef6f8fb16 100644 --- a/apps/web/src/keyboardLayout.ts +++ b/apps/web/src/keyboardLayout.ts @@ -1,6 +1,8 @@ import type { ShortcutKeyboardLayoutMap } from "@t3tools/shared/keybindings"; -interface KeyboardLayoutSource extends EventTarget { +interface KeyboardLayoutSource extends Partial< + Pick +> { getLayoutMap(): Promise; } @@ -14,6 +16,7 @@ export function getKeyboardLayoutMap() { // 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 () => { @@ -27,10 +30,12 @@ export function trackKeyboardLayout( } }; void refresh(); - keyboard?.addEventListener("layoutchange", refresh); + keyboard?.addEventListener?.("layoutchange", refresh); + focusTarget?.addEventListener("focus", refresh); return () => { revision++; keyboardLayoutMap = undefined; - keyboard?.removeEventListener("layoutchange", refresh); + keyboard?.removeEventListener?.("layoutchange", refresh); + focusTarget?.removeEventListener("focus", refresh); }; } diff --git a/packages/shared/src/keybindingMatching.ts b/packages/shared/src/keybindingMatching.ts index 6dd9e338160a0..622304981b407 100644 --- a/packages/shared/src/keybindingMatching.ts +++ b/packages/shared/src/keybindingMatching.ts @@ -121,4 +121,3 @@ export function matchesKeybindingShortcut( isMacPlatform(platform) && event.altKey ? keyboardLayoutMap : undefined, ).has(shortcut.key); } - From b076ad39a7ac8b728feddb8a0e612b0356798dd2 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 7 Nov 2026 21:56:50 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(web):=20=E8=81=9A=E7=84=A6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=9C=9F=E9=97=B4=E4=BF=9D=E7=95=99=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 刷新布局映射期间保留旧值,避免 Option 快捷键短暂失效。 - 布局变更或最新请求失败时清除缓存,继续忽略过期结果。 - 回归验证异步刷新、失败与清理路径,check:changed 通过 68 项测试及相关检查。 --- apps/web/src/keyboardLayout.test.ts | 21 +++++++++++++++------ apps/web/src/keyboardLayout.ts | 17 ++++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/web/src/keyboardLayout.test.ts b/apps/web/src/keyboardLayout.test.ts index 6aa69cd13feb6..c19c438c541ed 100644 --- a/apps/web/src/keyboardLayout.test.ts +++ b/apps/web/src/keyboardLayout.test.ts @@ -16,9 +16,9 @@ function pendingLayout() { it.each([true, false])( "records Option symbols and refreshes the current layout (keyboard events: %s)", async (supportsEvents) => { - let layout = new Map([["KeyQ", "a"]]); + let layout = Promise.resolve(new Map([["KeyQ", "a"]])); const keyboard = Object.assign(supportsEvents ? new EventTarget() : {}, { - getLayoutMap: () => Promise.resolve(layout), + getLayoutMap: () => layout, }); const focusTarget = new EventTarget(); stop = trackKeyboardLayout(keyboard, focusTarget); @@ -36,11 +36,19 @@ it.each([true, false])( keybindingFromKeyboardEvent(event, "MacIntel", { layoutCharacter: true }), "mod+alt+shift+a", ); - layout = new Map([["KeyQ", "q"]]); - if (keyboard instanceof EventTarget) keyboard.dispatchEvent(new Event("layoutchange")); - else focusTarget.dispatchEvent(new Event("focus")); - await keyboard.getLayoutMap(); + 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(); @@ -55,6 +63,7 @@ it("ignores an obsolete layout result and clears the map when access fails", asy 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; diff --git a/apps/web/src/keyboardLayout.ts b/apps/web/src/keyboardLayout.ts index 56efef6f8fb16..61fd6e24a1b57 100644 --- a/apps/web/src/keyboardLayout.ts +++ b/apps/web/src/keyboardLayout.ts @@ -19,23 +19,26 @@ export function trackKeyboardLayout( focusTarget: EventTarget | undefined = typeof window === "undefined" ? undefined : window, ) { let revision = 0; - const refresh = async () => { + const refresh = async (clearMap: boolean) => { const request = ++revision; - keyboardLayoutMap = undefined; + 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; } }; - void refresh(); - keyboard?.addEventListener?.("layoutchange", refresh); - focusTarget?.addEventListener("focus", refresh); + 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", refresh); - focusTarget?.removeEventListener("focus", refresh); + keyboard?.removeEventListener?.("layoutchange", onLayoutChange); + focusTarget?.removeEventListener("focus", onFocus); }; } From e6b1f244e6bef9c5b1261f093bfe5cf0e4aadff5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:56:04 +0000 Subject: [PATCH 5/5] refactor(web): require platform when resolving shortcut keys AI-assisted; verified with the tests and tools listed above. --- apps/web/src/keybindings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 16876139d6c07..df954cbbadcb6 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -28,7 +28,7 @@ import { getKeyboardLayoutMap } from "./keyboardLayout"; export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; export function shortcutKeyFromEvent( event: Pick, - platform = navigator.platform, + platform: string, ) { return sharedShortcutKeyFromEvent( event,