fix(editor): keep scroll after closing sql parameter dialog

This commit is contained in:
Terry Yao
2026-10-08 10:22:10 +08:00
committed by GitHub
parent 33b4652d39
commit 63d996b48d
4 changed files with 236 additions and 45 deletions
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch, type ComponentPublicInstance } from "vue";
import { computed, ref, type ComponentPublicInstance } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useDialogEditorFocusRestore } from "@/composables/useDialogEditorFocusRestore";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { copyToClipboard } from "@/lib/common/clipboard";
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
@@ -21,10 +22,7 @@ const suppressFuturePrompts = defineModel<boolean>("suppressFuturePrompts", { de
const wrap = ref(true);
const copied = ref(false);
// The CodeMirror editor (if any) that was focused when this dialog opened, so
// its internal selection can be restored without letting the browser move the caret.
let editorRootToRestoreFocus: HTMLElement | null = null;
let focusRestoreGeneration = 0;
const { onCloseAutoFocus: onDangerDialogCloseAutoFocus } = useDialogEditorFocusRestore(open);
const props = withDefaults(
defineProps<{
@@ -88,19 +86,6 @@ const dialogOpen = computed({
},
});
watch(
() => open.value,
(isOpen) => {
if (!isOpen) return;
focusRestoreGeneration += 1;
// Keep the original editor while a prior close animation is still pending.
if (editorRootToRestoreFocus) return;
const active = document.activeElement;
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
},
{ immediate: true },
);
const confirmButtonRef = ref<ComponentPublicInstance | HTMLButtonElement | null>(null);
const cancelButtonRef = ref<ComponentPublicInstance | HTMLButtonElement | null>(null);
@@ -122,32 +107,6 @@ function onDangerDialogOpenAutoFocus(event: Event) {
}
}
/**
* Restores focus through CodeMirror's own `EditorView.focus()` instead of the browser default.
*
* Reka UI's default close-auto-focus calls plain DOM `.focus()` on the element that was active
* before the dialog opened. In WebKit, refocusing a CodeMirror contenteditable this way can reset
* its caret to the start of the document, which makes the editor scroll to the top (#7692).
* `EditorView.focus()` restores CodeMirror's internal selection without creating that false change.
*/
function onDangerDialogCloseAutoFocus(event: Event) {
const target = editorRootToRestoreFocus;
if (!target || !target.isConnected) {
editorRootToRestoreFocus = null;
return;
}
event.preventDefault();
// An interrupted close may emit after the dialog has reopened. Suppress the stale native
// restoration, but retain the editor for the next completed close.
if (dialogOpen.value) return;
const generation = focusRestoreGeneration;
void import("@codemirror/view").then(({ EditorView }) => {
if (dialogOpen.value || generation !== focusRestoreGeneration || editorRootToRestoreFocus !== target) return;
editorRootToRestoreFocus = null;
EditorView.findFromDOM(target)?.focus();
});
}
function onConfirm() {
// Guard here as well as on the button: a disabled button still fires on some
// synthetic/keyboard paths, and this one gates a destructive operation.
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import { loadSqlParameterHistory, rememberSqlParameterValues } from "@/lib/sql/sqlParameterHistory";
import { substituteSqlParameters, type SqlParameterDescriptor, type SqlParameterInput, type SqlParameterSyntax, type SqlParameterValueKind } from "@/lib/sql/sqlParameters";
import { useDialogEditorFocusRestore } from "@/composables/useDialogEditorFocusRestore";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -37,6 +38,8 @@ const histories = ref<Record<string, SqlParameterInput[]>>({});
const activeHistoryName = ref("");
let closeHistoryTimer: ReturnType<typeof setTimeout> | undefined;
const { onCloseAutoFocus: onSqlParameterDialogCloseAutoFocus } = useDialogEditorFocusRestore(open);
const parameterKinds: SqlParameterValueKind[] = ["string", "number", "boolean", "null", "raw"];
const syntaxLabels: Record<SqlParameterSyntax, string> = {
@@ -150,7 +153,7 @@ async function copyResolvedSql() {
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]" @close-auto-focus="onSqlParameterDialogCloseAutoFocus">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Braces class="h-5 w-5 text-primary" />
@@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { createApp, defineComponent, h, inject, nextTick, provide, reactive, toRef, type App, type Ref, watch } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import SqlParameterDialog from "@/components/editor/SqlParameterDialog.vue";
import type { SqlParameterDescriptor, SqlParameterInput } from "@/lib/sql/sqlParameters";
const historyMocks = vi.hoisted(() => ({
load: vi.fn<(key: string) => SqlParameterInput[]>(),
remember: vi.fn<(values: Record<string, SqlParameterInput>) => Record<string, SqlParameterInput[]>>(),
}));
vi.mock("@/lib/sql/sqlParameterHistory", () => ({
loadSqlParameterHistory: historyMocks.load,
rememberSqlParameterValues: historyMocks.remember,
}));
historyMocks.load.mockReturnValue([]);
vi.mock("@/composables/useSqlHighlighter", () => ({
useSqlHighlighter: () => ({ highlight: (sql: string) => sql }),
}));
const dialogHarness = vi.hoisted(() => {
const pendingCloseAutoFocus: Array<() => Event> = [];
return {
pendingCloseAutoFocus,
flushCloseAutoFocus: () => pendingCloseAutoFocus.shift()?.(),
reset: () => pendingCloseAutoFocus.splice(0),
};
});
vi.mock("@/components/ui/dialog", () => {
const dialogContextKey = Symbol("sql-parameter-dialog-focus-test");
type DialogContext = { open: Readonly<Ref<boolean>>; trigger: Element | null };
const passthrough = () =>
defineComponent({
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
});
const Dialog = defineComponent({
props: { open: { type: Boolean, default: false } },
setup(props, { slots }) {
provide<DialogContext>(dialogContextKey, {
open: toRef(props, "open"),
trigger: document.activeElement,
});
return () => h("div", slots.default?.());
},
});
const DialogContent = defineComponent({
emits: ["openAutoFocus", "closeAutoFocus"],
setup(_, { emit, slots }) {
const context = inject<DialogContext>(dialogContextKey);
if (!context) throw new Error("DialogContent must be nested in Dialog");
watch(context.open, (isOpen, wasOpen) => {
if (isOpen || !wasOpen) return;
dialogHarness.pendingCloseAutoFocus.push(() => {
const event = new Event("close-auto-focus", { cancelable: true });
emit("closeAutoFocus", event);
if (!event.defaultPrevented && context.trigger instanceof HTMLElement && context.trigger.isConnected) {
context.trigger.focus();
context.trigger.dispatchEvent(new Event("test-native-focus-restoration"));
}
return event;
});
});
return () => h("div", slots.default?.());
},
});
return {
Dialog,
DialogContent,
DialogFooter: passthrough(),
DialogHeader: passthrough(),
DialogTitle: passthrough(),
};
});
const SQL = "SELECT ${value}";
const PARAMETERS: SqlParameterDescriptor[] = [{ key: "value", name: "value", syntax: "shell", token: "${value}" }];
const mountedApps: App[] = [];
const editorViews: EditorView[] = [];
afterEach(() => {
dialogHarness.reset();
for (const app of mountedApps.splice(0)) app.unmount();
for (const view of editorViews.splice(0)) view.destroy();
document.body.innerHTML = "";
});
function mountDialog(state: { open: boolean }) {
const dialogHost = document.createElement("div");
document.body.append(dialogHost);
const app = createApp(
defineComponent({
setup: () => () =>
h(SqlParameterDialog, {
open: state.open,
sql: SQL,
parameters: PARAMETERS,
databaseType: "mysql",
enabledSyntaxes: ["shell"],
"onUpdate:open": (open: boolean) => {
state.open = open;
},
}),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(dialogHost);
return dialogHost;
}
function createEditor() {
const editorHost = document.createElement("div");
document.body.append(editorHost);
const view = new EditorView({
state: EditorState.create({
doc: "SELECT :value;\nSELECT 2;\nSELECT 3;",
selection: { anchor: 20 },
}),
parent: editorHost,
});
editorViews.push(view);
view.focus();
view.contentDOM.addEventListener("test-native-focus-restoration", () => {
view.dispatch({ selection: { anchor: 0 } });
});
return view;
}
describe("SqlParameterDialog focus restoration", () => {
it("prevents native restoration and keeps the CodeMirror selection after cancel", async () => {
const view = createEditor();
const state = reactive({ open: true });
mountDialog(state);
await nextTick();
state.open = false;
await nextTick();
const closeAutoFocus = dialogHarness.flushCloseAutoFocus();
expect(closeAutoFocus?.defaultPrevented).toBe(true);
await vi.waitFor(() => expect(document.activeElement).toBe(view.contentDOM));
expect(view.state.selection.main.head).toBe(20);
});
it("leaves default restoration available for non-CodeMirror triggers", async () => {
const trigger = document.createElement("button");
document.body.append(trigger);
trigger.focus();
const state = reactive({ open: true });
mountDialog(state);
await nextTick();
state.open = false;
await nextTick();
const closeAutoFocus = dialogHarness.flushCloseAutoFocus();
expect(closeAutoFocus?.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(trigger);
});
});
@@ -0,0 +1,52 @@
import { watch, type Ref } from "vue";
/**
* Restores focus to the CodeMirror editor that was focused when a dialog opened.
*
* Reka UI's default close-auto-focus calls plain DOM `.focus()` on the element that was active
* before the dialog opened. In WebKit, refocusing a CodeMirror contenteditable this way can reset
* its caret to the start of the document, which makes the editor scroll to the top (#7692).
* `EditorView.focus()` restores CodeMirror's internal selection without creating that false change.
*
* The returned handler suppresses the native restoration only when a CodeMirror editor actually
* held focus at open time; other triggers keep the default behavior.
*/
export function useDialogEditorFocusRestore(open: Ref<boolean>) {
// The CodeMirror editor (if any) that was focused when this dialog opened, so
// its internal selection can be restored without letting the browser move the caret.
let editorRootToRestoreFocus: HTMLElement | null = null;
let focusRestoreGeneration = 0;
watch(
open,
(isOpen) => {
if (!isOpen) return;
focusRestoreGeneration += 1;
// Keep the original editor while a prior close animation is still pending.
if (editorRootToRestoreFocus) return;
const active = document.activeElement;
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
},
{ immediate: true },
);
function onCloseAutoFocus(event: Event) {
const target = editorRootToRestoreFocus;
if (!target || !target.isConnected) {
editorRootToRestoreFocus = null;
return;
}
event.preventDefault();
// An interrupted close may emit after the dialog has reopened. Suppress the stale native
// restoration, but retain the editor for the next completed close.
if (open.value) return;
const generation = focusRestoreGeneration;
void import("@codemirror/view").then(({ EditorView }) => {
if (open.value || generation !== focusRestoreGeneration || editorRootToRestoreFocus !== target) return;
editorRootToRestoreFocus = null;
EditorView.findFromDOM(target)?.focus();
});
}
return { onCloseAutoFocus };
}