fix(editor): let blank-area clicks clear a select-all selection

This commit is contained in:
zipg
2026-09-28 21:17:17 +08:00
committed by GitHub
parent 3e681fe61b
commit e90186b48c
5 changed files with 748 additions and 42 deletions
@@ -538,7 +538,7 @@ const {
const hoverContent = createQueryEditorHoverContent({ isDark, t, toast });
const { resolveSqlHoverTooltip } = useQueryEditorHover({ props, contextMenuOpen, settingsStore, connectionStore, metadata: completionMetadata, createHoverDom: hoverContent.createHoverDom, semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, maxCompletionTables: MAX_COMPLETION_TABLES });
const pointerInteractions = useQueryEditorPointer({ props, clearTableNavigationHover: () => clearTableNavigationHover(), emit });
const { registerEditorScrollbarPointerGuard, startEditorSelectionDrag } = pointerInteractions;
const { registerEditorScrollbarPointerGuard, registerEditorNativeSelectionDragGuard, startEditorSelectionDrag } = pointerInteractions;
const tableDrop = useQueryEditorTableDrop({ props, view, editorRef, settingsStore });
const { hasDroppedTableReference, insertDroppedTableReference, queryEditorDropCaret, queryEditorDropCaretStyle, showQueryEditorDropCaretAt, hideQueryEditorDropCaret, registerTableReferenceDropListener, unregisterTableReferenceDropListener } = tableDrop;
const objectNavigation = useQueryEditorObjectNavigation({
@@ -2124,6 +2124,13 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
batchSelection.attach(view.value, tooltipParent);
postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM);
registerEditorScrollbarPointerGuard(view.value);
// `clipboardLineEndings` is what the editor registers as its
// `clipboardOutputFilter`; the drag park needs it for the same reason
// the context-menu park does, so a Cmd+C during a long drag is byte
// identical to one taken with the selection live. It is resolved when
// such a copy happens rather than here, because that is the only moment
// the normalizer is used.
registerEditorNativeSelectionDragGuard(view.value, { finalizeClipboardText: (text) => clipboardLineEndings(text) });
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
passive: true,
});
@@ -11,21 +11,27 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: vi.fn(() => false
vi.mock("@/lib/backend/platform", () => ({ isMacOS: vi.fn(() => false) }));
const cleanups: Array<() => void> = [];
/** Lets the drag park's frame-deferred restore run. */
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.restoreAllMocks();
});
function createHarness(readOnly = false) {
const LONG_DOC = Array.from({ length: 60 }, (_, index) => `select column_${index} from table_${index} where id = ${index};`).join("\n");
function createHarness(readOnly = false, options: { doc?: string; selection?: { anchor: number; head: number } } = {}) {
const doc = options.doc ?? "abc def ghi";
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({ doc: "abc def ghi", selection: { anchor: 4, head: 7 } }) });
const view = new EditorView({ parent: host, state: EditorState.create({ doc, selection: options.selection ?? { anchor: 4, head: 7 } }) });
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(5);
vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 50, right: 51, top: 20, bottom: 38 });
vi.spyOn(view, "focus").mockImplementation(() => {});
const emit = vi.fn();
const clearTableNavigationHover = vi.fn();
const pointer = useQueryEditorPointer({ props: { modelValue: "abc def ghi", readOnly }, clearTableNavigationHover, emit });
const pointer = useQueryEditorPointer({ props: { modelValue: doc, readOnly }, clearTableNavigationHover, emit });
const start = (init: MouseEventInit = {}) => {
const event = new MouseEvent("mousedown", { button: 0, detail: 1, clientX: 10, clientY: 10, cancelable: true, ...init });
Object.defineProperty(event, "target", { value: view.contentDOM });
@@ -41,6 +47,21 @@ function createHarness(readOnly = false) {
return { host, view, position, emit, clearTableNavigationHover, pointer, start, move, release };
}
/**
* Dispatches a mousedown the editor's own mouse-selection handler would try to
* hit-test, which happy-dom cannot answer. The capture listener runs after the
* guard under test and stops the event before CodeMirror sees it.
*/
function dispatchEditorMouseDown(view: EditorView, init: MouseEventInit = {}) {
const block = (event: Event) => event.stopImmediatePropagation();
view.contentDOM.addEventListener("mousedown", block, true);
try {
view.contentDOM.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, ...init }));
} finally {
view.contentDOM.removeEventListener("mousedown", block, true);
}
}
describe("QueryEditor pointer ownership", () => {
it.each(["read-only", "non-primary", "shift", "double-click", "empty-selection", "outside-selection"])("does not capture %s gestures", (kind) => {
const { view, position, start, emit } = createHarness(kind === "read-only");
@@ -65,6 +86,43 @@ describe("QueryEditor pointer ownership", () => {
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
});
it("keeps a click that drifts past the drag threshold on the selection a caret move", () => {
const { view, position, start, move, release } = createHarness();
expect(start().handled).toBe(true);
position.mockReturnValue(6);
move({ clientX: 20 });
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
expect(view.contentDOM.style.cursor).toBe("");
release({ clientX: 20 });
expect(view.state.doc.toString()).toBe("abc def ghi");
expect(view.state.selection.main.anchor).toBe(6);
expect(view.state.selection.main.empty).toBe(true);
});
it("keeps the selection when a drag is carried out and brought back onto it", () => {
const { view, position, start, move, release } = createHarness();
start();
position.mockReturnValue(11);
move();
expect(view.contentDOM.style.cursor).toBe("move");
position.mockReturnValue(6);
release();
expect(view.state.doc.toString()).toBe("abc def ghi");
expect([view.state.selection.main.from, view.state.selection.main.to]).toEqual([4, 7]);
});
it("drops the move cursor when the window loses focus mid-drag", () => {
const { view, position, start, move } = createHarness();
start();
position.mockReturnValue(11);
move();
expect(view.contentDOM.style.cursor).toBe("move");
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).not.toBeNull();
window.dispatchEvent(new Event("blur"));
expect(view.contentDOM.style.cursor).toBe("");
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
});
it.each(["move", "control-copy", "command-copy"])("preserves %s text and selection mapping", (kind) => {
const { view, position, start, move, release, emit } = createHarness();
const modifiers = { ctrlKey: kind === "control-copy", metaKey: kind === "command-copy" };
@@ -112,6 +170,99 @@ describe("QueryEditor pointer ownership", () => {
expect(bubble).toHaveBeenCalledOnce();
});
it("parks a long selection for the length of a drag and restores it on release", async () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
const anchorInsideContent = () => view.contentDOM.contains(document.getSelection()?.anchorNode ?? null);
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
dispatchEditorMouseDown(view);
expect(anchorInsideContent()).toBe(false);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
// The restore waits a microtask so a collapse dispatched in the same task
// replaces the selection instead of being overwritten by it.
await nextFrame();
expect(anchorInsideContent()).toBe(true);
});
it("leaves a short selection alone while dragging", () => {
const { view, pointer } = createHarness();
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
dispatchEditorMouseDown(view);
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
it("does not start a parking window for gestures outside the editor content", () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
view.scrollDOM.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true }));
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("stops the parking window when the pointer comes back with the button already up", async () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
dispatchEditorMouseDown(view);
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
document.dispatchEvent(new MouseEvent("mousemove", { buttons: 0, bubbles: true }));
await nextFrame();
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("replaces drag parking listeners and removes them on disposal", () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
pointer.registerEditorNativeSelectionDragGuard(view);
pointer.dispose();
dispatchEditorMouseDown(view);
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("cancels the browser's own drag session for the length of the gesture", () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
dispatchEditorMouseDown(view);
const during = new Event("dragstart", { bubbles: true, cancelable: true });
view.contentDOM.dispatchEvent(during);
expect(during.defaultPrevented).toBe(true);
// The gesture is over, so the browser may drag again.
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
const after = new Event("dragstart", { bubbles: true, cancelable: true });
view.contentDOM.dispatchEvent(after);
expect(after.defaultPrevented).toBe(false);
});
it("ends the parking window on the first non-modifier key so typing never lands inside a parked gesture", async () => {
const { view, pointer } = createHarness(false, { doc: LONG_DOC, selection: { anchor: 0, head: 1200 } });
document.getSelection()!.collapse(view.contentDOM, 0);
pointer.registerEditorNativeSelectionDragGuard(view);
dispatchEditorMouseDown(view);
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift", bubbles: true }));
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
await nextFrame();
expect(view.contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("prevents native macOS Tauri scrollbar focus changes outside editor content", () => {
const { view, pointer } = createHarness();
vi.mocked(isTauriRuntime).mockReturnValue(true);
@@ -1,5 +1,6 @@
import type { EditorView as EditorViewType } from "@codemirror/view";
import { startsQueryEditorSelectionDrag } from "@/lib/editor/queryEditorPointerSelection";
import { keepNativeSelectionParkedDuringDrag, type EditorNativeSelectionPark } from "@/lib/editor/queryEditorNativeSelection";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
import type { QueryEditorProps } from "./queryEditorTypes";
@@ -10,10 +11,22 @@ interface QueryEditorPointerOptions {
emit: (event: "closeColumnPanel") => void;
}
export interface QueryEditorNativeSelectionDragGuardOptions {
/**
* Applied to the text the parked copy path puts on the clipboard. Pass the
* same normalizer the editor registers as `EditorView.clipboardOutputFilter`
* (DBX rewrites line endings there) so a copy taken while the selection is
* parked matches a copy taken while it is not.
*/
finalizeClipboardText?: (text: string) => string;
}
export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
const { props, clearTableNavigationHover, emit } = options;
let editorScrollbarPointerCleanup: (() => void) | null = null;
let editorNativeSelectionParkCleanup: (() => void) | null = null;
let editorSelectionDragCleanup: (() => void) | null = null;
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
@@ -52,6 +65,90 @@ export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
};
}
/**
* Parks the browser selection for the length of a selection gesture.
*
* macOS 26/27 has the web view serialize the browser selection whenever the
* pointer dwells on text services, at a cost that grows with the number of
* highlighted runs the selection covers. That is why a long drag is only
* smooth while the pointer stays at the edge of the editor (autoscrolling
* never puts it back on the text) and stalls as soon as it moves up over the
* lines it has already selected. Parking removes that cost; the editor keeps
* its own selection, so nothing about the gesture's result changes. See
* queryEditorNativeSelection.ts.
*
* The guard owns the whole gesture rather than a single mousedown/mouseup
* pair: the window has to cover CodeMirror's own mouse selection, the app's
* move/copy selection drag, and the autoscroll timer that keeps extending the
* selection while the pointer is held at the edge. It also cancels the
* browser's own drag session for that window, which would otherwise serialize
* the selection and take the pointer over with it.
*/
function registerEditorNativeSelectionDragGuard(currentView: EditorViewType, guardOptions: QueryEditorNativeSelectionDragGuardOptions = {}) {
editorNativeSelectionParkCleanup?.();
const doc = currentView.contentDOM.ownerDocument;
const win = doc.defaultView ?? window;
let park: EditorNativeSelectionPark | null = null;
const stop = () => {
park?.release();
park = null;
doc.removeEventListener("mouseup", onUp, true);
doc.removeEventListener("mousemove", onMove, true);
doc.removeEventListener("keydown", onKeyDown, true);
doc.removeEventListener("dragstart", blockNativeDrag, true);
win.removeEventListener("blur", stop);
};
// macOS turns a press, or a few pixels of travel, on selected text into its
// own drag session: it serializes the selection into drag data, puts up its
// own cursor, and then follows the pointer across whatever sits underneath
// — which is how a drag started in the editor ends up moving the selection
// over the SQL library list. On macOS 26/27 that serialization is the same
// per-run text-services cost the parking exists to remove, so a long
// selection stalls for seconds before the cursor even changes. The editor
// already implements its own move/copy drag (`startEditorSelectionDrag`), so
// the browser's session is cancelled for the length of the gesture. The
// column-header drag cancels the same event for the same reason — see
// `blockColumnHeaderNativeInteraction` in useDataGridColumnLayout.ts.
const blockNativeDrag = (event: Event) => event.preventDefault();
// A release outside the window (or over another app) never reaches the
// document, so a pointer that comes back with the button already up ends
// the gesture instead of leaving the selection parked.
const onMove = (event: MouseEvent) => {
if ((event.buttons & 1) === 0) stop();
};
const onUp = () => stop();
// A parked selection is invisible to CodeMirror by design, so the park must
// never outlive the gesture: the first key that is not a modifier ends it,
// which is what keeps typing (and IME) out of a parked window.
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift" || event.key === "Alt" || event.key === "Control" || event.key === "Meta") return;
stop();
};
const onDown = (event: MouseEvent) => {
if (event.button !== 0 || !currentView.contentDOM.contains(event.target as Node | null)) return;
stop();
park = keepNativeSelectionParkedDuringDrag(currentView, { finalizeClipboardText: guardOptions.finalizeClipboardText });
doc.addEventListener("mouseup", onUp, true);
doc.addEventListener("mousemove", onMove, true);
doc.addEventListener("keydown", onKeyDown, true);
doc.addEventListener("dragstart", blockNativeDrag, true);
win.addEventListener("blur", stop);
};
currentView.contentDOM.addEventListener("mousedown", onDown, true);
editorNativeSelectionParkCleanup = () => {
currentView.contentDOM.removeEventListener("mousedown", onDown, true);
stop();
editorNativeSelectionParkCleanup = null;
};
}
function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent) {
if (props.readOnly || event.button !== 0) return null;
if (!currentView.contentDOM.contains(event.target as Node | null)) return null;
@@ -66,6 +163,19 @@ export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
};
}
/**
* Whether the pointer is still over the text the press landed on.
*
* `posAtCoords` answers with the position the pointer is closest to, so a
* pointer in the blank area right of a line answers with that line's end —
* inside the selection whenever the line is part of it, which is exactly what
* a click on a long selection has to be read as.
*/
function pointerOverSelection(currentView: EditorViewType, event: MouseEvent, selection: { from: number; to: number }) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
return pos != null && pos >= selection.from && pos <= selection.to;
}
function moveOrCopySelectionToPointer(currentView: EditorViewType, selection: { from: number; to: number; text: string }, event: MouseEvent) {
const dropPos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (dropPos == null) return false;
@@ -140,18 +250,37 @@ export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
const startY = event.clientY;
let dragging = false;
const win = currentView.contentDOM.ownerDocument.defaultView ?? window;
const cleanup = () => {
currentView.contentDOM.ownerDocument.removeEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.removeEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.removeEventListener("keydown", onKeyDown, true);
win.removeEventListener("blur", onCancel);
hideEditorSelectionDropCursor();
editorSelectionDragCleanup = null;
};
// A release outside the window never reaches the document, so without this
// the drop cursor and the move/copy pointer would stay behind until the
// next press.
const onCancel = () => {
cleanup();
currentView.contentDOM.style.cursor = "";
};
const onMove = (moveEvent: MouseEvent) => {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (!dragging && distance < EDITOR_SELECTION_DRAG_THRESHOLD_PX) return;
dragging = true;
if (!dragging) {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (distance < EDITOR_SELECTION_DRAG_THRESHOLD_PX) return;
// A press that never leaves the selected text is a click that drifted,
// not a text drag, and it has to keep behaving like a click: the browser
// would place the caret where the pointer came up. Taking it over
// instead swallowed the release — the drop landed back inside the
// selection, `moveOrCopySelectionToPointer` had nothing to do, and a
// long selection looked like it could not be cleared at all.
if (pointerOverSelection(currentView, moveEvent, selection)) return;
dragging = true;
}
if (moveEvent.ctrlKey || moveEvent.metaKey) {
currentView.contentDOM.style.cursor = "copy";
} else {
@@ -195,6 +324,7 @@ export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
currentView.contentDOM.ownerDocument.addEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.addEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.addEventListener("keydown", onKeyDown, true);
win.addEventListener("blur", onCancel);
editorSelectionDragCleanup = () => {
cleanup();
currentView.contentDOM.style.cursor = "";
@@ -204,8 +334,9 @@ export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
function dispose() {
editorScrollbarPointerCleanup?.();
editorNativeSelectionParkCleanup?.();
editorSelectionDragCleanup?.();
}
return { registerEditorScrollbarPointerGuard, startEditorSelectionDrag, dispose };
return { registerEditorScrollbarPointerGuard, registerEditorNativeSelectionDragGuard, startEditorSelectionDrag, dispose };
}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import type { EditorView } from "@codemirror/view";
import { NATIVE_SELECTION_PARK_MIN_CHARS, parkEditorNativeSelection } from "../queryEditorNativeSelection";
import { NATIVE_SELECTION_PARK_MIN_CHARS, isLargeEditorSelection, keepNativeSelectionParkedDuringDrag, parkEditorNativeSelection } from "../queryEditorNativeSelection";
const DOC = `SELECT ${"x".repeat(NATIVE_SELECTION_PARK_MIN_CHARS)}`;
@@ -10,9 +10,38 @@ interface FakeView {
contentDOM: HTMLElement;
dispatch: ReturnType<typeof vi.fn>;
selectedText: () => string;
/** Move the editor's own selection, as a pointer gesture would. */
setSelection: (from: number, to: number) => void;
/** Re-publish the state selection as the browser selection. */
publishSelection: () => void;
/** The private CodeMirror internals the drag park shadows, when requested. */
docView: { updateSelection: ReturnType<typeof vi.fn> } | null;
observer: { selectionRange: { focusNode: Node | null }; readSelectionRange: ReturnType<typeof vi.fn> } | null;
}
function fakeView(from: number, to: number): FakeView {
/** Runs the park module's `requestAnimationFrame` loop by hand. */
function manualFrames() {
const queue: Array<{ id: number; callback: () => void }> = [];
let nextId = 0;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
const id = ++nextId;
queue.push({ id, callback: () => callback(performance.now()) });
return id;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id: number) => {
const index = queue.findIndex((entry) => entry.id === id);
if (index >= 0) queue.splice(index, 1);
});
return {
runFrame: () => {
const next = queue.shift();
expect(next).toBeDefined();
next!.callback();
},
};
}
function fakeView(from: number, to: number, options: { withSelectionInternals?: boolean } = {}): FakeView {
const dom = document.createElement("div");
const contentDOM = document.createElement("div");
const paragraph = document.createElement("p");
@@ -30,6 +59,10 @@ function fakeView(from: number, to: number): FakeView {
readOnly: false,
sliceDoc: (start: number, end: number) => DOC.slice(start, end),
};
// CodeMirror keeps `updateSelection` on the doc view's prototype, which is what
// the suppression shadows on the instance and deletes again on release.
const docView = options.withSelectionInternals ? (Object.create({ updateSelection: vi.fn() }) as { updateSelection: ReturnType<typeof vi.fn> }) : null;
const observer = options.withSelectionInternals ? { selectionRange: { focusNode: null as Node | null }, readSelectionRange: vi.fn(() => true) } : null;
const view = {
root: document,
dom,
@@ -37,13 +70,26 @@ function fakeView(from: number, to: number): FakeView {
state,
dispatch,
domAtPos: (pos: number) => ({ node: text, offset: Math.min(pos, DOC.length) }),
docView,
observer,
} as unknown as EditorView;
const selection = document.getSelection()!;
selection.collapse(text, from);
selection.extend(text, to);
const publishSelection = () => {
const selection = document.getSelection()!;
selection.collapse(text, range.from);
selection.extend(text, range.to);
};
publishSelection();
return { view, contentDOM, dispatch, selectedText: () => document.getSelection()?.toString() ?? "" };
const setSelection = (nextFrom: number, nextTo: number) => {
range.from = nextFrom;
range.to = nextTo;
range.empty = nextFrom === nextTo;
range.anchor = nextFrom;
range.head = nextTo;
};
return { view, contentDOM, dispatch, selectedText: () => document.getSelection()?.toString() ?? "", setSelection, publishSelection, docView, observer };
}
function copyEvent(target: EventTarget, type = "copy") {
@@ -118,3 +164,199 @@ describe("parkEditorNativeSelection", () => {
expect(data.setData).not.toHaveBeenCalled();
});
});
describe("isLargeEditorSelection", () => {
it("only counts selections long enough to be worth parking", () => {
const short = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS - 1);
expect(isLargeEditorSelection(short.view)).toBe(false);
const long = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
expect(isLargeEditorSelection(long.view)).toBe(true);
const caret = fakeView(10, 10);
expect(isLargeEditorSelection(caret.view)).toBe(false);
});
});
describe("keepNativeSelectionParkedDuringDrag", () => {
it("parks once the gesture has dragged out enough lines to be worth it", () => {
const frames = manualFrames();
const { view, contentDOM, setSelection } = fakeView(0, 4);
const park = keepNativeSelectionParkedDuringDrag(view);
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
setSelection(0, NATIVE_SELECTION_PARK_MIN_CHARS);
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
park.release();
});
it("leaves a gesture that never grows past a few lines alone", () => {
const frames = manualFrames();
const { view, contentDOM, setSelection, publishSelection } = fakeView(0, 4);
const park = keepNativeSelectionParkedDuringDrag(view);
setSelection(0, 40);
publishSelection();
frames.runFrame();
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
park.release();
});
it("stops parking once the caller reports the gesture is over", () => {
const frames = manualFrames();
const { view, contentDOM, publishSelection } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
let dragging = true;
const park = keepNativeSelectionParkedDuringDrag(view, { shouldKeepParked: () => dragging });
publishSelection();
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
dragging = false;
publishSelection();
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
park.release();
});
it("fills the clipboard for a copy taken while the gesture is still running", () => {
manualFrames();
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = keepNativeSelectionParkedDuringDrag(view);
const { data } = copyEvent(contentDOM);
expect(data.setData).toHaveBeenCalledWith("text/plain", DOC.slice(0, NATIVE_SELECTION_PARK_MIN_CHARS));
park.release();
});
it("re-parks after CodeMirror rewrites the browser selection from its own state", () => {
const frames = manualFrames();
const { view, contentDOM, publishSelection } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = keepNativeSelectionParkedDuringDrag(view);
// CodeMirror writes the browser selection on every selection update, which
// puts a long selection back on the text services the parking exists to
// keep away from it.
publishSelection();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
park.release();
});
});
describe("drag park selection-write suppression", () => {
it("shadows CodeMirror's browser-selection write for the length of the park", () => {
manualFrames();
const { view, contentDOM, docView, observer } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const park = keepNativeSelectionParkedDuringDrag(view);
// CodeMirror rewrites the browser selection on every selection update, and
// a parked selection always looks stale to it, so the write is shadowed and
// the read half is kept.
expect(Object.prototype.hasOwnProperty.call(docView, "updateSelection")).toBe(true);
docView!.updateSelection(true);
expect(observer!.readSelectionRange).toHaveBeenCalled();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
park.release();
expect(Object.prototype.hasOwnProperty.call(docView, "updateSelection")).toBe(false);
});
it("leaves a view that does not expose the internals it was written against untouched", () => {
manualFrames();
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = keepNativeSelectionParkedDuringDrag(view);
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
park.release();
});
it("does not shadow the write when the caller opts out", () => {
manualFrames();
const { view, docView } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const park = keepNativeSelectionParkedDuringDrag(view, { suppressEditorSelectionWrites: false });
expect(Object.prototype.hasOwnProperty.call(docView, "updateSelection")).toBe(false);
park.release();
});
it("never clobbers a write somebody else already owns", () => {
manualFrames();
const { view, docView } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const existing = vi.fn();
docView!.updateSelection = existing;
const park = keepNativeSelectionParkedDuringDrag(view);
expect(docView!.updateSelection).toBe(existing);
park.release();
expect(docView!.updateSelection).toBe(existing);
});
it("waits for the gesture's own write and only restores when the selection is still parked", () => {
const frames = manualFrames();
const { view, contentDOM, setSelection } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const park = keepNativeSelectionParkedDuringDrag(view);
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
// The click that ends the gesture collapses the editor selection, and that
// collapse is what CodeMirror writes into the browser. Restoring first would
// re-create the whole selection only to have CodeMirror collapse it again.
setSelection(0, 0);
park.release();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("leaves a browser selection CodeMirror wrote itself alone", () => {
const frames = manualFrames();
const { view, contentDOM, setSelection } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const park = keepNativeSelectionParkedDuringDrag(view);
setSelection(0, 0);
document.getSelection()!.collapse(view.contentDOM.querySelector("p")!.firstChild!, 7);
park.release();
frames.runFrame();
// The parked selection is gone, so there is nothing to put back.
expect(document.getSelection()!.anchorOffset).toBe(7);
expect(contentDOM.contains(document.getSelection()!.anchorNode)).toBe(true);
});
it("still restores the menu park inside release, before the caller's next task", () => {
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = parkEditorNativeSelection(view);
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
park!.release();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("leaves the browser selection to a park that started before its deferred restore ran", () => {
const frames = manualFrames();
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS, { withSelectionInternals: true });
const first = keepNativeSelectionParkedDuringDrag(view);
first.release();
const second = keepNativeSelectionParkedDuringDrag(view);
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(false);
second.release();
frames.runFrame();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
});
@@ -14,9 +14,16 @@ import { appendDebugLog } from "@/lib/backend/debugLog";
* The browser selection does not have to cover the selection for the editor to
* behave: CodeMirror paints the selection itself (`drawSelection` keeps the
* native one transparent), it copies from its own state, and it ignores a
* selection parked outside its content element. So while an overlay menu is
* shown on top of the editor the browser selection is parked on the editor
* chrome and restored as soon as the menu closes.
* selection parked outside its content element. So the browser selection is
* parked on the editor chrome while an overlay menu covers the editor, and for
* as long as a pointer gesture is dragging a long selection out, and restored
* as soon as the menu closes or the pointer is released.
*
* The drag is the case that is easiest to misread from a bug report: sweeping
* the pointer to the edge of the editor autoscrolls and never leaves the
* pointer on the text, so it stays smooth, while moving back up into the
* already selected lines pays the serialization cost on every mouse move
* (smooth in Chrome/Blink, which does not make that request at all).
*
* Parking outside `contentDOM` is what makes CodeMirror leave the editor state
* alone (it maps a foreign selection back to the selection it already has), so
@@ -27,15 +34,38 @@ import { appendDebugLog } from "@/lib/backend/debugLog";
/**
* Selections shorter than this are serialized cheaply enough that parking them
* would only trade a measurable cost for an invisible one.
* would only trade a measurable cost for an invisible one. Dragging a selection
* out keeps the browser selection live until it crosses this length, so a drag
* over a few lines behaves exactly as it did before.
*/
export const NATIVE_SELECTION_PARK_MIN_CHARS = 1000;
/** Whether the editor's primary selection is long enough to be worth parking. */
export function isLargeEditorSelection(currentView: EditorView): boolean {
const main = currentView.state.selection.main;
return !main.empty && main.to - main.from >= NATIVE_SELECTION_PARK_MIN_CHARS;
}
export interface EditorNativeSelectionPark {
/** Put the browser selection back where the editor state has it. */
release(): void;
}
export interface EditorNativeSelectionDragParkOptions extends EditorNativeSelectionParkOptions {
/**
* Parking is suspended while this returns false, which lets a caller keep one
* handle for a whole gesture and still let a short selection behave normally.
*/
shouldKeepParked?: () => boolean;
/**
* Whether CodeMirror's own browser-selection writes are suspended while the
* park holds. Only the drag park wants this, because only the drag park is
* re-collapsed in a frame loop that CodeMirror then writes against. See
* `suppressEditorSelectionWrites`.
*/
suppressEditorSelectionWrites?: boolean;
}
export interface EditorNativeSelectionParkOptions {
/**
* Applied to the text the parked copy path puts on the clipboard. Pass the
@@ -63,6 +93,48 @@ function isParkedOutsideContent(currentView: EditorView, selection: Selection):
return !!anchor && !currentView.contentDOM.contains(anchor);
}
interface EditorViewSelectionInternals {
docView?: { updateSelection?: (mustRead?: boolean, fromPointer?: boolean) => void };
observer?: { selectionRange: { focusNode: Node | null }; readSelectionRange: () => boolean };
}
const UPDATE_SELECTION = "updateSelection";
/**
* Stops CodeMirror from writing its selection into the browser while parked.
*
* Parking alone is not enough for a drag. CodeMirror rewrites the browser
* selection whenever it differs from the editor state, and a parked selection
* always differs, so every pointer move issues a fresh `collapse` inside the
* editable — which is exactly the operation that costs tens of milliseconds on
* macOS 26/27. Measured on a 2500 line document: 21 writes over a 300 line
* drag, 23–167 ms each, all of the web process main thread. Those writes buy
* nothing while parked: CodeMirror draws and copies the selection from its own
* state, a parked selection is by definition not what the user sees.
*
* The suppression is narrow on purpose — it shadows the single method that
* performs the write and keeps its read half, so CodeMirror still notices a
* selection the browser moved on its own. It is restored by the park's
* `release()`, and it refuses to touch a view whose internals do not look like
* the ones it was written against.
*/
function suppressEditorSelectionWrites(currentView: EditorView): (() => void) | null {
const internals = currentView as unknown as EditorViewSelectionInternals;
const docView = internals.docView;
const observer = internals.observer;
if (!docView || typeof docView[UPDATE_SELECTION] !== "function" || !observer) return null;
// Never clobber a patch somebody else owns; without our shadow the prototype
// method is what CodeMirror calls, and that is exactly what we restore.
if (Object.prototype.hasOwnProperty.call(docView, UPDATE_SELECTION)) return null;
const suppressed = (mustRead?: boolean) => {
if (mustRead || !observer.selectionRange.focusNode) observer.readSelectionRange();
};
docView[UPDATE_SELECTION] = suppressed;
return () => {
if (docView[UPDATE_SELECTION] === suppressed) delete docView[UPDATE_SELECTION];
};
}
function parkSelection(currentView: EditorView, selection: Selection): void {
try {
// `view.dom` (the editor element) sits outside `view.contentDOM`, so
@@ -138,38 +210,89 @@ function handleParkedClipboardEvent(currentView: EditorView, finalizeClipboardTe
}
/**
* Parks the browser selection while an overlay menu covers the editor.
* The machinery both parking windows share.
*
* @returns a handle that restores the browser selection, or `null` when there
* was nothing worth parking.
* Parking is idempotent and self-healing: the browser can put a selection back
* into the editor on its own (focus, a redraw, the platform moving a caret), so
* the collapse is repeated for as long as the window lasts. Reading `anchorNode`
* costs no layout, so the frame loop is cheap enough to run for the length of a
* gesture. A drag park additionally stops CodeMirror from writing the browser
* selection at all while it holds — see `suppressEditorSelectionWrites` — which
* is what removes the per-pointer-move cost of re-collapsing a large selection.
*/
export function parkEditorNativeSelection(currentView: EditorView, options: EditorNativeSelectionParkOptions = {}): EditorNativeSelectionPark | null {
const main = currentView.state.selection.main;
if (main.empty || main.to - main.from < NATIVE_SELECTION_PARK_MIN_CHARS) return null;
const selection = editorRootSelection(currentView);
if (!selection || !selection.anchorNode || !currentView.dom.contains(selection.anchorNode)) return null;
// Kept for field diagnosis: it only writes while debug logging is switched
// on, and it is the one line that shows whether the parked window was even
// entered when somebody reports the editor still stuttering.
appendDebugLog("info", "[DBX][QueryEditor:native-selection:park]", { chars: main.to - main.from });
interface NativeSelectionParkConfig {
/** Whether the window should still hold. Re-read on every frame. */
shouldKeepParked: () => boolean;
finalizeClipboardText: (text: string) => string;
/** See `suppressEditorSelectionWrites`. */
suppressWrites?: boolean;
/**
* Restore the browser selection on the next frame instead of inside
* `release()`, and skip the restore when the browser selection is no longer
* parked by then.
*
* A gesture ends by dispatching: CodeMirror collapses the selection for a
* click and rewrites the browser selection for a drag. Either way its own
* write lands first, after which there is nothing left to restore — and
* restoring first would put the whole long selection back into the browser
* only to have CodeMirror write over it. On macOS 26/27 each of those writes
* is the expensive text-services operation this module exists to avoid, so a
* click that clears a long drag paid for three of them. The frame is the
* safety net for the gestures that end without any dispatch — a release
* outside the window, a focus change — where the parked selection would
* otherwise stay parked. The restore re-reads the editor state when it runs.
*/
deferReleaseRestore?: boolean;
}
parkSelection(currentView, selection);
let released = false;
let frame = 0;
const finalizeClipboardText = options.finalizeClipboardText ?? ((text: string) => text);
// `copy`/`cut` bubble through the document, so listening there catches the
// event no matter which editor chrome inside the view holds focus.
/**
* Which park most recently took the browser selection. A deferred restore only
* acts while its own park is still the newest one: a park that starts before the
* microtask runs owns the browser selection, and restoring under it would hand
* the selection back to the text services for a frame.
*/
let latestParkSequence = 0;
function createNativeSelectionPark(currentView: EditorView, config: NativeSelectionParkConfig): EditorNativeSelectionPark {
const { shouldKeepParked, finalizeClipboardText, suppressWrites = false, deferReleaseRestore = false } = config;
const sequence = ++latestParkSequence;
const doc = currentView.dom.ownerDocument;
const win = doc.defaultView ?? window;
let released = false;
let frame = 0;
let parkedOnce = false;
let parkedSince = 0;
let restoreWrites: (() => void) | null = null;
const park = () => {
if (released || !shouldKeepParked()) return;
const selection = editorRootSelection(currentView);
if (!selection || isParkedOutsideContent(currentView, selection)) return;
parkSelection(currentView, selection);
if (!parkedOnce) {
parkedOnce = true;
parkedSince = performance.now();
if (suppressWrites) restoreWrites = suppressEditorSelectionWrites(currentView);
// Kept for field diagnosis: it only writes while debug logging is
// switched on, and it is the one line that shows whether the parking
// window was even entered when somebody reports the editor stuttering.
appendDebugLog("info", "[DBX][QueryEditor:native-selection:park]", {
chars: currentView.state.selection.main.to - currentView.state.selection.main.from,
suppressesEditorWrites: !!restoreWrites,
});
}
};
park();
const onCopy = (event: ClipboardEvent) => handleParkedClipboardEvent(currentView, finalizeClipboardText, () => released, event);
// `copy`/`cut` bubble through the document, so listening there catches the
// event no matter which editor chrome inside the view holds focus.
doc.addEventListener("copy", onCopy);
doc.addEventListener("cut", onCopy);
// CodeMirror rewrites the browser selection from its own state on every
// selection update (menu actions, diagnostics re-anchoring, typing), so a
// single collapse does not stick. Reading `anchorNode` costs no layout.
const keepParked = () => {
if (released) return;
if (!isParkedOutsideContent(currentView, selection)) parkSelection(currentView, selection);
park();
frame = win.requestAnimationFrame(keepParked);
};
frame = win.requestAnimationFrame(keepParked);
@@ -181,8 +304,60 @@ export function parkEditorNativeSelection(currentView: EditorView, options: Edit
doc.removeEventListener("copy", onCopy);
doc.removeEventListener("cut", onCopy);
win.cancelAnimationFrame(frame);
const live = editorRootSelection(currentView);
if (live && isParkedOutsideContent(currentView, live)) restoreSelectionFromState(currentView, live);
restoreWrites?.();
restoreWrites = null;
const restore = () => {
if (sequence !== latestParkSequence) return;
const live = editorRootSelection(currentView);
if (live && isParkedOutsideContent(currentView, live)) restoreSelectionFromState(currentView, live);
};
if (deferReleaseRestore) win.requestAnimationFrame(restore);
else restore();
if (parkedOnce) {
// Field diagnosis for "the selection took seconds to clear": how long a
// park lived says whether a gesture was still parked while the user
// thought it had ended, and how many frame-collapses it took.
appendDebugLog("info", "[DBX][QueryEditor:native-selection:release]", {
ms: Math.round(performance.now() - parkedSince),
chars: currentView.state.selection.main.to - currentView.state.selection.main.from,
suppressedEditorWrites: suppressWrites,
});
}
},
};
}
/**
* Parks the browser selection while an overlay menu covers the editor.
*
* @returns a handle that restores the browser selection, or `null` when there
* was nothing worth parking.
*/
export function parkEditorNativeSelection(currentView: EditorView, options: EditorNativeSelectionParkOptions = {}): EditorNativeSelectionPark | null {
if (!isLargeEditorSelection(currentView)) return null;
const selection = editorRootSelection(currentView);
if (!selection || !selection.anchorNode || !currentView.dom.contains(selection.anchorNode)) return null;
return createNativeSelectionPark(currentView, { shouldKeepParked: () => true, finalizeClipboardText: options.finalizeClipboardText ?? ((text: string) => text) });
}
/**
* Parks the browser selection for the length of a pointer gesture.
*
* Call this on pointer down and `release()` on pointer up: the browser
* selection stays parked only while the editor's own selection is long enough
* to be worth it (`isLargeEditorSelection`), so a gesture that never grows past
* a few lines — or one that starts by shrinking a selection back down — keeps
* behaving exactly as it did before. The editor keeps its own selection while
* this runs, so the highlight, the selection that survives the release and the
* text on the clipboard are unchanged.
*/
export function keepNativeSelectionParkedDuringDrag(currentView: EditorView, options: EditorNativeSelectionDragParkOptions = {}): EditorNativeSelectionPark {
return createNativeSelectionPark(currentView, {
shouldKeepParked: () => isLargeEditorSelection(currentView) && (options.shouldKeepParked?.() ?? true),
finalizeClipboardText: options.finalizeClipboardText ?? ((text: string) => text),
suppressWrites: options.suppressEditorSelectionWrites ?? true,
// A gesture ends by dispatching a selection change, and that write is both
// the correct one and the cheap one to land first. See `deferReleaseRestore`.
deferReleaseRestore: true,
});
}