mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(editor): anchor caret back to cursor after diagnostics relayout
This commit is contained in:
@@ -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[]) {
|
||||
|
||||
+60
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user