diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 94ee5664f..f8444c730 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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(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(); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index 0d32f6198..adab603bb 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -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; diff --git a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts index 572fc2075..dc8c82ad7 100644 --- a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts +++ b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts @@ -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(); diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts new file mode 100644 index 000000000..3d701f6d8 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorNativeSelection.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index c928f5e33..98a47fb98 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -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)`, diff --git a/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts b/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts new file mode 100644 index 000000000..e58a4e9a1 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorNativeSelection.ts @@ -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); + }, + }; +}