fix(editor): avoid native selection lag in macOS context menu

This commit is contained in:
zipg
2026-09-28 00:36:31 +08:00
committed by GitHub
parent 78dcb85713
commit 6b912f7116
6 changed files with 367 additions and 10 deletions
@@ -55,10 +55,11 @@ import { createColumnReferencePayload, tableReferenceInsertText } from "@/lib/ed
import { clearRememberedFocusedQueryEditorView, focusedQueryEditorView, queryEditorInsertContext, registerQueryEditorInsertContext, rememberFocusedQueryEditorView, unregisterQueryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
import { parkEditorNativeSelection, type EditorNativeSelectionPark } from "@/lib/editor/queryEditorNativeSelection";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import QueryEditorContextMenu, { type QueryEditorContextMenuState, type QueryEditorContextMenuActions } from "./QueryEditorContextMenu.vue";
import { readTextFromClipboard } from "@/lib/common/clipboard";
import { clipboardLineEndings, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import { supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
@@ -192,6 +193,37 @@ const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
// The editor's own context menu is a DOM overlay, so a long selection stays
// live underneath it and macOS keeps asking the web view to serialize that
// selection while the pointer moves over the menu. Parking the browser
// selection for as long as the menu is up is what keeps right click usable on
// a few hundred selected lines; see queryEditorNativeSelection.ts.
let parkedNativeSelection: EditorNativeSelectionPark | null = null;
function parkNativeSelectionUnderContextMenu() {
const currentView = view.value;
if (!currentView) return;
parkedNativeSelection?.release();
// `clipboardLineEndings` is what the editor registers as its
// `clipboardOutputFilter`; passing it here keeps Cmd+C working while the
// selection is parked and leaves the copied bytes unchanged.
parkedNativeSelection = parkEditorNativeSelection(currentView, { finalizeClipboardText: clipboardLineEndings });
}
function releaseParkedNativeSelection() {
parkedNativeSelection?.release();
parkedNativeSelection = null;
}
watch(
contextMenuOpen,
(open) => {
if (open) parkNativeSelectionUnderContextMenu();
else releaseParkedNativeSelection();
},
{ flush: "sync" },
);
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
let contextMenuDoc: Text | null = null;
@@ -2555,6 +2587,7 @@ onBeforeUnmount(() => {
pointerInteractions.dispose();
objectNavigation.dispose();
contextMenuPointerCleanup?.();
releaseParkedNativeSelection();
postCompositionKeyGuardCleanup?.();
postCompositionKeyGuardCleanup = null;
batchSelection.dispose();
@@ -8,6 +8,7 @@ import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { editorRootSelection } from "@/lib/editor/queryEditorNativeSelection";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { mergeSqlSemanticReferenceAnalysis } from "@/lib/sql/semantic/references";
import { sqlServerUseDatabaseBeforeCursor } from "@/lib/sql/sqlCompletionLookupTarget";
@@ -137,15 +138,6 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
);
}
// Mirrors CodeMirror's own root handling: shadow roots only expose
// `getSelection` on some browsers, otherwise the owner document holds it.
function editorRootSelection(currentView: EditorViewType): Selection | null {
const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null };
if (root.nodeType !== 11) return (root as unknown as Document).getSelection();
if (typeof root.getSelection === "function") return root.getSelection() ?? null;
return root.ownerDocument?.getSelection() ?? null;
}
// See queryEditorDiagnosticCaretAnchor.ts for why the browser caret needs re-anchoring.
function reanchorCaretAfterDiagnostics(currentView: EditorViewType) {
const selection = currentView.state.selection;
+14
View File
@@ -222,6 +222,20 @@ describe("SQL builtin highlight tag", () => {
});
describe("editor gutters", () => {
it("does not let the gutter minimum height cycle against the scroller content height", () => {
const rules = buildEditorFontThemeRules();
// CodeMirror's base theme applies `min-height: 100%` to `.cm-gutter` while
// `.cm-gutters` is sized from the scroller content (`.cm-content`), so the
// percentage resolves against a height the gutter itself participates in.
// WebKit then re-runs layout for the whole gutter subtree whenever layout
// is forced, which macOS 27 does on every selection collapse: a 639 line
// query made right click / Esc / select-all freeze for ~180ms. The gutter
// spacers already span the full content height and `.cm-gutters` paints the
// background, so the cyclic minimum must stay at 0.
expect(rules[".cm-gutter"]).toMatchObject({ minHeight: "0" });
});
it("keeps single line numbers vertically centered in the base rule", () => {
const rules = buildEditorFontThemeRules();
@@ -0,0 +1,120 @@
// @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";
const DOC = `SELECT ${"x".repeat(NATIVE_SELECTION_PARK_MIN_CHARS)}`;
interface FakeView {
view: EditorView;
contentDOM: HTMLElement;
dispatch: ReturnType<typeof vi.fn>;
selectedText: () => string;
}
function fakeView(from: number, to: number): FakeView {
const dom = document.createElement("div");
const contentDOM = document.createElement("div");
const paragraph = document.createElement("p");
const text = document.createTextNode(DOC);
paragraph.appendChild(text);
contentDOM.appendChild(paragraph);
dom.appendChild(contentDOM);
document.body.appendChild(dom);
const range = { from, to, empty: from === to, head: to, anchor: from };
const dispatch = vi.fn();
const state = {
selection: { main: range, ranges: [range] },
lineBreak: "\n",
readOnly: false,
sliceDoc: (start: number, end: number) => DOC.slice(start, end),
};
const view = {
root: document,
dom,
contentDOM,
state,
dispatch,
domAtPos: (pos: number) => ({ node: text, offset: Math.min(pos, DOC.length) }),
} as unknown as EditorView;
const selection = document.getSelection()!;
selection.collapse(text, from);
selection.extend(text, to);
return { view, contentDOM, dispatch, selectedText: () => document.getSelection()?.toString() ?? "" };
}
function copyEvent(target: EventTarget, type = "copy") {
const data = { clearData: vi.fn(), setData: vi.fn() };
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: data });
target.dispatchEvent(event);
return { data, event };
}
describe("parkEditorNativeSelection", () => {
it("leaves short selections alone", () => {
const { view } = fakeView(0, 5);
expect(parkEditorNativeSelection(view)).toBeNull();
expect(document.getSelection()?.toString()).toBe(DOC.slice(0, 5));
});
it("parks the browser selection outside the content element while the state keeps its selection", () => {
const { view, contentDOM } = fakeView(10, 10 + NATIVE_SELECTION_PARK_MIN_CHARS);
const park = parkEditorNativeSelection(view);
expect(park).not.toBeNull();
const selection = document.getSelection()!;
expect(contentDOM.contains(selection.anchorNode)).toBe(false);
expect(view.state.selection.main.to - view.state.selection.main.from).toBe(NATIVE_SELECTION_PARK_MIN_CHARS);
park!.release();
expect(contentDOM.contains(document.getSelection()?.anchorNode ?? null)).toBe(true);
});
it("fills the clipboard for a copy CodeMirror declined, using the injected normalizer", () => {
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const finalize = vi.fn((text: string) => text.replace(/\n/g, "\r\n"));
const park = parkEditorNativeSelection(view, { finalizeClipboardText: finalize });
const { data, event } = copyEvent(contentDOM);
expect(finalize).toHaveBeenCalledWith(DOC.slice(0, NATIVE_SELECTION_PARK_MIN_CHARS));
expect(data.setData).toHaveBeenCalledWith("text/plain", DOC.slice(0, NATIVE_SELECTION_PARK_MIN_CHARS).replace(/\n/g, "\r\n"));
expect(event.defaultPrevented).toBe(true);
park!.release();
});
it("cuts through its own path when CodeMirror declines", () => {
const { view, contentDOM, dispatch } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = parkEditorNativeSelection(view);
const { data } = copyEvent(contentDOM, "cut");
expect(data.setData).toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ changes: [{ from: 0, to: NATIVE_SELECTION_PARK_MIN_CHARS }], userEvent: "delete.cut" }));
park!.release();
});
it("stays out of the way when the editor already handled the copy", () => {
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = parkEditorNativeSelection(view);
contentDOM.addEventListener("copy", (event) => event.preventDefault());
const { data } = copyEvent(contentDOM);
expect(data.setData).not.toHaveBeenCalled();
park!.release();
});
it("stops listening once released", () => {
const { view, contentDOM } = fakeView(0, NATIVE_SELECTION_PARK_MIN_CHARS);
const park = parkEditorNativeSelection(view)!;
park.release();
const { data } = copyEvent(contentDOM);
expect(data.setData).not.toHaveBeenCalled();
});
});
@@ -824,6 +824,16 @@ export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scroll
".cm-trimmedSelection-bottomRight": {
borderBottomRightRadius: "3px",
},
// CodeMirror's base theme gives `.cm-gutter` `min-height: 100%` inside a
// `.cm-gutters` box that is itself sized by `height: 100%` of a scroller
// whose height comes from the content. That percentage cycle makes WebKit
// re-run layout for the whole gutter subtree on every layout pass, which
// costs ~160ms per collapse with a few hundred lines selected. The gutter's
// own spacers already span the full content height and the background is
// painted by `.cm-gutters`, so dropping the cyclic minimum is visually inert.
".cm-gutter": {
minHeight: "0",
},
".cm-gutters": {
borderRight: "0 !important",
fontSize: `var(${EDITOR_FONT_SIZE_CSS_VAR}, ${defaults?.size ?? 13}px)`,
@@ -0,0 +1,188 @@
import type { EditorView } from "@codemirror/view";
import { appendDebugLog } from "@/lib/backend/debugLog";
/**
* macOS 26/27 asks the web view to serialize the browser selection into an
* attributed string whenever the pointer dwells over text services (the
* Writing Tools check that follows a text selection). Resolving that string
* means a colour lookup per styled run, so one request over a few hundred
* highlighted lines of SQL costs well over 100 ms of web process main thread
* time — and while one of the app's own menus sits on top of the selection
* every pointer move over that menu asks again, which saturates the main
* thread and makes right click, Esc and the whole window feel frozen.
*
* 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.
*
* 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
* the parked selection stays where it is, but it also means CodeMirror's own
* `copy`/`cut` handler declines the event — see `handleParkedClipboardEvent`,
* which supplies the clipboard text in that window.
*/
/**
* Selections shorter than this are serialized cheaply enough that parking them
* would only trade a measurable cost for an invisible one.
*/
export const NATIVE_SELECTION_PARK_MIN_CHARS = 1000;
export interface EditorNativeSelectionPark {
/** Put the browser selection back where the editor state has it. */
release(): void;
}
export interface EditorNativeSelectionParkOptions {
/**
* 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 copying with the menu open matches
* copying with it closed. Injected rather than read from the facet so this
* module never pulls CodeMirror into the startup bundle.
*/
finalizeClipboardText?: (text: string) => string;
}
/**
* Mirrors CodeMirror's own root handling: shadow roots only expose
* `getSelection` on some browsers, otherwise the owner document holds it.
*/
export function editorRootSelection(currentView: EditorView): Selection | null {
const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null };
if (root.nodeType !== 11) return (root as unknown as Document).getSelection();
if (typeof root.getSelection === "function") return root.getSelection() ?? null;
return root.ownerDocument?.getSelection() ?? null;
}
function isParkedOutsideContent(currentView: EditorView, selection: Selection): boolean {
const anchor = selection.anchorNode;
return !!anchor && !currentView.contentDOM.contains(anchor);
}
function parkSelection(currentView: EditorView, selection: Selection): void {
try {
// `view.dom` (the editor element) sits outside `view.contentDOM`, so
// CodeMirror treats the parked selection as somebody else's and leaves the
// editor state alone. It is also far from the highlighted text, which is
// what keeps the attributed string cheap.
selection.collapse(currentView.dom, 0);
} catch {
// WebKit rejects a collapse whose node disappeared between layout passes.
}
}
function restoreSelectionFromState(currentView: EditorView, selection: Selection): void {
try {
const main = currentView.state.selection.main;
const anchor = currentView.domAtPos(main.from);
const head = main.empty ? anchor : currentView.domAtPos(main.to);
selection.collapse(anchor.node, anchor.offset);
if (!main.empty) selection.extend(head.node, head.offset);
} catch {
// WebKit refuses `extend` when the target moved in the meantime; the next
// selection change makes CodeMirror rewrite the browser selection anyway.
}
}
/**
* The text CodeMirror would put on the clipboard for the current state.
*
* Mirrors CodeMirror's `copiedRange` (join the non-empty ranges with the
* document line break, then run the app's output filter) so a copy taken while
* the selection is parked is byte-identical to a copy taken while it is not.
*/
function copiedEditorText(currentView: EditorView, finalizeText: (text: string) => string): { text: string; ranges: { from: number; to: number }[] } | null {
const { state } = currentView;
const parts: string[] = [];
const ranges: { from: number; to: number }[] = [];
for (const range of state.selection.ranges) {
if (range.empty) continue;
parts.push(state.sliceDoc(range.from, range.to));
ranges.push({ from: range.from, to: range.to });
}
if (!parts.length) return null;
return { text: finalizeText(parts.join(state.lineBreak)), ranges };
}
/**
* Fills the clipboard for a `copy`/`cut` that CodeMirror declined.
*
* CodeMirror's handler only acts when the browser selection sits inside
* `contentDOM` (`handlers.copy` bails out on `hasSelection(view.contentDOM,
* …)`), which a parked selection deliberately is not. Without this the user
* would copy the parked caret instead of the visible selection.
*
* The app's own menus close on the first non-modifier keydown, so a Cmd+C
* normally releases the park before the copy lands and never reaches here;
* this covers the copies that arrive while the selection is still parked
* (clicking a copy item, platform-initiated copies).
*/
function handleParkedClipboardEvent(currentView: EditorView, finalizeClipboardText: (text: string) => string, released: () => boolean, event: ClipboardEvent): void {
if (released() || event.defaultPrevented) return;
// `copy`/`cut` fire on the focused element; a parked selection only exists
// while the editor owns the interaction, so anything else is somebody else's.
const target = event.target as Node | null;
if (target && !currentView.dom.contains(target)) return;
const payload = copiedEditorText(currentView, finalizeClipboardText);
if (!payload) return;
const data = event.clipboardData;
if (!data) return;
data.clearData();
data.setData("text/plain", payload.text);
if (event.type === "cut" && !currentView.state.readOnly) currentView.dispatch({ changes: payload.ranges, scrollIntoView: true, userEvent: "delete.cut" });
event.preventDefault();
}
/**
* 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 {
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 });
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.
const doc = currentView.dom.ownerDocument;
const win = doc.defaultView ?? window;
const onCopy = (event: ClipboardEvent) => handleParkedClipboardEvent(currentView, finalizeClipboardText, () => released, event);
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);
frame = win.requestAnimationFrame(keepParked);
};
frame = win.requestAnimationFrame(keepParked);
return {
release() {
if (released) return;
released = true;
doc.removeEventListener("copy", onCopy);
doc.removeEventListener("cut", onCopy);
win.cancelAnimationFrame(frame);
const live = editorRootSelection(currentView);
if (live && isParkedOutsideContent(currentView, live)) restoreSelectionFromState(currentView, live);
},
};
}