diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 261f5c77d1..fa71599a1f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -185,6 +185,7 @@ import { startsQueryEditorRectangularSelection, startsQueryEditorSelectionDrag, import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; import { queryEditorClipboardPasteChange } from "@/lib/editor/queryEditorClipboardPaste"; import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync"; +import { needsDiagnosticCaretReanchor } from "@/lib/editor/queryEditorDiagnosticCaretAnchor"; import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment"; import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight"; import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; @@ -3690,18 +3691,49 @@ function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").E ); } +// Mirrors CodeMirror's own root handling: shadow roots only expose +// `getSelection` on some browsers, otherwise the owner document holds it. +function editorRootSelection(currentView: EditorViewType): Selection | null { + const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null }; + if (root.nodeType !== 11) return (root as unknown as Document).getSelection(); + if (typeof root.getSelection === "function") return root.getSelection() ?? null; + return root.ownerDocument?.getSelection() ?? null; +} + +// See queryEditorDiagnosticCaretAnchor.ts for why the browser caret needs re-anchoring. +function reanchorCaretAfterDiagnostics(currentView: EditorViewType) { + const selection = currentView.state.selection; + const target = selection.ranges.length === 1 && selection.main.empty ? currentView.domAtPos(selection.main.head) : null; + const domSelection = editorRootSelection(currentView); + const reanchor = needsDiagnosticCaretReanchor({ + hasFocus: currentView.hasFocus, + composing: currentView.composing, + domRangeCount: domSelection?.rangeCount ?? 0, + currentAnchorNode: domSelection?.anchorNode ?? null, + currentAnchorOffset: domSelection?.anchorOffset ?? 0, + targetNode: target?.node ?? null, + targetOffset: target?.offset ?? 0, + }); + if (!reanchor || !domSelection || !target) return; + domSelection.collapse(target.node, target.offset); +} + function reconfigureDiagnostics() { - if (!view.value) return; + const currentView = view.value; + if (!currentView) return; if (setSqlDiagnosticsEffect) { - view.value.dispatch({ + currentView.dispatch({ effects: setSqlDiagnosticsEffect.of(semanticDiagnostics), }); - return; + } else { + if (!diagnosticComp || !buildSqlDiagnosticExtension) return; + currentView.dispatch({ + effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()), + }); } - if (!diagnosticComp || !buildSqlDiagnosticExtension) return; - view.value.dispatch({ - effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()), - }); + // Diagnostic decorations re-parent the DOM text nodes around the caret; put the + // browser's insertion point back on the caret before the next keystroke (#9480). + reanchorCaretAfterDiagnostics(currentView); } function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) { diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorDiagnosticCaretAnchor.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorDiagnosticCaretAnchor.spec.ts new file mode 100644 index 0000000000..ff6362d889 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorDiagnosticCaretAnchor.spec.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { needsDiagnosticCaretReanchor, type DiagnosticCaretAnchorState } from "@/lib/editor/queryEditorDiagnosticCaretAnchor"; + +const textNode = { nodeType: 3, name: "text" } as unknown as Node; +const lineNode = { nodeType: 1, name: "line" } as unknown as Node; + +function state(overrides: Partial = {}): DiagnosticCaretAnchorState { + return { + hasFocus: true, + composing: false, + domRangeCount: 1, + currentAnchorNode: lineNode, + currentAnchorOffset: 2, + targetNode: textNode, + targetOffset: 3, + ...overrides, + }; +} + +describe("needsDiagnosticCaretReanchor", () => { + it("re-anchors when the browser caret collapsed onto the surrounding line element", () => { + // The reported case: `document.getSelection()` reports (line, childCount) + // while the caret really sits inside the text node. + expect(needsDiagnosticCaretReanchor(state())).toBe(true); + }); + + it("re-anchors when the browser caret sits on the wrong offset of the same node", () => { + expect(needsDiagnosticCaretReanchor(state({ currentAnchorNode: textNode, currentAnchorOffset: 0 }))).toBe(true); + }); + + it("keeps a caret that already matches the precise DOM position", () => { + expect(needsDiagnosticCaretReanchor(state({ currentAnchorNode: textNode, currentAnchorOffset: 3 }))).toBe(false); + }); + + it("does not touch the caret while an IME composition is running", () => { + expect(needsDiagnosticCaretReanchor(state({ composing: true }))).toBe(false); + }); + + it("does not touch the caret when the editor is not focused", () => { + expect(needsDiagnosticCaretReanchor(state({ hasFocus: false }))).toBe(false); + }); + + it("keeps non-empty selections and multiple cursors untouched", () => { + expect(needsDiagnosticCaretReanchor(state({ targetNode: null }))).toBe(false); + expect(needsDiagnosticCaretReanchor(state({ domRangeCount: 2 }))).toBe(false); + expect(needsDiagnosticCaretReanchor(state({ domRangeCount: 0, currentAnchorNode: null }))).toBe(false); + }); +}); + +describe("QueryEditor diagnostic caret re-anchor wiring", () => { + const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + + it("re-anchors the browser caret after applying diagnostics, outside a transaction", () => { + expect(queryEditorSource).toMatch(/function reconfigureDiagnostics\(\)[\s\S]*?reanchorCaretAfterDiagnostics\(currentView\)/); + // The re-anchor must not dispatch a selection transaction: that closes the + // completion popup the user is looking at (#9480). + expect(queryEditorSource).toMatch(/function reanchorCaretAfterDiagnostics\(currentView: EditorViewType\)[\s\S]*?domSelection\.collapse\(target\.node, target\.offset\)/); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorDiagnosticCaretAnchor.ts b/apps/desktop/src/lib/editor/queryEditorDiagnosticCaretAnchor.ts new file mode 100644 index 0000000000..9d20110992 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorDiagnosticCaretAnchor.ts @@ -0,0 +1,42 @@ +/** + * Chromium keeps using its own native insertion point for the next typed + * character. When a decoration-only update (SQL / Redis syntax diagnostics) + * wraps or unwraps the text nodes next to the caret, the browser collapses + * that insertion point onto the surrounding line element instead of a text + * node. `document.getSelection()` still reports a position that CodeMirror + * considers equivalent to the caret — the same document position, only + * expressed as (lineElement, childCount) instead of (textNode, offset) — so + * CodeMirror leaves it alone, while the browser inserts the next character at + * the start of the re-parented text node. + * + * Reported as #9480: after a short pause inside a Redis command argument + * ("HGET us|"), the typed characters land at the start of the argument + * ("HGET er:1us"). + * + * Collapsing the DOM caret onto the precise DOM position of the editor + * selection restores the insertion point. This has to happen outside a + * CodeMirror transaction: a selection transaction would reset the completion + * popup the user is currently looking at, and it is not needed because the + * editor state already sits on that position. + */ +export interface DiagnosticCaretAnchorState { + /** Whether the editor owns the browser focus. */ + hasFocus: boolean; + /** Whether an IME composition is in progress — the browser owns the caret then. */ + composing: boolean; + /** Number of ranges in the DOM selection of the editor root. */ + domRangeCount: number; + /** Anchor node the browser currently uses for the caret. */ + currentAnchorNode: Node | null; + currentAnchorOffset: number; + /** Precise DOM position of the editor caret, or null when there is no single empty caret. */ + targetNode: Node | null; + targetOffset: number; +} + +export function needsDiagnosticCaretReanchor(state: DiagnosticCaretAnchorState): boolean { + if (!state.hasFocus || state.composing) return false; + if (state.domRangeCount !== 1) return false; + if (!state.currentAnchorNode || !state.targetNode) return false; + return state.currentAnchorNode !== state.targetNode || state.currentAnchorOffset !== state.targetOffset; +}