perf(layout): track panel resizing at a stepped cadence

This commit is contained in:
dengjie zhang authored and GitHub committed 2026-09-18 01:48:27 +08:00
1 parent a78fc48a7b
commit 8eba12ea2f
17 files changed
+705 -34

No files matched your search

@@ -153,6 +153,8 @@ import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { copySqlAsRichText } from "@/lib/sql/sqlRichText";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { isPanelResizing } from "@/lib/app/panelResizeState";
import { uiTuning } from "@/lib/app/uiTuning";
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape";
@@ -297,6 +299,90 @@ const emit = defineEmits<{
}>();
const editorRef = ref<HTMLDivElement>();
// While any panel divider is dragged, CodeMirror re-wraps and re-measures
// every visible line on every frame the editor box changes — with a 100+ line
// script that is the dominant drag cost (profiler: 2k+ measure() calls with
// getBoundingClientRect in a 3s drag). We pin the editor root to an explicit
// pixel size and then step-update that pin at most every TRACK_EVERY_FRAMES
// animation frames (~30fps), which keeps the editor visually following the
// divider while cutting the relayout frequency (and the layout thrash from
// interleaved reads/writes) by half or more. The step interval is tunable via
// ~/.dbx/ui-tuning.json (panelResizeTrackEveryFrames). The pin is released
// once on pointer-up for one final exact layout.
let frozenEditorBox: { width: number; height: number } | null = null;
let editorTrackFrameId = 0;
let editorTrackFrameCount = 0;
function editorAvailableSize(): { width: number; height: number } | null {
const host = editorRef.value?.parentElement;
if (!host) return null;
const width = host.clientWidth;
const height = host.clientHeight;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function pinEditorBox(size: { width: number; height: number }) {
const root = editorRef.value;
if (!root) return;
frozenEditorBox = size;
root.style.width = `${Math.round(size.width)}px`;
root.style.height = `${Math.round(size.height)}px`;
root.style.maxWidth = "none";
root.style.minHeight = "0";
}
function releaseEditorBox() {
const root = editorRef.value;
frozenEditorBox = null;
if (!root) return;
root.style.width = "";
root.style.height = "";
root.style.maxWidth = "";
root.style.minHeight = "";
}
function stopEditorBoxTracking() {
if (editorTrackFrameId) {
cancelAnimationFrame(editorTrackFrameId);
editorTrackFrameId = 0;
}
editorTrackFrameCount = 0;
}
function editorBoxTrackStep() {
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
if (++editorTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return;
editorTrackFrameCount = 0;
const next = editorAvailableSize();
if (!next || !frozenEditorBox) return;
if (Math.round(next.width) === Math.round(frozenEditorBox.width) && Math.round(next.height) === Math.round(frozenEditorBox.height)) return;
// Writing the pin keeps the editor a fixed-size box: the parent's layout is
// reflowed (cheap, the editor subtree is `contain`ed and skipped) but
// CodeMirror only observes one discrete size change per step.
pinEditorBox(next);
}
watch(
isPanelResizing,
(resizing) => {
const root = editorRef.value;
if (!root) return;
if (resizing) {
if (frozenEditorBox) return;
const size = editorAvailableSize();
if (!size) return;
pinEditorBox(size);
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
} else {
stopEditorBoxTracking();
if (frozenEditorBox) releaseEditorBox();
}
},
{ flush: "sync" },
);
onBeforeUnmount(stopEditorBoxTracking);
const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
+83 -28
View File
@@ -296,6 +296,8 @@ import { useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeade
import { createColumnReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent } from "@/lib/editor/queryEditorTableDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { deferUntilPanelResizeEnd, isPanelResizing } from "@/lib/app/panelResizeState";
import { uiTuning } from "@/lib/app/uiTuning";
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation";
@@ -2591,43 +2593,56 @@ function observeGridHorizontalScrollbarScroller() {
gridScrollbarsRuntime.observeScroller();
}
let dataGridTopbarRafId: number | null = null;
function updateDataGridTopbarWidth() {
const topbar = dataGridTopbarRef.value;
const previousActionCount = compactDataGridToolbarActionCount.value;
dataGridTopbarWidth.value = topbar?.clientWidth ?? 0;
dataGridViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
if (!topbar) return;
if (compactDataGridToolbarActionCount.value !== previousActionCount) {
scheduleDataGridTopbarRecheck();
return;
}
// Reading scrollWidth/clientWidth here forces a document-wide synchronous
// relayout; while a divider drag is in flight that stacks on top of
// CodeMirror's measurement. Defer to a single re-measure when the drag ends.
if (deferUntilPanelResizeEnd(updateDataGridTopbarWidth)) return;
if (dataGridTopbarOverflowCompact.value || dataGridTopbarOverflowActionCount.value > 0) {
if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) {
dataGridTopbarOverflowCompact.value = false;
dataGridTopbarOverflowActionCount.value = 0;
dataGridTopbarExpandedRequiredWidth.value = 0;
const performMeasure = () => {
dataGridTopbarRafId = null;
const currentTopbar = dataGridTopbarRef.value;
if (!currentTopbar) return;
const previousActionCount = compactDataGridToolbarActionCount.value;
dataGridTopbarWidth.value = currentTopbar.clientWidth ?? 0;
dataGridViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
if (compactDataGridToolbarActionCount.value !== previousActionCount) {
scheduleDataGridTopbarRecheck();
return;
}
}
// The action list gains preview/save/rollback controls in editable grids. The
// responsive breakpoint cannot see that extra width, so progressively
// compact the leftmost controls before overflow-clip hides a trailing action.
if (topbar.scrollWidth > topbar.clientWidth + 1) {
dataGridTopbarExpandedRequiredWidth.value = Math.max(dataGridTopbarExpandedRequiredWidth.value, topbar.scrollWidth + DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX);
if (!compactDataGridToolbar.value) {
dataGridTopbarOverflowCompact.value = true;
} else if (compactDataGridToolbarActionCount.value < DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length) {
dataGridTopbarOverflowActionCount.value = compactDataGridToolbarActionCount.value + 1;
} else {
return;
if (dataGridTopbarOverflowCompact.value || dataGridTopbarOverflowActionCount.value > 0) {
if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) {
dataGridTopbarOverflowCompact.value = false;
dataGridTopbarOverflowActionCount.value = 0;
dataGridTopbarExpandedRequiredWidth.value = 0;
scheduleDataGridTopbarRecheck();
return;
}
}
scheduleDataGridTopbarRecheck();
}
if (currentTopbar.scrollWidth > currentTopbar.clientWidth + 1) {
dataGridTopbarExpandedRequiredWidth.value = Math.max(dataGridTopbarExpandedRequiredWidth.value, currentTopbar.scrollWidth + DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX);
if (!compactDataGridToolbar.value) {
dataGridTopbarOverflowCompact.value = true;
} else if (compactDataGridToolbarActionCount.value < DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length) {
dataGridTopbarOverflowActionCount.value = compactDataGridToolbarActionCount.value + 1;
} else {
return;
}
scheduleDataGridTopbarRecheck();
}
};
if (dataGridTopbarRafId !== null) cancelAnimationFrame(dataGridTopbarRafId);
dataGridTopbarRafId = requestAnimationFrame(performMeasure);
}
function resetDataGridTopbarOverflowCompact() {
@@ -6576,7 +6591,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number |
return Number.isInteger(rowIndex) ? rowIndex : null;
}
function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) {
function syncCanvasViewportRaw(entries?: readonly ResizeObserverEntry[]) {
if (!dataGridIsActive) return;
const scroller = canvasScrollerElement();
if (!scroller) return;
@@ -6591,6 +6606,45 @@ function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) {
canvasRuntime?.drawNow();
}
// Public entry: ResizeObserver / imperative callers. During divider drags the
// step tracker owns refreshes, so plain notifications are dropped here.
function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) {
if (!dataGridIsActive) return;
if (isPanelResizing.value) return;
syncCanvasViewportRaw(entries);
}
// During divider drags the ResizeObserver would fire every frame; re-measure
// and redraw the canvas on a fixed step instead (same cadence as the SQL
// editor's pinned-box tracking, tunable via ~/.dbx/ui-tuning.json) so the
// grid follows the drag at reduced cost.
let canvasTrackFrameId = 0;
let canvasTrackFrameCount = 0;
function canvasTrackStep() {
canvasTrackFrameId = requestAnimationFrame(canvasTrackStep);
if (++canvasTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return;
canvasTrackFrameCount = 0;
syncCanvasViewportRaw();
}
function stopCanvasResizeTracking() {
if (canvasTrackFrameId) {
cancelAnimationFrame(canvasTrackFrameId);
canvasTrackFrameId = 0;
}
canvasTrackFrameCount = 0;
}
watch(isPanelResizing, (resizing) => {
if (resizing) {
if (!canvasTrackFrameId) canvasTrackFrameId = requestAnimationFrame(canvasTrackStep);
} else {
stopCanvasResizeTracking();
syncCanvasViewportRaw();
}
});
function currentCanvasDevicePixelRatio(): number {
return typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1);
}
@@ -7266,6 +7320,7 @@ function pauseCanvasGridWork() {
gridRef.value?.setAttribute("data-grid-active", "false");
if (gridSurfaceBusy.value) finishDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
canvasRuntime.pause();
stopCanvasResizeTracking();
gridScrollbarsRuntime.pause();
disconnectCellEditResizeObserver();
dataGridTopbarResizeObserver?.disconnect();
@@ -155,6 +155,7 @@ import {
tabularResultItems,
type ExecutionSummaryItem,
} from "@/lib/tabs/tabPresentation";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/table/tableEditing";
@@ -708,7 +709,11 @@ function handleHideResultsPane() {
}
}
function onResultsSplitResize() {
beginPanelResize();
}
function onResultsResized(payload: { panes: { size: number }[] }) {
endPanelResize();
const resultsPane = payload.panes[1];
if (resultsPane?.size != null && resultsPane.size >= 20 && resultsPane.size <= 85) {
resultsPaneSize.value = resultsPane.size;
@@ -1428,7 +1433,7 @@ defineExpose({
</div>
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resize="onResultsSplitResize" @resized="onResultsResized">
<Pane v-if="!resultOnly" class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, provide, ref, useAttrs, watch } from "vue";
import { computed, inject, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -9,6 +9,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import { hasQueryOutput } from "@/lib/query/queryOutput";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
import { Button } from "@/components/ui/button";
import EditorGroup from "./EditorGroup.vue";
import QueryResultSurface from "./QueryResultSurface.vue";
@@ -198,6 +199,28 @@ function paneEnterClass(groupId: string): string | undefined {
}
return queryStore.orientation === "horizontal" ? "workspace-pane-enter workspace-pane-enter--from-top" : "workspace-pane-enter workspace-pane-enter--from-left";
}
let splitterDragging = false;
const splitterEndEvents = ["mouseup", "touchend", "touchcancel", "pointerup", "pointercancel"] as const;
function onWorkspaceSplitterStart(event: MouseEvent | TouchEvent) {
const splitter = event.target instanceof Element ? event.target.closest(".splitpanes__splitter") : null;
if (!splitter?.parentElement?.matches(".sql-editor-workspace-split, .sql-editor-groups") || splitterDragging) return;
splitterDragging = true;
beginPanelResize();
for (const eventName of splitterEndEvents) document.addEventListener(eventName, onWorkspaceSplitterEnd, true);
window.addEventListener("blur", onWorkspaceSplitterEnd);
}
function onWorkspaceSplitterEnd() {
if (!splitterDragging) return;
splitterDragging = false;
for (const eventName of splitterEndEvents) document.removeEventListener(eventName, onWorkspaceSplitterEnd, true);
window.removeEventListener("blur", onWorkspaceSplitterEnd);
endPanelResize();
}
onBeforeUnmount(onWorkspaceSplitterEnd);
watch(() => props.contentSuppressed, onWorkspaceSplitterEnd);
function onSharedResultResized(payload: { panes: { size: number }[] }) {
const resultPane = payload.panes[1];
if (resultPane?.size != null && resultPane.size >= SHARED_RESULT_PANE_MIN_SIZE && resultPane.size <= SHARED_RESULT_PANE_MAX_SIZE) {
@@ -302,7 +325,7 @@ function handleFocusErrorOffset(tabId: string, offset: number): boolean {
@detach-tab="emit('detach-tab', $event)"
/>
</template>
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @mousedown.capture="onWorkspaceSplitterStart" @touchstart.capture="onWorkspaceSplitterStart" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
@@ -0,0 +1,216 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beginPanelResize, deferUntilPanelResizeEnd, endPanelResize, isPanelResizing } from "@/lib/app/panelResizeState";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryTab } from "@/types/database";
import SqlEditorWorkspace from "../SqlEditorWorkspace.vue";
vi.mock("@/components/layout/EditorGroup.vue", () => ({ default: { template: "<div />" } }));
vi.mock("@/components/layout/QueryResultSurface.vue", () => ({ default: { template: "<div />" } }));
describe("workspace real Splitpanes drag lifecycle", () => {
let frames: FrameRequestCallback[];
let cleanup: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
frames = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback));
localStorage.removeItem("dbx-shared-results-pane-size");
});
afterEach(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
vi.runAllTimers();
cleanup?.();
cleanup = undefined;
endPanelResize();
vi.unstubAllGlobals();
vi.useRealTimers();
document.body.innerHTML = "";
});
async function flushFrames() {
const pending = frames.splice(0);
for (const callback of pending) callback(0);
await nextTick();
}
async function mountWorkspace(orientation: "horizontal" | "vertical" = "vertical") {
const pinia = createPinia();
setActivePinia(pinia);
const store = useQueryStore();
const tabs: QueryTab[] = ["first", "second"].map((id) => ({
id,
title: id,
connectionId: "conn",
database: "db",
sql: "SELECT 1",
mode: "query",
isExecuting: false,
result: { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 },
}));
store.tabs = tabs;
store.activeTabId = tabs[0]!.id;
store.groups = tabs.map((tab) => ({ id: tab.id, tabIds: [tab.id], activeTabId: tab.id }));
store.focusedGroupId = tabs[0]!.id;
store.sizes = [50, 50];
store.orientation = orientation;
const host = document.createElement("div");
document.body.append(host);
const app = createApp(SqlEditorWorkspace, {
activeTab: tabs[0],
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanup = () => app.unmount();
await nextTick();
for (const container of host.querySelectorAll(".splitpanes")) {
Object.defineProperty(container, "clientWidth", { value: 1000 });
Object.defineProperty(container, "clientHeight", { value: 1000 });
}
await flushFrames();
return { host, store, app };
}
function start(host: HTMLElement, shared = false) {
const selector = shared ? ".sql-editor-workspace-split" : ".sql-editor-groups";
const splitter = host.querySelector(`${selector} > .splitpanes__splitter`)!;
vi.spyOn(splitter, "getBoundingClientRect").mockReturnValue({ left: 500, top: 500 } as DOMRect);
splitter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 500, clientY: 500 }));
}
function move(position: number) {
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: position, clientY: position }));
}
it.each(["horizontal", "vertical", "shared"] as const)("ignores queued resize after release for %s panes and preserves final sizes", async (layout) => {
const { host, store } = await mountWorkspace(layout === "horizontal" ? "horizontal" : "vertical");
start(host, layout === "shared");
move(600);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
const resync = vi.fn();
expect(deferUntilPanelResizeEnd(resync)).toBe(true);
move(650);
expect(frames.length).toBeGreaterThan(0);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
const sizes = [...store.sizes];
const resultSize = localStorage.getItem("dbx-shared-results-pane-size");
if (layout === "shared") expect(Number(resultSize)).toBeCloseTo(40);
else expect(sizes).toEqual([60, 40]);
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
expect(store.sizes).toEqual(sizes);
expect(localStorage.getItem("dbx-shared-results-pane-size")).toBe(resultSize);
});
it("ignores the first queued frame when released before any frame runs", async () => {
const { host } = await mountWorkspace();
start(host);
move(600);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
});
it.each(["pointerup", "pointercancel", "blur", "unmount"])("cleans up on %s", async (eventName) => {
const { host } = await mountWorkspace();
start(host);
move(600);
await flushFrames();
const resync = vi.fn();
deferUntilPanelResizeEnd(resync);
if (eventName !== "unmount") move(650);
if (eventName === "unmount") {
cleanup?.();
cleanup = undefined;
} else if (eventName === "blur") window.dispatchEvent(new Event(eventName));
else document.dispatchEvent(new Event(eventName));
expect(isPanelResizing.value).toBe(false);
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
});
it.each(["touchend", "touchcancel"])("tracks real touch dragging and cleans up on %s", async (eventName) => {
vi.stubGlobal("ontouchstart", null);
const { host } = await mountWorkspace();
const splitter = host.querySelector(".sql-editor-groups > .splitpanes__splitter")!;
const touchStart = new Event("touchstart", { bubbles: true });
Object.defineProperty(touchStart, "touches", { value: [{ clientX: 0, clientY: 0 }] });
splitter.dispatchEvent(touchStart);
const touchMove = new Event("touchmove", { bubbles: true });
Object.defineProperty(touchMove, "touches", { value: [{ clientX: 600, clientY: 600 }] });
document.dispatchEvent(touchMove);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
document.dispatchEvent(touchMove);
document.dispatchEvent(new Event(eventName, { bubbles: true }));
await flushFrames();
expect(isPanelResizing.value).toBe(false);
});
it("keeps tracking across frames and pauses until the real release", async () => {
const { host, store } = await mountWorkspace();
start(host);
move(600);
await flushFrames();
const resync = vi.fn();
deferUntilPanelResizeEnd(resync);
vi.advanceTimersByTime(1600);
expect(isPanelResizing.value).toBe(true);
move(700);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
expect(resync).not.toHaveBeenCalled();
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(store.sizes).toEqual([70, 30]);
expect(resync).toHaveBeenCalledTimes(1);
});
it("does not let a previous drag's queued frame end the next drag", async () => {
const { host } = await mountWorkspace();
start(host);
move(600);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
vi.advanceTimersByTime(100);
start(host);
expect(isPanelResizing.value).toBe(true);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
});
it("ignores content clicks and leaves another panel's resize state alone", async () => {
const { host, store } = await mountWorkspace();
host.querySelector(".splitpanes__pane")!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
beginPanelResize();
store.sizes = [55, 45];
await nextTick();
await flushFrames();
expect(isPanelResizing.value).toBe(true);
cleanup?.();
cleanup = undefined;
expect(isPanelResizing.value).toBe(true);
});
});
@@ -49,3 +49,10 @@
animation: none;
}
}
/* 隔离编辑器区域与结果集区域的重排作用域,防止大文档重测联动穿透导致卡顿 */
.sql-editor-groups,
[data-shared-result-surface] {
contain: layout style;
}