From f73c0a6b1dfab084076e2a9c677679dc06af67e4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:16:35 +0800 Subject: [PATCH] fix(keymap): Don't run the Enter that commits an input-method candidate WebKit sends the Enter (or Space) that commits a candidate after compositionend, with isComposing already false and keyCode 229. Handlers that checked isComposing alone ran it: dialogs confirmed and shortcuts fired while typing Chinese or Japanese. isImeKey() checks both, and every keydown handler that skipped composition uses it. --- src/features/database/Dialogs.tsx | 3 ++- src/features/database/SourceDialog.tsx | 3 ++- src/features/debugger/ui/Overlays.tsx | 3 ++- src/features/macros/index.ts | 3 ++- src/features/search/everywhere/SearchEverywhere.tsx | 3 ++- src/features/settings/index.ts | 3 ++- src/features/terminal/keys.ts | 3 ++- src/platform/ime.ts | 8 ++++++++ src/platform/keymap.ts | 3 ++- src/ui/Dialog.tsx | 3 ++- 10 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 src/platform/ime.ts diff --git a/src/features/database/Dialogs.tsx b/src/features/database/Dialogs.tsx index 3a1cd7c8..13fc362d 100644 --- a/src/features/database/Dialogs.tsx +++ b/src/features/database/Dialogs.tsx @@ -6,6 +6,7 @@ import { sshLabel } from "./model"; import { SourceDialog } from "./SourceDialog"; import { closeDialog, useDatabase } from "./store"; import styles from "./Database.module.css"; +import { isImeKey } from "@/platform/ime"; const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs)); const ExportDialog = lazyComponent(() => import("./ExportDialog").then((m) => m.ExportDialog)); @@ -138,7 +139,7 @@ function ParamsDialog({
{ - if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + if (e.key !== "Enter" || isImeKey(e.nativeEvent)) return; e.preventDefault(); done(values); }} diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index 2929f361..9bca7f2b 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -23,6 +23,7 @@ import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, s import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store"; import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors"; import styles from "./Database.module.css"; +import { isImeKey } from "@/platform/ime"; type Tab = "general" | "options" | "ssh" | "schemas" | "advanced"; @@ -284,7 +285,7 @@ function NamePicker({ aria-label={`Add ${title} pattern`} onChange={(e) => setPattern(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing) { + if (e.key === "Enter" && !isImeKey(e.nativeEvent)) { e.preventDefault(); add(); } diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index 570f1a97..374df949 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -33,6 +33,7 @@ import { closeBreakpointPopover, useOverlays } from "./popoverStore"; import { VariablesView, type VarRoot } from "./VariablesView"; import { pushHistory, readHistory } from "./expressionHistory"; import styles from "./Overlays.module.css"; +import { isImeKey } from "@/platform/ime"; export function DebuggerOverlays() { return ( @@ -710,7 +711,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) { }); }; const onKeyDown = (e: KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; + if (isImeKey(e.nativeEvent)) return; const el = e.currentTarget; if (e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey)) { e.preventDefault(); diff --git a/src/features/macros/index.ts b/src/features/macros/index.ts index 8ec9323d..3c483796 100644 --- a/src/features/macros/index.ts +++ b/src/features/macros/index.ts @@ -21,6 +21,7 @@ import { useMacros, type MacroStep, } from "./model"; +import { isImeKey } from "@/platform/ime"; /** Records typing and editor keys of every text editor while a macro records. */ const recorder = [ @@ -31,7 +32,7 @@ const recorder = [ EditorView.domEventHandlers({ keydown(e) { // Keys the IDE keymap ran as actions were recorded as those actions. - if (e.defaultPrevented || e.isComposing || !useMacros.getState().recording) return false; + if (e.defaultPrevented || isImeKey(e) || !useMacros.getState().recording) return false; const key = editorKeyName(e); if (key) recordStep({ kind: "key", key }); return false; diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index ab32e53d..d55ff6ec 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -57,6 +57,7 @@ import { widenSearchEverywhere, } from "./store"; import styles from "./SearchEverywhere.module.css"; +import { isImeKey } from "@/platform/ime"; type SeScope = "allPlaces" | ProjectFileSet; @@ -315,7 +316,7 @@ function SearchEverywhereDialog() { }; const onKeyDown = (e: KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; + if (isImeKey(e.nativeEvent)) return; if (e.key === "Tab") { e.preventDefault(); e.stopPropagation(); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index f7f7cdc0..1643d0c4 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -33,6 +33,7 @@ import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut"; import { settingsSearchEntries } from "./searchIndex"; +import { isImeKey } from "@/platform/ime"; export { settingsWindowParent } from "./window/bridge"; export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindow").then((m) => m.SettingsWindow)); @@ -165,7 +166,7 @@ function listenLaunch(): () => void { /** Esc and ⌘W cancel, like closing the window; ⌘, stays here. */ function installWindowKeys(): () => void { const onKey = (e: KeyboardEvent) => { - if (e.defaultPrevented || e.isComposing) return; + if (e.defaultPrevented || isImeKey(e)) return; const mod = e.metaKey || e.ctrlKey; if ((e.key === "Escape" && !isPopupOpen()) || (mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "w")) { e.preventDefault(); diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts index bd77624f..68bc13ec 100644 --- a/src/features/terminal/keys.ts +++ b/src/features/terminal/keys.ts @@ -18,6 +18,7 @@ import { executeAction } from "@/platform/actions"; import { effectiveBindings, strokeOf } from "@/platform/keymap"; import { workflowSettings } from "@/platform/workflowSettings"; import { terminalRuntime } from "./loader"; +import { isImeKey } from "@/platform/ime"; export type TerminalKeyResult = | { kind: "send"; data: string } @@ -128,7 +129,7 @@ export function isAllowedIdeShortcut(stroke: string, bindings: ReadonlyArray void { const onKeyDown = (e: KeyboardEvent) => { const rt = terminalRuntime(); - if (e.isComposing || !rt) return; + if (isImeKey(e) || !rt) return; // The terminal find bar: Escape closes it (instead of "focus editor"), ⌘F reselects the query. if ((e.target as Element | null)?.closest?.("[data-terminal-find]")) { const plain = !e.ctrlKey && !e.altKey && !e.shiftKey; diff --git a/src/platform/ime.ts b/src/platform/ime.ts new file mode 100644 index 00000000..389e1324 --- /dev/null +++ b/src/platform/ime.ts @@ -0,0 +1,8 @@ +/** + * True while an input method is composing, including the key that ends it: WebKit sends the + * Enter / Space that commits a candidate after `compositionend`, with `isComposing` already + * false and keyCode 229, so a handler checking `isComposing` alone runs that Enter. + */ +export function isImeKey(e: KeyboardEvent): boolean { + return e.isComposing || e.keyCode === 229; +} diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 596c7033..2a900886 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -18,6 +18,7 @@ import { intellijWindows } from "./keymaps/intellij-windows"; import { vscodeMacos } from "./keymaps/vscode-macos"; import { vscodeWindows } from "./keymaps/vscode-windows"; import { usePlatform } from "./platformInfo"; +import { isImeKey } from "./ime"; export type Keymap = Record; @@ -439,7 +440,7 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu if (runHeldGesture(e)) return reset(); resetTap(); otherKeyAt = e.key === "Escape" || e.key === "Tab" ? -Infinity : now(); - if (e.isComposing) return; + if (isImeKey(e)) return; const stroke = strokeOf(e); if (!stroke) return; const chord = pending.length > 0; diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx index 9e08fbf2..d840d77a 100644 --- a/src/ui/Dialog.tsx +++ b/src/ui/Dialog.tsx @@ -8,6 +8,7 @@ import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState" import { useTx } from "@/platform/i18n"; import { selectableText } from "./selectableText"; import styles from "./Dialog.module.css"; +import { isImeKey } from "@/platform/ime"; /** * `default`: filled accent button, triggered by Enter. `cancel`: Esc / close @@ -256,7 +257,7 @@ function DialogInner({ onCloseRef.current(); } else if (e.key === "Tab") { trapTab(e, ref.current); - } else if (e.key === "Enter" && !e.defaultPrevented && !e.isComposing && !e.shiftKey && !e.altKey) { + } else if (e.key === "Enter" && !e.defaultPrevented && !isImeKey(e) && !e.shiftKey && !e.altKey) { const t = e.target instanceof Element ? e.target : null; const mod = e.metaKey || e.ctrlKey; if (t && t !== document.body && !ref.current.contains(t)) return;