diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d92a1fd003..84c5454ab3 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -97,6 +97,7 @@ import { nextKeyboardTransposeState, nextTransposeState, nextTransposeStateForRecordCount, + restoreDataGridAfterTranspose, transposeRecordIndexesForMode, transposeRecordWidthsForDensity, transposeFieldWidth, @@ -128,7 +129,7 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowSho import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; -import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, restoredDataGridScrollLeft, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; +import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; @@ -4838,11 +4839,10 @@ function drawCanvasGrid() { } watch( - [useCanvasGridRows, hasVisibleRows], + [useCanvasGridRows, hasVisibleRows, isErrorResult], () => { - // When an empty table gets its first pending row, the canvas scroller is created by - // the v-if branch after the original mount-time observer attempt has already no-op'd. - // Reattach after that branch mounts so the canvas/overlay get real viewport dimensions. + // Empty and error surfaces replace the canvas scroller. Reattach after the + // normal branch remounts so the canvas/overlay get real viewport dimensions. nextTick(attachCanvasResizeObserver); }, { immediate: true }, @@ -6304,12 +6304,12 @@ watch(isTransposeMode, (active) => { } nextTick(() => { - const scroller = gridScrollerElement(); - if (!scroller) return; - // Transpose mode replaces the normal grid scroller, so restore its state and - // reconnect overflow observers only after Vue mounts the new element. - scroller.scrollLeft = restoredDataGridScrollLeft(gridScrollLeftBeforeTranspose, scroller.scrollWidth, scroller.clientWidth); - refreshGridScrollerMetrics(); + restoreDataGridAfterTranspose({ + scroller: gridScrollerElement(), + scrollLeftBeforeTranspose: gridScrollLeftBeforeTranspose, + attachCanvasResizeObserver, + refreshGridScrollerMetrics, + }); }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts index 75f72f2a92..21244c46f2 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts @@ -1,4 +1,5 @@ import { COLUMN_WIDTH_DENSITY_PRESETS, percentileValue } from "@/lib/dataGrid/dataGridColumnWidth"; +import { restoredDataGridScrollLeft } from "@/lib/dataGrid/dataGridInfiniteScroll"; import type { ColumnWidthDensity } from "@/stores/settingsStore"; export interface DataGridTransposeState { @@ -174,6 +175,13 @@ export function nextTransposeState(showTranspose: boolean, transposeRowIndex: nu return { showTranspose: true, transposeRowIndex: requestedRowIndex }; } +export function restoreDataGridAfterTranspose(options: { scroller: Pick | null; scrollLeftBeforeTranspose: number; attachCanvasResizeObserver: () => void; refreshGridScrollerMetrics: () => void }) { + if (!options.scroller) return; + options.scroller.scrollLeft = restoredDataGridScrollLeft(options.scrollLeftBeforeTranspose, options.scroller.scrollWidth, options.scroller.clientWidth); + options.attachCanvasResizeObserver(); + options.refreshGridScrollerMetrics(); +} + export function nextContextTransposeState(options: ContextTransposeStateOptions): DataGridTransposeState { const anchorRowIndex = transposeAnchorRowIndex(options); return nextTransposeState(options.showTranspose, options.transposeRowIndex, anchorRowIndex); diff --git a/packages/app-tests/dataGridTransposeLifecycle.test.ts b/packages/app-tests/dataGridTransposeLifecycle.test.ts new file mode 100644 index 0000000000..ac2917bafd --- /dev/null +++ b/packages/app-tests/dataGridTransposeLifecycle.test.ts @@ -0,0 +1,140 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "../../apps/desktop/src/composables/useDataGridCanvasRuntime.ts"; +import { useDataGridScrollbars } from "../../apps/desktop/src/composables/useDataGridScrollbars.ts"; +import { restoreDataGridAfterTranspose, transposeRecordIndexesForMode } from "../../apps/desktop/src/lib/dataGrid/dataGridTranspose.ts"; + +type TestScroller = HTMLElement & { clientHeight: number }; + +function createScroller(scrollWidth: number, clientWidth: number, clientHeight: number): TestScroller { + return { + children: [], + scrollLeft: 0, + scrollWidth, + clientWidth, + clientHeight, + } as unknown as TestScroller; +} + +function createFrameDriver() { + let nextId = 0; + const frames = new Map(); + const driver: DataGridAnimationFrameDriver = { + request(callback) { + const id = nextId++; + frames.set(id, callback); + return id; + }, + cancel(id) { + frames.delete(id); + }, + }; + return { + driver, + runAll() { + while (frames.size > 0) { + const pending = [...frames.entries()]; + frames.clear(); + pending.forEach(([, callback]) => callback(0)); + } + }, + }; +} + +function createObserverHarness() { + const callbacks: ResizeObserverCallback[] = []; + const observed: Element[] = []; + return { + observed, + create(callback: ResizeObserverCallback) { + callbacks.push(callback); + return { + disconnect() {}, + observe(element: Element) { + observed.push(element); + }, + } as unknown as ResizeObserver; + }, + fireLatest() { + callbacks.at(-1)?.([], {} as ResizeObserver); + }, + }; +} + +test("restores a wide canvas grid after single-row and multi-row transpose remounts", () => { + const frames = createFrameDriver(); + const canvasObservers = createObserverHarness(); + let viewport: TestScroller | null = createScroller(1600, 600, 320); + let syncedViewportHeight = 0; + let hasHorizontalOverflow = false; + + const canvasRuntime = useDataGridCanvasRuntime({ + draw() {}, + syncViewport() { + syncedViewportHeight = viewport?.clientHeight ?? 0; + }, + getViewport: () => viewport, + frameDriver: frames.driver, + createResizeObserver: canvasObservers.create, + }); + const scrollbarsRuntime = useDataGridScrollbars({ + update() { + hasHorizontalOverflow = !!viewport && viewport.scrollWidth - viewport.clientWidth > 1; + }, + getScroller: () => viewport, + applyHorizontalDrag() {}, + applyVerticalDrag() {}, + frameDriver: frames.driver, + }); + + canvasRuntime.observeViewport(); + scrollbarsRuntime.observeScroller(); + frames.runAll(); + + for (const { mode, multiRow, expectedRecordIndexes, savedScrollLeft } of [ + { mode: "single-row", multiRow: false, expectedRecordIndexes: [1], savedScrollLeft: 320 }, + { mode: "multi-row", multiRow: true, expectedRecordIndexes: [0, 1, 2], savedScrollLeft: 540 }, + ]) { + assert.deepEqual( + transposeRecordIndexesForMode({ + multiRow, + activeRecordIndex: 1, + totalRecords: 3, + visibleRecordIndexes: [0, 1, 2], + }), + expectedRecordIndexes, + ); + viewport!.scrollLeft = savedScrollLeft; + viewport = null; + canvasRuntime.observeViewport(); + scrollbarsRuntime.observeScroller(); + frames.runAll(); + assert.equal(hasHorizontalOverflow, false, `${mode} transpose should remove the normal grid scrollbar`); + + viewport = createScroller(1600, 600, 320); + restoreDataGridAfterTranspose({ + scroller: viewport, + scrollLeftBeforeTranspose: savedScrollLeft, + attachCanvasResizeObserver: canvasRuntime.observeViewport, + refreshGridScrollerMetrics: scrollbarsRuntime.observeScroller, + }); + frames.runAll(); + + assert.equal(viewport.scrollLeft, savedScrollLeft, `${mode} transpose should restore horizontal position`); + assert.equal(hasHorizontalOverflow, true, `${mode} transpose should restore the custom scrollbar`); + assert.equal(canvasObservers.observed.at(-1), viewport, `${mode} transpose should observe the remounted canvas scroller`); + + viewport.clientHeight = 180; + canvasObservers.fireLatest(); + assert.equal(syncedViewportHeight, 180, `${mode} transpose should react to a later container resize`); + } + + canvasRuntime.dispose(); + scrollbarsRuntime.dispose(); +}); + +test("error result recovery participates in canvas observer remounts", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + assert.match(source, /watch\(\s*\[useCanvasGridRows, hasVisibleRows, isErrorResult\]/); +});