diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 05794a57da..48433de45f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -154,6 +154,7 @@ import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlig import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints"; import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; +import { stabilizeUnfocusedQueryEditorPointerDown } from "@/lib/editor/queryEditorUnfocusedPointer"; import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection, startsQueryEditorSelectionDrag, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection"; @@ -6629,6 +6630,7 @@ onMounted(async () => { if (currentView && startEditorSelectionDrag(currentView, event)) { return true; } + if (currentView) stabilizeUnfocusedQueryEditorPointerDown(currentView, event); // Alt belongs to CodeMirror's rectangular and multi-cursor gestures, // even when Cmd/Ctrl is held at the same time. if (!usesQueryEditorObjectNavigationModifier(event)) { diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorUnfocusedPointer.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorUnfocusedPointer.spec.ts new file mode 100644 index 0000000000..a120a44974 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorUnfocusedPointer.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { preserveQueryEditorScrollPosition, shouldStabilizeUnfocusedQueryEditorPointerDown, stabilizeUnfocusedQueryEditorPointerDown } from "@/lib/editor/queryEditorUnfocusedPointer"; + +function createEvent( + overrides: Partial<{ + altKey: boolean; + button: number; + ctrlKey: boolean; + detail: number; + metaKey: boolean; + shiftKey: boolean; + }> = {}, +) { + return { + altKey: false, + button: 0, + ctrlKey: false, + detail: 1, + metaKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...overrides, + }; +} + +function createView(hasFocus = false) { + const scrollDOM = { scrollLeft: 40, scrollTop: 320 }; + return { + hasFocus, + focus: vi.fn(() => { + scrollDOM.scrollLeft = 0; + scrollDOM.scrollTop = 0; + }), + posAtCoords: vi.fn(() => 42), + dispatch: vi.fn(), + scrollDOM, + }; +} + +describe("shouldStabilizeUnfocusedQueryEditorPointerDown", () => { + it("handles an unmodified left click on an unfocused editor", () => { + expect( + shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent(), { + hasFocus: false, + }), + ).toBe(true); + }); + + it("leaves focused clicks, Shift-extend, multi-click, non-primary buttons, and modifier clicks alone", () => { + expect( + shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent(), { + hasFocus: true, + }), + ).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ shiftKey: true }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ detail: 2 }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ detail: 3 }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ button: 1 }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ metaKey: true }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ ctrlKey: true }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent({ altKey: true }), { hasFocus: false })).toBe(false); + expect(shouldStabilizeUnfocusedQueryEditorPointerDown(createEvent(), null)).toBe(false); + }); +}); + +describe("preserveQueryEditorScrollPosition", () => { + it("restores a scroller that jumped during focus", () => { + const scroller = { scrollLeft: 40, scrollTop: 320 }; + const restoreScroll = preserveQueryEditorScrollPosition(scroller); + scroller.scrollLeft = 0; + scroller.scrollTop = 0; + restoreScroll(); + expect(scroller).toEqual({ scrollLeft: 40, scrollTop: 320 }); + }); +}); + +describe("stabilizeUnfocusedQueryEditorPointerDown", () => { + it("focuses and restores the viewport while leaving the selection to codemirror", () => { + const view = createView(); + const event = createEvent(); + const scheduled: Array<() => void> = []; + + expect(stabilizeUnfocusedQueryEditorPointerDown(view, event, (callback) => scheduled.push(callback))).toBe(true); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(view.focus).toHaveBeenCalledOnce(); + expect(view.posAtCoords).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + expect(view.scrollDOM).toEqual({ scrollLeft: 40, scrollTop: 320 }); + + view.scrollDOM.scrollLeft = 12; + view.scrollDOM.scrollTop = 8; + scheduled[0]?.(); + expect(view.scrollDOM).toEqual({ scrollLeft: 40, scrollTop: 320 }); + }); + + it("does not intercept a focused editor click", () => { + const view = createView(true); + const event = createEvent(); + + expect(stabilizeUnfocusedQueryEditorPointerDown(view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(view.focus).not.toHaveBeenCalled(); + expect(view.posAtCoords).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("does not intercept modifier clicks or non-left buttons", () => { + for (const overrides of [{ metaKey: true }, { ctrlKey: true }, { altKey: true }, { button: 1 }, { button: 2 }]) { + const view = createView(); + const event = createEvent(overrides); + + expect(stabilizeUnfocusedQueryEditorPointerDown(view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(view.focus).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + } + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorUnfocusedPointer.ts b/apps/desktop/src/lib/editor/queryEditorUnfocusedPointer.ts new file mode 100644 index 0000000000..71cb44102c --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorUnfocusedPointer.ts @@ -0,0 +1,70 @@ +/** + * Click-to-focus for an unfocused SQL editor. + * + * On macOS WKWebView, a blurred contenteditable keeps its last selection. Clicking + * after the scroller has moved can restore that caret and jump the viewport. The + * scrollbar pointer guard does not cover wheel/trackpad scrolling (#9296). + * + * This only focuses the editor early and restores the viewport around the focus + * jump. Selection stays with CodeMirror's built-in mousedown, which runs after + * custom domEventHandlers and owns caret placement, drag-to-select, and the + * Alt-rectangular and multi-cursor gestures. Calling event.preventDefault() here + * would suppress that handler entirely (InputState.runHandlers stops at the + * first handler whose event is default-prevented). + */ + +import { startsQueryEditorSelectionDrag } from "@/lib/editor/queryEditorPointerSelection"; + +export interface UnfocusedQueryEditorPointerEvent { + altKey: boolean; + button: number; + ctrlKey: boolean; + detail: number; + metaKey: boolean; + shiftKey: boolean; +} + +export interface UnfocusedQueryEditorScroller { + scrollLeft: number; + scrollTop: number; +} + +export interface UnfocusedQueryEditorView { + hasFocus: boolean; + focus(): void; + scrollDOM: UnfocusedQueryEditorScroller; +} + +export function shouldStabilizeUnfocusedQueryEditorPointerDown(event: UnfocusedQueryEditorPointerEvent, view: Pick | null | undefined): boolean { + if (!view || view.hasFocus) return false; + if (event.button !== 0) return false; + // Cmd/Ctrl belong to object navigation and Alt to CodeMirror's rectangular and + // multi-cursor gestures; those clicks must reach their own handlers unfocused. + if (event.metaKey || event.ctrlKey || event.altKey) return false; + return startsQueryEditorSelectionDrag(event); +} + +export function preserveQueryEditorScrollPosition(scroller: UnfocusedQueryEditorScroller) { + const scrollLeft = scroller.scrollLeft; + const scrollTop = scroller.scrollTop; + return () => { + if (scroller.scrollLeft !== scrollLeft) scroller.scrollLeft = scrollLeft; + if (scroller.scrollTop !== scrollTop) scroller.scrollTop = scrollTop; + }; +} + +/** + * Focuses an unfocused editor before CodeMirror's built-in mousedown runs and + * restores the viewport synchronously plus once after a frame; the default is + * left unprevented so CodeMirror performs the selection itself. + */ +export function stabilizeUnfocusedQueryEditorPointerDown(view: UnfocusedQueryEditorView, event: UnfocusedQueryEditorPointerEvent, scheduleFrame?: (callback: () => void) => void): boolean { + if (!shouldStabilizeUnfocusedQueryEditorPointerDown(event, view)) return false; + + const restoreScroll = preserveQueryEditorScrollPosition(view.scrollDOM); + view.focus(); + restoreScroll(); + const schedule = scheduleFrame ?? (typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => callback()); + schedule(restoreScroll); + return true; +}