From 42d0eedfc797c21057f4bd3e505c23b52306f746 Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 2 Aug 2026 10:08:36 +0800 Subject: [PATCH] fix(grid): preserve scroll position across transpose toggle --- apps/desktop/src/components/grid/DataGrid.vue | 13 +++++++- .../src/lib/dataGrid/dataGridTranspose.ts | 11 ++++++- .../dataGridTransposeLifecycle.test.ts | 30 +++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index eeea1ba7d2..88472b824d 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1927,6 +1927,8 @@ const gridStyle = computed(() => ({ const gridHorizontalScrollLeft = ref(0); const gridViewportWidth = ref(0); let gridScrollLeftBeforeTranspose = 0; +let gridScrollTopBeforeKeyboardTranspose: number | null = null; +let restoreGridScrollTopAfterTranspose = false; const { renderedColumnOffsets, horizontalColumnWindow, @@ -6093,13 +6095,18 @@ function toggleKeyboardTranspose(): boolean { selectedRowIds: selectedRowIds.value, selectedRange: selectedRange.value, }); + if (next.showTranspose) { + gridScrollTopBeforeKeyboardTranspose = gridScrollerElement()?.scrollTop ?? null; + } else if (gridScrollTopBeforeKeyboardTranspose !== null) { + restoreGridScrollTopAfterTranspose = true; + } showTranspose.value = next.showTranspose; transposeRowIndex.value = next.transposeRowIndex; if (next.showTranspose) { closeCellDetails(); nextTick(updateTransposeViewport); if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); - } else { + } else if (!restoreGridScrollTopAfterTranspose) { scrollGridRowIntoView(requestedRowIndex); } return true; @@ -6825,10 +6832,14 @@ watch(isTransposeMode, (active) => { return; } + const scrollTopBeforeTranspose = restoreGridScrollTopAfterTranspose ? (gridScrollTopBeforeKeyboardTranspose ?? undefined) : undefined; + restoreGridScrollTopAfterTranspose = false; + gridScrollTopBeforeKeyboardTranspose = null; nextTick(() => { restoreDataGridAfterTranspose({ scroller: gridScrollerElement(), scrollLeftBeforeTranspose: gridScrollLeftBeforeTranspose, + scrollTopBeforeTranspose, attachCanvasResizeObserver, refreshGridScrollerMetrics, }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts index e6c497d236..f7f1c445d4 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridTranspose.ts @@ -189,8 +189,17 @@ 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 }) { +export function restoreDataGridAfterTranspose(options: { + scroller: Pick | null; + scrollLeftBeforeTranspose: number; + scrollTopBeforeTranspose?: number; + attachCanvasResizeObserver: () => void; + refreshGridScrollerMetrics: () => void; +}) { if (!options.scroller) return; + if (options.scrollTopBeforeTranspose !== undefined) { + options.scroller.scrollTop = Math.max(0, Math.min(Math.max(0, options.scroller.scrollHeight - options.scroller.clientHeight), options.scrollTopBeforeTranspose)); + } options.scroller.scrollLeft = restoredDataGridScrollLeft(options.scrollLeftBeforeTranspose, options.scroller.scrollWidth, options.scroller.clientWidth); options.attachCanvasResizeObserver(); options.refreshGridScrollerMetrics(); diff --git a/packages/app-tests/dataGridTransposeLifecycle.test.ts b/packages/app-tests/dataGridTransposeLifecycle.test.ts index ac2917bafd..5d0eb5b21d 100644 --- a/packages/app-tests/dataGridTransposeLifecycle.test.ts +++ b/packages/app-tests/dataGridTransposeLifecycle.test.ts @@ -5,12 +5,14 @@ import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "../ 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 }; +type TestScroller = HTMLElement & { clientHeight: number; scrollHeight: number }; -function createScroller(scrollWidth: number, clientWidth: number, clientHeight: number): TestScroller { +function createScroller(scrollWidth: number, clientWidth: number, clientHeight: number, scrollHeight = 1200): TestScroller { return { children: [], + scrollTop: 0, scrollLeft: 0, + scrollHeight, scrollWidth, clientWidth, clientHeight, @@ -134,6 +136,30 @@ test("restores a wide canvas grid after single-row and multi-row transpose remou scrollbarsRuntime.dispose(); }); +test("restores the vertical grid position after a keyboard transpose round trip", () => { + const viewport = createScroller(600, 600, 320, 2600); + + restoreDataGridAfterTranspose({ + scroller: viewport, + scrollLeftBeforeTranspose: 0, + scrollTopBeforeTranspose: 1300, + attachCanvasResizeObserver() {}, + refreshGridScrollerMetrics() {}, + }); + + assert.equal(viewport.scrollTop, 1300); + + restoreDataGridAfterTranspose({ + scroller: viewport, + scrollLeftBeforeTranspose: 0, + scrollTopBeforeTranspose: 2500, + attachCanvasResizeObserver() {}, + refreshGridScrollerMetrics() {}, + }); + + assert.equal(viewport.scrollTop, 2280, "restored position should stay within the remounted grid bounds"); +}); + 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\]/);