diff --git a/src/components/ui/SqlEditorWrapper.tsx b/src/components/ui/SqlEditorWrapper.tsx index 88ff8ea72..e42c18dd5 100644 --- a/src/components/ui/SqlEditorWrapper.tsx +++ b/src/components/ui/SqlEditorWrapper.tsx @@ -15,6 +15,7 @@ import { type Statement, } from "../../utils/sqlSplitter"; import { formatSql } from "../../utils/sqlFormat"; +import { isTextCompositionKeyEvent } from "../../utils/keyboardEvents"; import type { SqlDialect } from "../../utils/sql"; import type { RunContext } from "../../utils/runTarget"; import { @@ -403,6 +404,8 @@ const SqlEditorInternal = ({ // Monaco binds Ctrl+Shift+A to block comments on Linux. Handle the // user-configurable palette shortcut before Monaco consumes it. editor.onKeyDown((e) => { + if (isTextCompositionKeyEvent(e.browserEvent)) return; + const togglePalette = togglePaletteRef.current; if ( togglePalette && diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index f008d44e5..db40fcdc4 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useCommandPaletteDispatch } from "./useCommandPalette"; import { useConnectionManager } from "./useConnectionManager"; import { useKeybindings } from "./useKeybindings"; +import { isTextCompositionKeyEvent } from "../utils/keyboardEvents"; /** Shortcuts that must still fire while the user is typing in a field. */ const TYPING_SAFE_SHORTCUTS = [ @@ -24,6 +25,8 @@ export function useGlobalShortcuts() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (isTextCompositionKeyEvent(e)) return; + const target = e.target as HTMLElement; const isTypingTarget = target.tagName === "INPUT" || diff --git a/src/utils/keyboardEvents.ts b/src/utils/keyboardEvents.ts new file mode 100644 index 000000000..a2059abbc --- /dev/null +++ b/src/utils/keyboardEvents.ts @@ -0,0 +1,9 @@ +export function isTextCompositionKeyEvent(event: KeyboardEvent): boolean { + return ( + event.isComposing || + event.key === "Dead" || + event.key === "Process" || + event.key === "Unidentified" || + event.keyCode === 229 + ); +} diff --git a/tests/components/ui/SqlEditorWrapper.test.tsx b/tests/components/ui/SqlEditorWrapper.test.tsx index 3c00f0148..99bddc81e 100644 --- a/tests/components/ui/SqlEditorWrapper.test.tsx +++ b/tests/components/ui/SqlEditorWrapper.test.tsx @@ -390,6 +390,38 @@ describe('SqlEditorWrapper', () => { expect(trigger).not.toHaveBeenCalled(); }); + it('does not intercept composing editor key events as palette shortcuts', () => { + matchesShortcutMock.mockImplementation( + (_event, id) => id === 'command_palette_actions', + ); + render( + , + { wrapper } + ); + const { keyDownHandlers } = mountCapturedEditor(); + const event = { + browserEvent: new KeyboardEvent('keydown', { + key: 'a', + ctrlKey: true, + shiftKey: true, + isComposing: true, + }), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + keyDownHandlers[0](event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(togglePaletteMock).not.toHaveBeenCalled(); + }); + it('renders without a command palette provider and leaves its shortcut to Monaco', () => { matchesShortcutMock.mockImplementation( (_event, id) => id === 'command_palette_actions', diff --git a/tests/hooks/useGlobalShortcuts.test.ts b/tests/hooks/useGlobalShortcuts.test.ts index 3b6da0bf0..6bbad8969 100644 --- a/tests/hooks/useGlobalShortcuts.test.ts +++ b/tests/hooks/useGlobalShortcuts.test.ts @@ -58,6 +58,39 @@ describe("useGlobalShortcuts", () => { input.remove(); }); + it("ignores composing key events while focus is inside an input", () => { + renderHook(() => useGlobalShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + fireEvent.keyDown(input, { + key: "a", + metaKey: true, + shiftKey: true, + isComposing: true, + }); + + expect(togglePaletteMock).not.toHaveBeenCalled(); + input.remove(); + }); + + it("ignores dead-key events while focus is inside an input", () => { + renderHook(() => useGlobalShortcuts()); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + fireEvent.keyDown(input, { + key: "Dead", + metaKey: true, + shiftKey: true, + }); + + expect(togglePaletteMock).not.toHaveBeenCalled(); + input.remove(); + }); + it("should open object search through the shared palette controller", () => { activeShortcutId = "quick_navigator"; renderHook(() => useGlobalShortcuts()); diff --git a/tests/utils/keyboardEvents.test.ts b/tests/utils/keyboardEvents.test.ts new file mode 100644 index 000000000..f403cdd0a --- /dev/null +++ b/tests/utils/keyboardEvents.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isTextCompositionKeyEvent } from "../../src/utils/keyboardEvents"; + +describe("keyboardEvents", () => { + describe("isTextCompositionKeyEvent", () => { + it("detects active IME composition", () => { + const event = new KeyboardEvent("keydown", { key: "a", isComposing: true }); + expect(isTextCompositionKeyEvent(event)).toBe(true); + }); + + it("detects dead keys", () => { + const event = new KeyboardEvent("keydown", { key: "Dead" }); + expect(isTextCompositionKeyEvent(event)).toBe(true); + }); + + it("detects IME process keys", () => { + const event = new KeyboardEvent("keydown", { key: "Process" }); + expect(isTextCompositionKeyEvent(event)).toBe(true); + }); + + it("detects unidentified keys", () => { + const event = new KeyboardEvent("keydown", { key: "Unidentified" }); + expect(isTextCompositionKeyEvent(event)).toBe(true); + }); + + it("detects legacy IME keyCode 229", () => { + const event = new KeyboardEvent("keydown", { key: "a", keyCode: 229 } as KeyboardEventInit); + expect(isTextCompositionKeyEvent(event)).toBe(true); + }); + + it("lets ordinary shortcut key events through", () => { + const event = new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + shiftKey: true, + }); + expect(isTextCompositionKeyEvent(event)).toBe(false); + }); + }); +});