mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(editor): avoid native selection lag in macOS context menu
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user