diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9675e33033..ee5fdcb75b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2,6 +2,7 @@ import QueryTimingDetails from "./QueryTimingDetails.vue"; import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage"; import { applyDdlSearchMarks } from "@/lib/sql/ddlSearchMarks"; +import { isAiPromptImeCompositionEvent } from "@/lib/ai/aiPromptKeyboard"; import DdlStorageToggle from "@/components/objects/DdlStorageToggle.vue"; import { useUpdateBlocker } from "@/lib/app/updatePreparation"; @@ -10818,7 +10819,14 @@ async function onTemporalCellEditorSave() { } } +const cellEditCompositionActive = ref(false); + async function onCellEditKeydown(event: KeyboardEvent) { + // The composition guard must also cover the save-shortcut and inline bulk + // edit branches below: they run before the delegation to onEditKeydown and + // reuse the same cell edit input, so an IME Enter would otherwise commit + // the bulk fill (#12470). + if (isAiPromptImeCompositionEvent(event, cellEditCompositionActive.value)) return; if (isSaveShortcut(event, settingsStore.editorSettings.shortcuts)) { event.preventDefault(); event.stopPropagation(); @@ -10841,7 +10849,7 @@ async function onCellEditKeydown(event: KeyboardEvent) { } return; } - onEditKeydown(event); + onEditKeydown(event, cellEditCompositionActive.value); } function undoGridChange(): boolean { @@ -14850,6 +14858,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @click.stop @focus="onCellEditTextareaInput" @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" @wheel.stop @@ -14865,6 +14875,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @blur="commitEditFromCellBlur" @click.stop @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" /> @@ -15550,6 +15562,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @click.stop @focus="onCellEditTextareaInput" @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" @wheel.stop @@ -15565,6 +15579,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @blur="commitEditFromCellBlur" @click.stop @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" /> @@ -15774,6 +15790,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @click.stop @focus="onCellEditTextareaInput" @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" @wheel.stop @@ -15789,6 +15807,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @blur="commitEditFromCellBlur" @click.stop @input="onCellEditTextareaInput" + @compositionstart="cellEditCompositionActive = true" + @compositionend="cellEditCompositionActive = false" @keydown.stop="onCellEditKeydown" @paste.stop="onCellEditTextareaPaste" /> diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 29f6c75126..a2cb970563 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -2339,6 +2339,46 @@ describe("useDataGridEditor saveChanges reload", () => { }); }); +describe("useDataGridEditor IME composition", () => { + it("does not commit or cancel the cell while Enter/Escape belongs to an IME composition", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "hidden", "Lovelace"]]); + editor.startEdit(0, 0); + editor.editValue.value = "δΈ­"; + + const composing = { key: "Enter", isComposing: true, keyCode: 229, preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as KeyboardEvent; + editor.onEditKeydown(composing); + expect(composing.preventDefault).not.toHaveBeenCalled(); + expect(editor.editingCell.value).not.toBeNull(); + + const escape = { key: "Escape", isComposing: true, keyCode: 229, preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as KeyboardEvent; + editor.onEditKeydown(escape); + expect(escape.preventDefault).not.toHaveBeenCalled(); + expect(editor.editingCell.value).not.toBeNull(); + }); + + it("keeps the guard for the Process key without keyCode 229", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "hidden", "Lovelace"]]); + editor.startEdit(0, 0); + const process = { key: "Process", isComposing: false, keyCode: 13, preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as KeyboardEvent; + editor.onEditKeydown(process); + expect(process.preventDefault).not.toHaveBeenCalled(); + expect(editor.editingCell.value).not.toBeNull(); + }); + + it("defers to the composition tracking flag when the keydown itself carries no composition signal", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "hidden", "Lovelace"]]); + editor.startEdit(0, 0); + // WebKit fires compositionend before the committing keydown, so the input's + // composition tracking ref is the only reliable signal in that window. + const enter = { key: "Enter", isComposing: false, keyCode: 13, preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as KeyboardEvent; + editor.onEditKeydown(enter, true); + expect(enter.preventDefault).not.toHaveBeenCalled(); + expect(editor.editingCell.value).not.toBeNull(); + editor.onEditKeydown(enter, false); + expect(enter.preventDefault).toHaveBeenCalled(); + }); +}); + describe("useDataGridEditor cell edit focus", () => { it("selects the editor value only on the first frame so fast typing is not clobbered (#7336)", async () => { const editor = createEditor(undefined, true, undefined, undefined, [["long json value", "hidden", "last"]]); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index f21c6e0812..7ed735e70d 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1,4 +1,5 @@ import { joinedSaveOptions } from "@/lib/dataGrid/joinedRowChanges"; +import { isAiPromptImeCompositionEvent } from "@/lib/ai/aiPromptKeyboard"; import { ref, shallowRef, triggerRef, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue"; import * as api from "@/lib/backend/api"; import type { DataGridSaveGuard } from "@/lib/backend/tauri"; @@ -1248,7 +1249,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { restoreScrollAcrossFrames(restoreScroll); } - function onEditKeydown(e: KeyboardEvent) { + function onEditKeydown(e: KeyboardEvent, compositionActive = false) { + // Enter/Escape can also be the key that commits an IME candidate. Never + // treat that key as the grid shortcut; otherwise a Chinese/Japanese input + // commits the cell before the composition is inserted (#12470). + if (isAiPromptImeCompositionEvent(e, compositionActive)) return; const isExpandedTextarea = typeof HTMLTextAreaElement !== "undefined" && e.target instanceof HTMLTextAreaElement && e.target.dataset.expandedCellEditor === "true"; if (e.key === "Enter" && (!isExpandedTextarea || e.metaKey || e.ctrlKey)) { e.preventDefault();