From cb2bee24baff8c49fc74ee9b2260202e06d16417 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 24 Jun 2026 08:53:31 +0800 Subject: [PATCH] fix: keep pivot row tree scroll position --- ...expand-scroll-anchor_2026-06-24-00-52.json | 11 + .../vtable/__tests__/pivotTable-tree.test.ts | 249 ++++++++++++++++++ packages/vtable/src/PivotTable.ts | 18 +- 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 common/changes/@visactor/vtable/codex-fix-pivot-expand-scroll-anchor_2026-06-24-00-52.json diff --git a/common/changes/@visactor/vtable/codex-fix-pivot-expand-scroll-anchor_2026-06-24-00-52.json b/common/changes/@visactor/vtable/codex-fix-pivot-expand-scroll-anchor_2026-06-24-00-52.json new file mode 100644 index 000000000..9b465c05d --- /dev/null +++ b/common/changes/@visactor/vtable/codex-fix-pivot-expand-scroll-anchor_2026-06-24-00-52.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: keep pivot row tree scroll position on toggle", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/packages/vtable/__tests__/pivotTable-tree.test.ts b/packages/vtable/__tests__/pivotTable-tree.test.ts index 2c4097d1d..494d1c784 100644 --- a/packages/vtable/__tests__/pivotTable-tree.test.ts +++ b/packages/vtable/__tests__/pivotTable-tree.test.ts @@ -659,4 +659,253 @@ describe('pivotTable grid-tree hierarchy scroll', () => { pivotTable.release(); } }); + + test('keeps scroll top after expanding a row tree node at scroll bottom', async () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '800px'; + containerDom.style.height = '400px'; + + const columnTree = Array.from({ length: 5 }, (_, index) => ({ + value: `indicator-${index}`, + indicatorKey: `indicator-${index}` + })); + const indicators = columnTree.map(item => ({ + indicatorKey: item.indicatorKey, + caption: item.value, + width: 100 + })); + + const pivotTable = new PivotTable({ + container: containerDom, + records: [], + rowTree: [ + ...Array.from({ length: 30 }, (_, index) => ({ + dimensionKey: 'category', + value: `category-${index}` + })), + { + dimensionKey: 'category', + value: 'Technology', + hierarchyState: 'collapse', + children: Array.from({ length: 20 }, (_, index) => ({ + dimensionKey: 'subCategory', + value: `technology-${index}` + })) + } + ], + columnTree, + rows: [ + { + dimensionKey: 'category', + title: 'category', + width: 200 + }, + { + dimensionKey: 'subCategory', + title: 'subCategory', + width: 200 + } + ], + columns: [], + indicators, + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + rowHierarchyType: 'grid-tree', + columnHierarchyType: 'grid-tree', + widthMode: 'standard' + }); + + try { + const getMaxScrollTop = () => Math.max(0, pivotTable.getAllRowsHeight() - pivotTable.scenegraph.height); + + pivotTable.setScrollTop(Number.MAX_SAFE_INTEGER); + const oldMaxScrollTop = getMaxScrollTop(); + expect(pivotTable.scrollTop).toBe(oldMaxScrollTop); + + const visibleRowRange = pivotTable.getBodyVisibleRowRange(); + let targetRow = -1; + for (let row = visibleRowRange.rowStart; row <= visibleRowRange.rowEnd; row++) { + if (pivotTable.getCellValue(0, row) === 'Technology' && pivotTable.getHierarchyState(0, row) === 'collapse') { + targetRow = row; + break; + } + } + expect(targetRow).toBeGreaterThanOrEqual(0); + pivotTable.toggleHierarchyState(0, targetRow); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(getMaxScrollTop()).toBeGreaterThan(oldMaxScrollTop); + expect(pivotTable.scrollTop).toBe(oldMaxScrollTop); + } finally { + pivotTable.release(); + } + }); + + test('returns to scroll bottom after expanding and collapsing a bottom row tree node', async () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '800px'; + containerDom.style.height = '400px'; + + const columnTree = Array.from({ length: 5 }, (_, index) => ({ + value: `indicator-${index}`, + indicatorKey: `indicator-${index}` + })); + const indicators = columnTree.map(item => ({ + indicatorKey: item.indicatorKey, + caption: item.value, + width: 100 + })); + + const pivotTable = new PivotTable({ + container: containerDom, + records: [], + rowTree: [ + ...Array.from({ length: 30 }, (_, index) => ({ + dimensionKey: 'category', + value: `category-${index}` + })), + { + dimensionKey: 'category', + value: 'Technology', + hierarchyState: 'collapse', + children: Array.from({ length: 20 }, (_, index) => ({ + dimensionKey: 'subCategory', + value: `technology-${index}` + })) + } + ], + columnTree, + rows: [ + { + dimensionKey: 'category', + title: 'category', + width: 200 + }, + { + dimensionKey: 'subCategory', + title: 'subCategory', + width: 200 + } + ], + columns: [], + indicators, + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + rowHierarchyType: 'grid-tree', + columnHierarchyType: 'grid-tree', + widthMode: 'standard' + }); + + try { + const getMaxScrollTop = () => Math.max(0, pivotTable.getAllRowsHeight() - pivotTable.scenegraph.height); + + pivotTable.setScrollTop(Number.MAX_SAFE_INTEGER); + const collapsedMaxScrollTop = getMaxScrollTop(); + expect(pivotTable.scrollTop).toBe(collapsedMaxScrollTop); + + const visibleRowRange = pivotTable.getBodyVisibleRowRange(); + let targetRow = -1; + for (let row = visibleRowRange.rowStart; row <= visibleRowRange.rowEnd; row++) { + if (pivotTable.getCellValue(0, row) === 'Technology' && pivotTable.getHierarchyState(0, row) === 'collapse') { + targetRow = row; + break; + } + } + expect(targetRow).toBeGreaterThanOrEqual(0); + + pivotTable.toggleHierarchyState(0, targetRow); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(getMaxScrollTop()).toBeGreaterThan(collapsedMaxScrollTop); + expect(pivotTable.scrollTop).toBe(collapsedMaxScrollTop); + + pivotTable.toggleHierarchyState(0, targetRow); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(getMaxScrollTop()).toBe(collapsedMaxScrollTop); + expect(pivotTable.scrollTop).toBe(getMaxScrollTop()); + } finally { + pivotTable.release(); + } + }); + + test('keeps scroll top after expanding a visible row tree node', async () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '800px'; + containerDom.style.height = '400px'; + + const columnTree = Array.from({ length: 5 }, (_, index) => ({ + value: `indicator-${index}`, + indicatorKey: `indicator-${index}` + })); + const indicators = columnTree.map(item => ({ + indicatorKey: item.indicatorKey, + caption: item.value, + width: 100 + })); + + const pivotTable = new PivotTable({ + container: containerDom, + records: [], + rowTree: [ + ...Array.from({ length: 10 }, (_, index) => ({ + dimensionKey: 'region', + value: `region-${index}`, + hierarchyState: 'collapse', + children: Array.from({ length: 4 }, (_, childIndex) => ({ + dimensionKey: 'province', + value: `province-${index}-${childIndex}` + })) + })), + { + dimensionKey: 'region', + value: 'grand-total' + } + ], + columnTree, + rows: [ + { + dimensionKey: 'region', + title: 'region', + width: 200 + }, + { + dimensionKey: 'province', + title: 'province', + width: 200 + } + ], + columns: [], + indicators, + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + rowHierarchyType: 'grid-tree', + columnHierarchyType: 'grid-tree', + widthMode: 'standard' + }); + + try { + const scrollTop = 90; + pivotTable.setScrollTop(scrollTop); + const oldScrollTop = pivotTable.scrollTop; + + const visibleRowRange = pivotTable.getBodyVisibleRowRange(); + let targetRow = -1; + for (let row = visibleRowRange.rowStart; row <= visibleRowRange.rowEnd; row++) { + if (pivotTable.getHierarchyState(0, row) === 'collapse') { + targetRow = row; + break; + } + } + expect(targetRow).toBeGreaterThanOrEqual(0); + + pivotTable.toggleHierarchyState(0, targetRow); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pivotTable.scrollTop).toBe(oldScrollTop); + } finally { + pivotTable.release(); + } + }); }); diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index bab1bc929..de64c0e3f 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -1571,6 +1571,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { Math.max(0, this.getAllRowsHeight() - (this.scenegraph?.height ?? this.tableNoFrameHeight) - sizeTolerance); const oldMaxScrollTop = getMaxScrollTop(); const isScrollToBottom = oldMaxScrollTop > 0 && this.scrollTop >= oldMaxScrollTop - 1; + const oldScrollTop = this.scrollTop; const visibleStartRow = this.getBodyVisibleRowRange().rowStart; this.internalProps._oldRowCount = this.rowCount; this.internalProps._oldColCount = this.colCount; @@ -1588,6 +1589,9 @@ export class PivotTable extends BaseTable implements PivotTableAPI { } } const isChangeRowTree = this.internalProps.layoutMap.isRowHeader(col, row); + const oldHierarchyState = isChangeRowTree ? this.getHierarchyState(col, row) : undefined; + const shouldKeepBottomAfterToggle = isScrollToBottom && oldHierarchyState === 'expand'; + const shouldKeepScrollAfterToggle = isChangeRowTree && !shouldKeepBottomAfterToggle; const result: { addCellPositionsRowDirection?: CellAddress[]; removeCellPositionsRowDirection?: CellAddress[]; @@ -1645,17 +1649,27 @@ export class PivotTable extends BaseTable implements PivotTableAPI { this.clearCellStyleCache(); this.scenegraph.createSceneGraph(); this.scrollToRow(visibleStartRow); - if (isScrollToBottom) { + if (shouldKeepBottomAfterToggle) { this.clearCorrectTimer(); this.setScrollTop(Number.MAX_SAFE_INTEGER); + } else if (shouldKeepScrollAfterToggle) { + this.clearCorrectTimer(); + this.setScrollTop(oldScrollTop); } // this.renderWithRecreateCells(); } this.reactCustomLayout?.updateAllCustomCell(); - if (isScrollToBottom) { + if (shouldKeepBottomAfterToggle) { this.clearCorrectTimer(); this.setScrollTop(Number.MAX_SAFE_INTEGER); + } else if ( + shouldKeepScrollAfterToggle && + this.rowHierarchyType !== 'grid-tree' && + this.columnHierarchyType !== 'grid-tree' + ) { + this.clearCorrectTimer(); + this.setScrollTop(oldScrollTop); } if (checkHasChart) {