From e90186b48c67ddde76aec32e65b076794f208c28 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 28 Sep 2026 21:17:17 +0800 Subject: [PATCH] fix(editor): let blank-area clicks clear a select-all selection --- .../src/components/editor/QueryEditor.vue | 9 +- .../__tests__/useQueryEditorPointer.spec.ts | 157 ++++++++++- .../editor/useQueryEditorPointer.ts | 139 +++++++++- .../queryEditorNativeSelection.spec.ts | 254 +++++++++++++++++- .../lib/editor/queryEditorNativeSelection.ts | 231 ++++++++++++++-- 5 files changed, 748 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cf854a549b..abd34cc47b 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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, }); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorPointer.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorPointer.spec.ts index 65486e0306..2a04a89d93 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorPointer.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorPointer.spec.ts @@ -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((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); diff --git a/apps/desktop/src/components/editor/useQueryEditorPointer.ts b/apps/desktop/src/components/editor/useQueryEditorPointer.ts index 37e4d83623..4f54f0c410 100644 --- a/apps/desktop/src/components/editor/useQueryEditorPointer.ts +++ b/apps/desktop/src/components/editor/useQueryEditorPointer.ts @@ -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 }; } diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts index 3d701f6d83..6eecb5d65f 100644 --- a/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts @@ -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; 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 } | null; + observer: { selectionRange: { focusNode: Node | null }; readSelectionRange: ReturnType } | 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 }) : 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); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts b/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts index e58a4e9a13..40b8ec23a0 100644 --- a/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts +++ b/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts @@ -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, + }); +}