diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index 6a8b32756d..835257eb14 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -17,6 +17,7 @@ import { tabSwitcherDirectionFromShortcut, } from "@/lib/editor/keyboardShortcuts"; import { formatShortcutDisplay, isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; +import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; describe("keyboard shortcut matching", () => { it("records modifier-only mouse shortcut settings", () => { @@ -44,6 +45,31 @@ describe("keyboard shortcut matching", () => { expect(eventToShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Win32")).toBe("Shift+Mod+Plus"); }); + it("records shifted punctuation by its unshifted key so CodeMirror can resolve the binding", () => { + // CodeMirror registers and looks up "Shift-" bindings against the physical + // key ("/"), never the shifted character ("?"), so recording "?" produced a + // binding that could never fire. + expect(eventToShortcut({ key: "?", code: "Slash", ctrlKey: true, shiftKey: true }, "Win32")).toBe("Shift+Mod+/"); + expect(shortcutToCodeMirrorKey(eventToShortcut({ key: "?", code: "Slash", ctrlKey: true, shiftKey: true }, "Win32")!)).toBe("Shift-Mod-/"); + + expect(eventToShortcut({ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }, "Win32")).toBe("Shift+Mod+1"); + expect(eventToShortcut({ key: ":", code: "Semicolon", altKey: true, shiftKey: true }, "Win32")).toBe("Shift+Alt+;"); + }); + + it("matches a press against a shortcut stored with the unshifted key", () => { + const press = { key: "?", code: "Slash", ctrlKey: true, shiftKey: true }; + expect(matchesShortcut(press, "Shift+Mod+/", "Win32")).toBe(true); + // The shifted character still matches, so shortcuts saved by older versions keep working. + expect(matchesShortcut(press, "Shift+Mod+?", "Win32")).toBe(true); + // A different physical key must not match. + expect(matchesShortcut({ key: "?", code: "Period", ctrlKey: true, shiftKey: true }, "Shift+Mod+/", "Win32")).toBe(false); + }); + + it("leaves unshifted and letter keys untouched", () => { + expect(eventToShortcut({ key: "/", code: "Slash", ctrlKey: true }, "Win32")).toBe("Mod+/"); + expect(eventToShortcut({ key: "A", code: "KeyA", ctrlKey: true, shiftKey: true }, "Win32")).toBe("Shift+Mod+A"); + }); + it.each([ ["¨", "KeyU", "Shift+Alt+U"], ["Ò", "KeyL", "Shift+Alt+L"], @@ -89,13 +115,37 @@ describe("keyboard shortcut matching", () => { const combinedShortcut = eventToShortcut({ key: "b", ctrlKey: true, metaKey: true, shiftKey: true, altKey: true }, "MacIntel"); expect(combinedShortcut).toBe("Shift+Ctrl+Mod+Alt+B"); - expect(matchesShortcut({ key: "b", ctrlKey: true, metaKey: true, shiftKey: true, altKey: true }, combinedShortcut!, "MacIntel")).toBe(true); + expect( + matchesShortcut( + { + key: "b", + ctrlKey: true, + metaKey: true, + shiftKey: true, + altKey: true, + }, + combinedShortcut!, + "MacIntel", + ), + ).toBe(true); expect(matchesShortcut({ key: "b", ctrlKey: true, shiftKey: true, altKey: true }, combinedShortcut!, "MacIntel")).toBe(false); expect(matchesShortcut({ key: "b", metaKey: true, shiftKey: true, altKey: true }, combinedShortcut!, "MacIntel")).toBe(false); const nonMacCombinedShortcut = eventToShortcut({ key: "b", ctrlKey: true, metaKey: true, shiftKey: true, altKey: true }, "Win32"); expect(nonMacCombinedShortcut).toBe("Shift+Mod+Meta+Alt+B"); - expect(matchesShortcut({ key: "b", ctrlKey: true, metaKey: true, shiftKey: true, altKey: true }, nonMacCombinedShortcut!, "Win32")).toBe(true); + expect( + matchesShortcut( + { + key: "b", + ctrlKey: true, + metaKey: true, + shiftKey: true, + altKey: true, + }, + nonMacCombinedShortcut!, + "Win32", + ), + ).toBe(true); expect(matchesShortcut({ key: "b", ctrlKey: true, shiftKey: true, altKey: true }, nonMacCombinedShortcut!, "Win32")).toBe(false); expect(matchesShortcut({ key: "b", metaKey: true, shiftKey: true, altKey: true }, nonMacCombinedShortcut!, "Win32")).toBe(false); }); @@ -109,7 +159,11 @@ describe("keyboard shortcut matching", () => { const isMac = isMacShortcutPlatform(); const platformModEvent = isMac ? { key: "\\", metaKey: true } : { key: "\\", ctrlKey: true }; - expect(isExecuteSqlInNewResultTabShortcut(platformModEvent, { executeSqlInNewResultTab: "Mod+\\" })).toBe(true); + expect( + isExecuteSqlInNewResultTabShortcut(platformModEvent, { + executeSqlInNewResultTab: "Mod+\\", + }), + ).toBe(true); expect(isExecuteSqlInNewResultTabShortcut({ ...platformModEvent, shiftKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(false); expect(isExecuteSqlInNewResultTabShortcut({ key: "\\", ctrlKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(!isMac); expect(isExecuteSqlInNewResultTabShortcut({ key: "\\", metaKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(true); @@ -118,7 +172,11 @@ describe("keyboard shortcut matching", () => { it("matches the configurable Zen mode shortcut", () => { const platformModEvent = isMacShortcutPlatform() ? { key: "F12", metaKey: true, shiftKey: true } : { key: "F12", ctrlKey: true, shiftKey: true }; - expect(isToggleZenModeShortcut(platformModEvent, { toggleZenMode: "Shift+Mod+F12" })).toBe(true); + expect( + isToggleZenModeShortcut(platformModEvent, { + toggleZenMode: "Shift+Mod+F12", + }), + ).toBe(true); expect(isToggleZenModeShortcut({ ...platformModEvent, shiftKey: false }, { toggleZenMode: "Shift+Mod+F12" })).toBe(false); expect(isToggleZenModeShortcut(platformModEvent, { toggleZenMode: "" })).toBe(false); }); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 9fd2d8c48e..e911939f8b 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -17,8 +17,51 @@ function normalizeKey(key: string): string { return key.length === 1 ? key.toLowerCase() : key; } +// Physical keys whose unshifted character CodeMirror always registers bindings +// under. `KeyboardEvent.key` reports the *shifted* character instead ("/" -> "?", +// "1" -> "!"), so a shortcut recorded from that character can never match the +// binding CodeMirror looks up. Letters are excluded: they already round-trip +// because `shortcutToCodeMirrorKey` lowercases them. +const BASE_KEY_BY_CODE: Record = { + Digit0: "0", + Digit1: "1", + Digit2: "2", + Digit3: "3", + Digit4: "4", + Digit5: "5", + Digit6: "6", + Digit7: "7", + Digit8: "8", + Digit9: "9", + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", +}; + +/** + * The unshifted character for a shifted punctuation/digit press, or null when + * the event is not one. Mirrors CodeMirror's `base[event.keyCode]` table, which + * is what its keymap resolves `Shift-` bindings against. + */ +function baseKeyForShiftedEvent(event: ShortcutLikeEvent): string | null { + if (!event.shiftKey || event.key.length !== 1) return null; + const baseKey = BASE_KEY_BY_CODE[event.code ?? ""]; + return baseKey && baseKey !== event.key ? baseKey : null; +} + function matchesShortcutKey(event: ShortcutLikeEvent, key: string, platform = globalThis.navigator?.platform || ""): boolean { if (normalizeKey(event.key) === normalizeKey(key)) return true; + // Shortcuts store the unshifted character for shifted punctuation/digits (see + // baseKeyForShiftedEvent), so match those against the physical key too. + if (baseKeyForShiftedEvent(event) === normalizeKey(key)) return true; // KeyboardEvent.code 回退:event.key 随布局/修饰键变形时按物理键位匹配 // (macOS Option+字母 → 变形字符如 ⌥W="∑";俄文等非拉丁布局 → "Ц")。 // 仅限 Alt 组合键场景 @@ -41,6 +84,10 @@ function shortcutKeyNameFromEvent(event: ShortcutLikeEvent, platform: string): s if (event.altKey && isMacShortcutPlatform(platform) && /^Key[A-Z]$/.test(event.code ?? "")) { return event.code!.slice(3); } + // Record "Shift+Mod+/" rather than "Shift+Mod+?" so the CodeMirror binding + // ("Shift-Ctrl-/") is one the keymap actually resolves for this key press. + const baseKey = baseKeyForShiftedEvent(event); + if (baseKey) return shortcutKeyName(baseKey); return shortcutKeyName(event.key); }