fix(editor): keep scroll after canceling dangerous sql
This commit is contained in:
1 parent
a751b5690b
commit
694611aade
9 files changed
+215
-11
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,6 +21,10 @@ 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;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sql?: string;
|
||||
@@ -73,6 +77,34 @@ const dialogOpen = computed({
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
const active = document.activeElement;
|
||||
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
editorRootToRestoreFocus = null;
|
||||
if (!target || !target.isConnected) return;
|
||||
event.preventDefault();
|
||||
void import("@codemirror/view").then(({ EditorView }) => {
|
||||
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.
|
||||
@@ -92,7 +124,7 @@ async function copyFullCode() {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="sm:max-w-[480px]">
|
||||
<DialogContent class="sm:max-w-[480px]" @close-auto-focus="onDangerDialogCloseAutoFocus">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
|
||||
@@ -6584,6 +6584,10 @@ function acceptGutterExecutionViewport(requestId: number) {
|
||||
return executionViewportOwnership.acceptRequest(requestId);
|
||||
}
|
||||
|
||||
function cancelGutterExecutionViewport(requestId: number) {
|
||||
return executionViewportOwnership.cancelPendingRequest(requestId);
|
||||
}
|
||||
|
||||
function shouldBlockExecutionShortcut(event?: KeyboardEvent, currentView: EditorViewType | null = view.value): boolean {
|
||||
return (currentView ? isEditorComposing(currentView) : false) || (event ? postCompositionKeyGuard.blocks(event) : false);
|
||||
}
|
||||
@@ -6594,6 +6598,7 @@ defineExpose({
|
||||
scrollCursorIntoView,
|
||||
beginExecutionViewportTracking,
|
||||
acceptGutterExecutionViewport,
|
||||
cancelGutterExecutionViewport,
|
||||
shouldBlockExecutionShortcut,
|
||||
requestExecute,
|
||||
requestExecuteInNewResultTab,
|
||||
|
||||
@@ -9,6 +9,17 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
|
||||
const DANGER_PREVIEW_MAX_LINES = 200;
|
||||
const highlight = vi.fn((sql: string) => `<span>${sql}</span>`);
|
||||
const focusMocks = vi.hoisted(() => {
|
||||
const focus = vi.fn();
|
||||
return {
|
||||
focus,
|
||||
findFromDOM: vi.fn(() => ({ focus })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@codemirror/view", () => ({
|
||||
EditorView: { findFromDOM: focusMocks.findFromDOM },
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({
|
||||
useSqlHighlighter: () => ({ highlight }),
|
||||
@@ -50,6 +61,8 @@ afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
highlight.mockClear();
|
||||
vi.mocked(copyToClipboard).mockClear();
|
||||
focusMocks.findFromDOM.mockClear();
|
||||
focusMocks.focus.mockClear();
|
||||
});
|
||||
|
||||
describe("DangerConfirmDialog SQL preview", () => {
|
||||
@@ -107,6 +120,26 @@ describe("DangerConfirmDialog SQL preview", () => {
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(sql);
|
||||
});
|
||||
|
||||
it("restores a previously focused CodeMirror editor through EditorView on close", async () => {
|
||||
const editorRoot = document.createElement("div");
|
||||
editorRoot.className = "cm-editor";
|
||||
const editorContent = document.createElement("div");
|
||||
editorContent.className = "cm-content";
|
||||
editorContent.tabIndex = 0;
|
||||
editorRoot.append(editorContent);
|
||||
document.body.append(editorRoot);
|
||||
editorContent.focus();
|
||||
|
||||
await mountDialog("DROP TABLE users;");
|
||||
const cancelButton = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Cancel");
|
||||
cancelButton?.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(focusMocks.findFromDOM).toHaveBeenCalledWith(editorRoot);
|
||||
});
|
||||
expect(focusMocks.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DangerConfirmDialog running/cancel footer state", () => {
|
||||
|
||||
@@ -1139,6 +1139,10 @@ function acceptQueryEditorExecutionViewport(requestId: number) {
|
||||
return queryEditorRef.value?.acceptGutterExecutionViewport(requestId) ?? false;
|
||||
}
|
||||
|
||||
function cancelQueryEditorExecutionViewport(requestId: number) {
|
||||
return queryEditorRef.value?.cancelGutterExecutionViewport(requestId) ?? false;
|
||||
}
|
||||
|
||||
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }) {
|
||||
const tab = props.activeTab;
|
||||
if (!tab || tab.mode !== "query") return;
|
||||
@@ -1183,6 +1187,7 @@ defineExpose({
|
||||
requestQueryEditorExecuteInNewResultTab,
|
||||
shouldBlockQueryEditorExecutionShortcut,
|
||||
acceptQueryEditorExecutionViewport,
|
||||
cancelQueryEditorExecutionViewport,
|
||||
pasteClipboardAsSqlInCondition,
|
||||
applyTableStructureChanges,
|
||||
insertRedisCommand,
|
||||
|
||||
Reference in new issue
Block a user