perf(layout): track panel resizing at a stepped cadence
This commit is contained in:
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;
|
||||
|
||||
@@ -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'"
|
||||
|
||||
+216
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user