mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(editor): let blank-area clicks clear a select-all selection
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
+154
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user