fix(editor): place caret on click after unfocused sql scroll

Closes #9296
This commit is contained in:
九日 authored and GitHub committed 2026-09-16 21:23:58 +08:00
1 parent 9cb6b28a0f
commit 85653427ef
3 files changed
+190

No files matched your search

@@ -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)) {
@@ -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();
}
});
});
@@ -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<UnfocusedQueryEditorView, "hasFocus"> | 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;
}