fix(editor): place caret on click after unfocused sql scroll
Closes #9296
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user