fix(editor): anchor caret back to cursor after diagnostics relayout

This commit is contained in:
zipg
2026-09-21 11:37:52 +08:00
committed by GitHub
parent 566bfc3039
commit 2271d840d8
3 changed files with 141 additions and 7 deletions
@@ -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[]) {
@@ -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> = {}): 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\)/);
});
});
@@ -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;
}