From 0533e5c75f4be96340430e67171e0328a9dcf6b4 Mon Sep 17 00:00:00 2001 From: "shuwen.wu" Date: Sat, 26 Sep 2026 00:13:02 +0800 Subject: [PATCH 1/3] fix(animation): correct scroll position after animation to handle fractional row/col heights - Run post-animation scroll correction for both integer and fractional scroll targets - Add post-animation correction for scrollToCol (consistent with scrollToRow) - Use Math.round instead of Math.floor in animation onUpdate to reduce pixel drift - Add explicit boundary check in carousel plugin to reliably reset at end of scroll range - Fixes TableCarouselAnimationPlugin failing to reach bottom when defaultRowHeight is a decimal value --- .../vtable-plugins/src/table-carousel-animation.ts | 8 ++++++-- packages/vtable/src/core/BaseTable.ts | 13 ++++++++----- packages/vtable/src/core/animation.ts | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/vtable-plugins/src/table-carousel-animation.ts b/packages/vtable-plugins/src/table-carousel-animation.ts index 57f6d6397f..513b6ad571 100644 --- a/packages/vtable-plugins/src/table-carousel-animation.ts +++ b/packages/vtable-plugins/src/table-carousel-animation.ts @@ -101,6 +101,9 @@ export class TableCarouselAnimationPlugin implements pluginsDefinition.IVTablePl if (customRow) { this.row = customRow.distRow; animation = customRow.animation ?? true; + } else if (this.row >= this.table.rowCount - 1) { + this.row = this.table.frozenRowCount; + animation = false; } else if (isInteger(this.row) && screenTopRow !== this.row) { this.row = this.table.frozenRowCount; animation = false; @@ -120,7 +123,6 @@ export class TableCarouselAnimationPlugin implements pluginsDefinition.IVTablePl this.willUpdateRow = false; this.updateRow(); }, - // animation ? this.animationDuration + this.animationDelay : 0 this.animationDuration + this.animationDelay ); } @@ -135,6 +137,9 @@ export class TableCarouselAnimationPlugin implements pluginsDefinition.IVTablePl if (customCol) { this.col = customCol.distCol; animation = customCol.animation ?? true; + } else if (this.col >= this.table.colCount - 1) { + this.col = this.table.frozenColCount; + animation = false; } else if (isInteger(this.col) && this.table.scenegraph.proxy.screenLeftCol !== this.col) { this.col = this.table.frozenColCount; animation = false; @@ -156,7 +161,6 @@ export class TableCarouselAnimationPlugin implements pluginsDefinition.IVTablePl this.willUpdateCol = false; this.updateCol(); }, - // animation ? this.animationDuration + this.animationDelay : 0 this.animationDuration + this.animationDelay ); } diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 8f880d7481..0c1b6ba96e 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -5309,18 +5309,21 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } const duration = !isBoolean(animationOption) ? animationOption?.duration ?? 3000 : 3000; this.animationManager.scrollTo({ row: targetRow }, animationOption); - if (targetRowInt === targetRow) { - this._scrollToRowCorrectTimer = setTimeout(() => { - this.scrollToRow(targetRowInt, false); - }, duration); - } + this._scrollToRowCorrectTimer = setTimeout(() => { + this.scrollToRow(targetRowInt, false); + }, duration); } scrollToCol(col: number, animationOption?: ITableAnimationOption | boolean) { if (!animationOption) { this.scrollToCell({ col }); return; } + const duration = !isBoolean(animationOption) ? animationOption?.duration ?? 3000 : 3000; this.animationManager.scrollTo({ col }, animationOption); + setTimeout(() => { + this.scrollToCell({ col: Math.floor(col) }); + this.render(); + }, duration); } /** * 滚动到具体某个单元格位置 diff --git a/packages/vtable/src/core/animation.ts b/packages/vtable/src/core/animation.ts index 32fd437f32..e34f48b2ae 100644 --- a/packages/vtable/src/core/animation.ts +++ b/packages/vtable/src/core/animation.ts @@ -10,11 +10,11 @@ function isInteger(value: number) { class Animateaaa extends ACustomAnimate { onUpdate(end: boolean, ratio: number, out: Record): void { if (this.from.x !== this.to.x) { - const x = end ? this.to.x : this.from.x + Math.floor((this.to.x - this.from.x) * ratio); + const x = end ? this.to.x : this.from.x + Math.round((this.to.x - this.from.x) * ratio); this.params.table.scrollLeft = x; } if (this.from.y !== this.to.y) { - const y = end ? this.to.y : this.from.y + Math.floor((this.to.y - this.from.y) * ratio); + const y = end ? this.to.y : this.from.y + Math.round((this.to.y - this.from.y) * ratio); this.params.table.scrollTop = y; } } From 67fa198d8ec0f4f31280b26095a1ad59ebea827e Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Fri, 9 Oct 2026 02:01:21 +0800 Subject: [PATCH 2/3] fix: preserve fractional scroll targets and cancel correction timers --- ...ix-carousel-boundary_2026-10-09-02-00.json | 11 +++++ ...el-scroll-correction_2026-10-09-02-00.json | 11 +++++ .../api/listTable-scrollToRow.test.ts | 46 +++++++++++++++++-- packages/vtable/src/core/BaseTable.ts | 37 ++++++++++++--- 4 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 common/changes/@visactor/vtable-plugins/fix-carousel-boundary_2026-10-09-02-00.json create mode 100644 common/changes/@visactor/vtable/fix-carousel-scroll-correction_2026-10-09-02-00.json diff --git a/common/changes/@visactor/vtable-plugins/fix-carousel-boundary_2026-10-09-02-00.json b/common/changes/@visactor/vtable-plugins/fix-carousel-boundary_2026-10-09-02-00.json new file mode 100644 index 0000000000..07cc6043d1 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-carousel-boundary_2026-10-09-02-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable-plugins): restart carousel scrolling at the row and column boundaries with fractional dimensions", + "type": "patch", + "packageName": "@visactor/vtable-plugins" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "mikewushuwen@outlook.com" +} diff --git a/common/changes/@visactor/vtable/fix-carousel-scroll-correction_2026-10-09-02-00.json b/common/changes/@visactor/vtable/fix-carousel-scroll-correction_2026-10-09-02-00.json new file mode 100644 index 0000000000..d4708d220c --- /dev/null +++ b/common/changes/@visactor/vtable/fix-carousel-scroll-correction_2026-10-09-02-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable): preserve fractional animated scroll targets and cancel column correction when the table is released", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "mikewushuwen@outlook.com" +} diff --git a/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts index 6c2c4bf7e5..6b6776f1bb 100644 --- a/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts +++ b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts @@ -10,7 +10,7 @@ describe('listTable scrollToRow api', () => { document.body.innerHTML = ''; }); - test('keeps fractional row target when scrolling with animation', () => { + test.each(['row', 'col'])('keeps fractional %s target when scrolling with animation', axis => { const container = createDiv(); container.style.position = 'relative'; container.style.width = '400px'; @@ -18,7 +18,7 @@ describe('listTable scrollToRow api', () => { const table = new ListTable({ container, - columns: [{ field: 'name', title: 'Name', width: 120 }], + columns: Array.from({ length: 8 }, (_, index) => ({ field: `name${index}`, title: 'Name', width: 120 })), records: Array.from({ length: 20 }, (_, index) => ({ name: `row-${index}` })), defaultRowHeight: 40 }); @@ -26,11 +26,49 @@ describe('listTable scrollToRow api', () => { const scrollTo = jest.spyOn(table.animationManager, 'scrollTo').mockImplementation(() => undefined); const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any); - table.scrollToRow(1.5, { duration: 100, easing: 'linear' }); + if (axis === 'row') { + table.scrollToRow(1.5, { duration: 100, easing: 'linear' }); + } else { + table.scrollToCol(1.5, { duration: 100, easing: 'linear' }); + } - expect(scrollTo).toHaveBeenCalledWith({ row: 1.5 }, { duration: 100, easing: 'linear' }); + expect(scrollTo).toHaveBeenCalledWith({ [axis]: 1.5 }, { duration: 100, easing: 'linear' }); expect(setTimeoutSpy).not.toHaveBeenCalled(); table.release(); }); + + test('cancels column correction on replacement and release, and ignores a late callback', () => { + const container = createDiv(); + container.style.width = '400px'; + container.style.height = '240px'; + const table = new ListTable({ + container, + columns: Array.from({ length: 8 }, (_, index) => ({ field: `name${index}`, title: 'Name', width: 120 })), + records: [{ name0: 'first' }] + }); + jest.spyOn(table.animationManager, 'scrollTo').mockImplementation(() => undefined); + const callbacks = []; + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout').mockImplementation(callback => { + callbacks.push(callback); + return callbacks.length as any; + }); + const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); + + table.scrollToCol(2, { duration: 100 }); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + const firstHandle = table._scrollToColCorrectTimer; + table.scrollToCol(3, { duration: 100 }); + expect(clearTimeoutSpy).toHaveBeenCalledWith(firstHandle); + const releaseHandle = table._scrollToColCorrectTimer; + table.release(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(releaseHandle); + expect(table._scrollToColCorrectTimer).toBeNull(); + + const scrollToCell = jest.spyOn(table, 'scrollToCell'); + const render = jest.spyOn(table, 'render'); + callbacks[1](); + expect(scrollToCell).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + }); }); diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 0c1b6ba96e..c4408959cc 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -176,6 +176,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps: IBaseTableProtected; showFrozenIcon = true; _scrollToRowCorrectTimer: ReturnType | null = null; + _scrollToColCorrectTimer: ReturnType | null = null; padding: { top: number; left: number; right: number; bottom: number }; globalDropDownMenu?: MenuListItem[] | ((args: { row: number; col: number; table: BaseTableAPI }) => MenuListItem[]); //画布绘制单元格的区域 不包括整体边框frame,所以比canvas的width和height要小一点(canvas的width包括了frame) @@ -2671,6 +2672,13 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } } + private clearColCorrectTimer() { + if (this._scrollToColCorrectTimer !== null) { + clearTimeout(this._scrollToColCorrectTimer); + this._scrollToColCorrectTimer = null; + } + } + /** * Dispose the table instance. * @returns {void} @@ -2726,6 +2734,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { delete element.__vtable_id__; } this.clearCorrectTimer(); + this.clearColCorrectTimer(); internalProps.tooltipHandler?.release?.(); internalProps.menuHandler?.release?.(); @@ -5285,6 +5294,9 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { private _scheduleScrollToRowCorrect(row: number, delay: number = 0) { this._scrollToRowCorrectTimer = setTimeout(() => { this.clearCorrectTimer(); + if (this.isReleased) { + return; + } const targetScrollTop = this.getTargetScrollTop(row); if (targetScrollTop !== this.scrollTop) { this.scrollTop = targetScrollTop; @@ -5309,21 +5321,32 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } const duration = !isBoolean(animationOption) ? animationOption?.duration ?? 3000 : 3000; this.animationManager.scrollTo({ row: targetRow }, animationOption); - this._scrollToRowCorrectTimer = setTimeout(() => { - this.scrollToRow(targetRowInt, false); - }, duration); + if (targetRowInt === targetRow) { + this._scrollToRowCorrectTimer = setTimeout(() => { + this._scrollToRowCorrectTimer = null; + if (!this.isReleased) { + this.scrollToRow(targetRowInt, false); + } + }, duration); + } } scrollToCol(col: number, animationOption?: ITableAnimationOption | boolean) { + this.clearColCorrectTimer(); if (!animationOption) { this.scrollToCell({ col }); return; } const duration = !isBoolean(animationOption) ? animationOption?.duration ?? 3000 : 3000; this.animationManager.scrollTo({ col }, animationOption); - setTimeout(() => { - this.scrollToCell({ col: Math.floor(col) }); - this.render(); - }, duration); + if (Math.floor(col) === col) { + this._scrollToColCorrectTimer = setTimeout(() => { + this._scrollToColCorrectTimer = null; + if (!this.isReleased) { + this.scrollToCell({ col }); + this.render(); + } + }, duration); + } } /** * 滚动到具体某个单元格位置 From affa6032056153ecc4e39f40b29c7bd879cf8f2e Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 15 Oct 2026 13:37:22 +0800 Subject: [PATCH 3/3] fix: avoid redundant render after column correction --- .../api/listTable-scrollToRow.test.ts | 23 +++++++++++++++++++ packages/vtable/src/core/BaseTable.ts | 1 - 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts index 6b6776f1bb..3f4c1959a9 100644 --- a/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts +++ b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts @@ -38,6 +38,29 @@ describe('listTable scrollToRow api', () => { table.release(); }); + test('renders once after animated column correction', () => { + const table = Object.create(Object.getPrototypeOf(ListTable.prototype)); + table._scrollToColCorrectTimer = null; + table.isReleased = false; + table.clearColCorrectTimer = jest.fn(); + table.animationManager = { scrollTo: jest.fn() }; + const callbacks = []; + jest.spyOn(globalThis, 'setTimeout').mockImplementation(callback => { + callbacks.push(callback); + return callbacks.length as any; + }); + const render = jest.fn(); + const scrollToCell = jest.fn(() => render()); + table.render = render; + table.scrollToCell = scrollToCell; + + table.scrollToCol(2, { duration: 100 }); + callbacks[0](); + + expect(scrollToCell).toHaveBeenCalledWith({ col: 2 }); + expect(render).toHaveBeenCalledTimes(1); + }); + test('cancels column correction on replacement and release, and ignores a late callback', () => { const container = createDiv(); container.style.width = '400px'; diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index c4408959cc..d3386edc7d 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -5343,7 +5343,6 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { this._scrollToColCorrectTimer = null; if (!this.isReleased) { this.scrollToCell({ col }); - this.render(); } }, duration); }