diff --git a/.gitignore b/.gitignore
index bb1ecf0d10..71ca08ddf2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,8 @@ jspm_packages/
# Optional eslint cache
.eslintcache
+.jest-cache/
+**/.jest-cache/
# Optional REPL history
.node_repl_history
@@ -109,4 +111,4 @@ tsconfig.tsbuildinfo
.history
.trae/
-.agents/
\ No newline at end of file
+.agents/
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 0000000000..9b465c05df
--- /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/common/changes/@visactor/vtable/fix-issue-3672-right-frozen-count_2026-06-16-07-08.json b/common/changes/@visactor/vtable/fix-issue-3672-right-frozen-count_2026-06-16-07-08.json
new file mode 100644
index 0000000000..b0f18b824a
--- /dev/null
+++ b/common/changes/@visactor/vtable/fix-issue-3672-right-frozen-count_2026-06-16-07-08.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "fix: handle right frozen column count update",
+ "type": "none",
+ "packageName": "@visactor/vtable"
+ }
+ ],
+ "packageName": "@visactor/vtable",
+ "email": "biukam.w@gmail.com"
+}
\ No newline at end of file
diff --git a/common/changes/@visactor/vtable/fix-issue-5088-carousel-fractional-row_2026-06-23-10-48.json b/common/changes/@visactor/vtable/fix-issue-5088-carousel-fractional-row_2026-06-23-10-48.json
new file mode 100644
index 0000000000..4950785848
--- /dev/null
+++ b/common/changes/@visactor/vtable/fix-issue-5088-carousel-fractional-row_2026-06-23-10-48.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "fix: keep fractional row scroll target",
+ "type": "none",
+ "packageName": "@visactor/vtable"
+ }
+ ],
+ "packageName": "@visactor/vtable",
+ "email": "biukam.w@gmail.com"
+}
\ No newline at end of file
diff --git a/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-07-15.json b/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-07-15.json
new file mode 100644
index 0000000000..30f1d280ec
--- /dev/null
+++ b/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-07-15.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "fix: sync gantt task bars after sorted updates\n\n",
+ "type": "none",
+ "packageName": "@visactor/vtable"
+ }
+ ],
+ "packageName": "@visactor/vtable",
+ "email": "liufangfang.jane@bytedance.com"
+}
diff --git a/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-10-52.json b/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-10-52.json
new file mode 100644
index 0000000000..3edf376c32
--- /dev/null
+++ b/common/changes/@visactor/vtable/fix-issue-5162-gantt-sort-drag_2026-06-17-10-52.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "fix: preserve quad style values",
+ "type": "none",
+ "packageName": "@visactor/vtable"
+ }
+ ],
+ "packageName": "@visactor/vtable",
+ "email": "liufangfang.jane@bytedance.com"
+}
diff --git a/packages/vtable-gantt/__tests__/gantt-sort-sync.test.ts b/packages/vtable-gantt/__tests__/gantt-sort-sync.test.ts
new file mode 100644
index 0000000000..513b78751e
--- /dev/null
+++ b/packages/vtable-gantt/__tests__/gantt-sort-sync.test.ts
@@ -0,0 +1,113 @@
+// @ts-nocheck
+
+global.__VERSION__ = 'none';
+
+import { Gantt } from '../src';
+import { createDiv, removeDom } from './dom';
+
+describe('gantt sort sync', () => {
+ test('task bar nodes should bind sorted records after table sort', async () => {
+ const container = createDiv();
+ container.style.width = '900px';
+ container.style.height = '400px';
+
+ const records = [
+ { id: 1, title: 'Task 1', startDate: '2024-07-01', endDate: '2024-07-03', progress: 10 },
+ { id: 2, title: 'Task 2', startDate: '2024-07-05', endDate: '2024-07-07', progress: 20 },
+ { id: 3, title: 'Task 3', startDate: '2024-07-09', endDate: '2024-07-11', progress: 30 }
+ ];
+
+ const gantt = new Gantt(container, {
+ records,
+ taskListTable: {
+ columns: [
+ { field: 'title', title: 'title', width: 160, sort: true },
+ { field: 'startDate', title: 'startDate', width: 120, sort: true }
+ ],
+ tableWidth: 320
+ },
+ taskBar: {
+ startDateField: 'startDate',
+ endDateField: 'endDate',
+ progressField: 'progress'
+ },
+ timelineHeader: {
+ colWidth: 40,
+ scales: [{ unit: 'day', step: 1 }]
+ },
+ minDate: '2024-06-25',
+ maxDate: '2024-07-20'
+ });
+
+ try {
+ gantt.taskListTableInstance.updateSortState({ field: 'startDate', order: 'desc' });
+ await new Promise(resolve => setTimeout(resolve, 250));
+
+ const firstVisibleRecord = gantt.getRecordByIndex(0);
+ const firstTaskBarNode = gantt.scenegraph.taskBar.getTaskBarNodeByIndex(0);
+
+ expect(firstVisibleRecord.id).toBe(3);
+ expect(firstTaskBarNode.record.id).toBe(firstVisibleRecord.id);
+ expect(firstTaskBarNode.record.startDate).toBe(firstVisibleRecord.startDate);
+ } finally {
+ gantt.release?.();
+ removeDom(container);
+ }
+ });
+
+ test('task bar nodes should refresh when date update changes sorted row order', async () => {
+ const container = createDiv();
+ container.style.width = '900px';
+ container.style.height = '400px';
+
+ const records = [
+ { id: 101, title: '需求评审', startDate: '2024-02-05', endDate: '2024-02-12', progress: 20 },
+ { id: 102, title: '交互设计', startDate: '2024-03-10', endDate: '2024-03-18', progress: 35 },
+ { id: 103, title: '接口联调', startDate: '2024-05-28', endDate: '2024-06-05', progress: 50 },
+ { id: 104, title: '灰度验证', startDate: '2024-10-05', endDate: '2024-10-20', progress: 65 },
+ { id: 105, title: '正式上线', startDate: '2024-11-10', endDate: '2024-11-25', progress: 80 }
+ ];
+
+ const gantt = new Gantt(container, {
+ records,
+ taskKeyField: 'id',
+ taskListTable: {
+ columns: [
+ { field: 'title', title: 'title', width: 160, sort: true },
+ { field: 'startDate', title: 'startDate', width: 120, sort: true }
+ ],
+ tableWidth: 320
+ },
+ taskBar: {
+ startDateField: 'startDate',
+ endDateField: 'endDate',
+ progressField: 'progress'
+ },
+ timelineHeader: {
+ colWidth: 40,
+ scales: [{ unit: 'day', step: 1 }]
+ },
+ minDate: '2024-01-01',
+ maxDate: '2024-12-31'
+ });
+
+ try {
+ gantt.taskListTableInstance.updateSortState({ field: 'startDate', order: 'desc' });
+ await new Promise(resolve => setTimeout(resolve, 250));
+
+ expect(gantt.getRecordByIndex(0).id).toBe(105);
+ expect(gantt.scenegraph.taskBar.getTaskBarNodeByIndex(0).record.id).toBe(105);
+
+ gantt._updateStartEndDateToTaskRecord(new Date('2024-09-19'), new Date('2024-10-04'), 0);
+ await new Promise(resolve => setTimeout(resolve, 250));
+
+ expect(gantt.getRecordByIndex(0).id).toBe(104);
+ expect(gantt.scenegraph.taskBar.getTaskBarNodeByIndex(0).record.id).toBe(104);
+ expect(gantt.getRecordByIndex(1).id).toBe(105);
+ expect(gantt.scenegraph.taskBar.getTaskBarNodeByIndex(1).record.id).toBe(105);
+ } finally {
+ gantt.release?.();
+ removeDom(container);
+ }
+ });
+});
diff --git a/packages/vtable-gantt/examples/gantt/gantt-issue-5162-sort-drag.ts b/packages/vtable-gantt/examples/gantt/gantt-issue-5162-sort-drag.ts
new file mode 100644
index 0000000000..b357f98f6f
--- /dev/null
+++ b/packages/vtable-gantt/examples/gantt/gantt-issue-5162-sort-drag.ts
@@ -0,0 +1,132 @@
+import type { ColumnsDefine } from '@visactor/vtable';
+import type { GanttConstructorOptions } from '../../src/index';
+import { Gantt } from '../../src/index';
+
+const CONTAINER_ID = 'vTable';
+
+function createTips(container: HTMLElement) {
+ const tips = document.createElement('div');
+ tips.style.cssText = [
+ 'margin: 12px 0',
+ 'padding: 12px 16px',
+ 'border: 1px solid #d9e2f2',
+ 'border-radius: 6px',
+ 'background: #f7faff',
+ 'font-family: Arial, sans-serif',
+ 'font-size: 13px',
+ 'line-height: 20px',
+ 'color: #1f2329'
+ ].join(';');
+ tips.innerHTML = [
+ 'Issue #5162 Repro
',
+ '1. 点击左侧 start 列排序,切到 desc。
',
+ '2. 拖动排序后的第一行或第二行任务条,观察控制台输出。
',
+ '3. 再次排序或调用 window.ganttIssue5162.logMapping() 查看可见行与任务条绑定是否一致。'
+ ].join('');
+ container.appendChild(tips);
+}
+
+export function createTable() {
+ const container = document.getElementById(CONTAINER_ID)!;
+ container.innerHTML = '';
+ createTips(container);
+
+ const records = [
+ { id: 101, title: '需求评审', owner: 'Alice', start: '2024-02-05', end: '2024-02-12', progress: 20 },
+ { id: 102, title: '交互设计', owner: 'Bob', start: '2024-03-10', end: '2024-03-18', progress: 35 },
+ { id: 103, title: '接口联调', owner: 'Carol', start: '2024-05-28', end: '2024-06-05', progress: 50 },
+ { id: 104, title: '灰度验证', owner: 'David', start: '2024-10-05', end: '2024-10-20', progress: 65 },
+ { id: 105, title: '正式上线', owner: 'Eve', start: '2024-11-10', end: '2024-11-25', progress: 80 }
+ ];
+
+ const columns: ColumnsDefine = [
+ { field: 'title', title: 'title', width: 160, sort: true },
+ { field: 'owner', title: 'owner', width: 120, sort: true },
+ { field: 'start', title: 'start', width: 120, sort: true },
+ { field: 'end', title: 'end', width: 120, sort: true },
+ { field: 'progress', title: 'progress', width: 100, sort: true }
+ ];
+
+ const option: GanttConstructorOptions = {
+ records,
+ taskListTable: {
+ columns,
+ tableWidth: 360,
+ minTableWidth: 280,
+ maxTableWidth: 640
+ },
+ taskKeyField: 'id',
+ taskBar: {
+ startDateField: 'start',
+ endDateField: 'end',
+ progressField: 'progress',
+ moveable: true,
+ labelText: '{title}'
+ },
+ minDate: '2024-01-01',
+ maxDate: '2024-12-31',
+ timelineHeader: {
+ colWidth: 30,
+ scales: [{ unit: 'day', step: 1 }]
+ },
+ scrollStyle: {
+ visible: 'scrolling'
+ },
+ grid: {
+ verticalLine: {
+ lineWidth: 1,
+ lineColor: '#e1e4e8'
+ },
+ horizontalLine: {
+ lineWidth: 1,
+ lineColor: '#e1e4e8'
+ }
+ }
+ };
+
+ const ganttInstance = new Gantt(container, option);
+ (window as any).ganttInstance = ganttInstance;
+
+ const logMapping = () => {
+ const rows = records.map((_, index) => {
+ const visibleRecord = ganttInstance.getRecordByIndex(index);
+ const taskBarNode = ganttInstance.scenegraph.taskBar.getTaskBarNodeByIndex(index);
+ return {
+ row: index,
+ visibleId: visibleRecord?.id,
+ visibleTitle: visibleRecord?.title,
+ taskBarRecordId: taskBarNode?.record?.id,
+ taskBarRecordTitle: taskBarNode?.record?.title
+ };
+ });
+ console.table(rows);
+ return rows;
+ };
+
+ (window as any).ganttIssue5162 = {
+ gantt: ganttInstance,
+ sortByStartDesc: () => ganttInstance.taskListTableInstance?.updateSortState({ field: 'start', order: 'desc' }),
+ sortByStartAsc: () => ganttInstance.taskListTableInstance?.updateSortState({ field: 'start', order: 'asc' }),
+ logMapping,
+ logRecords: () => {
+ console.table(ganttInstance.records);
+ return ganttInstance.records;
+ }
+ };
+
+ ganttInstance.taskListTableInstance?.on('after_sort', () => {
+ console.log('[issue-5162] after_sort');
+ logMapping();
+ });
+
+ ganttInstance.on('move_end_task_bar', e => {
+ console.log('[issue-5162] move_end_task_bar', e);
+ logMapping();
+ console.table(ganttInstance.records);
+ });
+
+ setTimeout(() => {
+ const x = ganttInstance.getXByTime(new Date('2024-06-01 00:00:00').getTime());
+ ganttInstance.scrollLeft = x;
+ }, 0);
+}
diff --git a/packages/vtable-gantt/examples/menu.ts b/packages/vtable-gantt/examples/menu.ts
index 727abebefe..c5e88f7d7a 100644
--- a/packages/vtable-gantt/examples/menu.ts
+++ b/packages/vtable-gantt/examples/menu.ts
@@ -174,6 +174,10 @@ export const menus = [
{
path: 'gantt',
name: 'gantt-locate-taskbar'
+ },
+ {
+ path: 'gantt',
+ name: 'gantt-issue-5162-sort-drag'
}
// ]
// }
diff --git a/packages/vtable-gantt/jest.config.js b/packages/vtable-gantt/jest.config.js
index 80750b3c8f..b85a9b7249 100644
--- a/packages/vtable-gantt/jest.config.js
+++ b/packages/vtable-gantt/jest.config.js
@@ -44,6 +44,7 @@ module.exports = {
statements: 60
}
},
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'd3-color': path.resolve(__dirname, './node_modules/d3-color/dist/d3-color.min.js'),
'd3-array': path.resolve(process.cwd(), './node_modules/d3-array/dist/d3-array.min.js'),
diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts
index 3e45afe087..4084a9713e 100644
--- a/packages/vtable-gantt/src/Gantt.ts
+++ b/packages/vtable-gantt/src/Gantt.ts
@@ -1041,6 +1041,26 @@ export class Gantt extends EventTarget {
// }
this.taskListTableInstance.updateRecords([record], [index]);
}
+ private _refreshSortedTaskBarsAfterRecordUpdate(recordIndex: number | number[], taskShowIndex: number) {
+ const sortState = this.taskListTableInstance?.sortState;
+ if (!sortState || (Array.isArray(sortState) && sortState.length === 0)) {
+ return false;
+ }
+
+ const nextTaskShowIndex = this.getTaskShowIndexByRecordIndex(recordIndex);
+ if (!isValid(nextTaskShowIndex) || nextTaskShowIndex === taskShowIndex) {
+ return false;
+ }
+
+ this._syncPropsFromTable();
+ this.scenegraph.refreshTaskBarsAndGrid();
+
+ const left = this.stateManager.scroll.horizontalBarPos;
+ const top = this.stateManager.scroll.verticalBarPos;
+ this.scenegraph.setX(-left);
+ this.scenegraph.setY(-top);
+ return true;
+ }
/**
* 获取指定index处任务数据的具体信息
* @param taskShowIndex 任务显示的index,从0开始
@@ -1218,12 +1238,13 @@ export class Gantt extends EventTarget {
if (!isValid(sub_task_index)) {
//子任务不是独占左侧表格一行的情况
- const indexs = this.getRecordIndexByTaskShowIndex(index);
- this._updateRecordToListTable(taskRecord, indexs);
+ const recordIndex = this.getRecordIndexByTaskShowIndex(index);
+ this._updateRecordToListTable(taskRecord, Array.isArray(recordIndex) ? recordIndex : index);
// 递归更新父级project任务的时间范围
- if (Array.isArray(indexs)) {
- this.stateManager.updateProjectTaskTimes(indexs);
+ if (Array.isArray(recordIndex)) {
+ this.stateManager.updateProjectTaskTimes(recordIndex);
}
+ this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index);
} else if (Array.isArray(sub_task_index)) {
// 递归更新父级project任务的时间范围
this.stateManager.updateProjectTaskTimes(sub_task_index);
@@ -1239,12 +1260,13 @@ export class Gantt extends EventTarget {
taskRecord[endDateField] = newEndDate;
if (!isValid(sub_task_index)) {
//子任务不是独占左侧表格一行的情况
- const indexs = this.getRecordIndexByTaskShowIndex(index);
- this._updateRecordToListTable(taskRecord, indexs);
+ const recordIndex = this.getRecordIndexByTaskShowIndex(index);
+ this._updateRecordToListTable(taskRecord, Array.isArray(recordIndex) ? recordIndex : index);
// 递归更新父级project任务的时间范围
- if (Array.isArray(indexs)) {
- this.stateManager.updateProjectTaskTimes(indexs);
+ if (Array.isArray(recordIndex)) {
+ this.stateManager.updateProjectTaskTimes(recordIndex);
}
+ this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index);
} else if (Array.isArray(sub_task_index)) {
// 递归更新父级project任务的时间范围
this.stateManager.updateProjectTaskTimes(sub_task_index);
@@ -1261,13 +1283,14 @@ export class Gantt extends EventTarget {
const newEndDate = formatDate(endDate, dateFormat);
taskRecord[endDateField] = newEndDate;
if (!isValid(sub_task_index)) {
- const indexs = this.getRecordIndexByTaskShowIndex(index);
+ const recordIndex = this.getRecordIndexByTaskShowIndex(index);
//子任务不是独占左侧表格一行的情况
- this._updateRecordToListTable(taskRecord, indexs);
+ this._updateRecordToListTable(taskRecord, Array.isArray(recordIndex) ? recordIndex : index);
// 递归更新父级project任务的时间范围
- if (Array.isArray(indexs)) {
- this.stateManager.updateProjectTaskTimes(indexs);
+ if (Array.isArray(recordIndex)) {
+ this.stateManager.updateProjectTaskTimes(recordIndex);
}
+ this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index);
} else if (Array.isArray(sub_task_index)) {
// 递归更新父级project任务的时间范围
this.stateManager.updateProjectTaskTimes(sub_task_index);
@@ -1285,9 +1308,11 @@ export class Gantt extends EventTarget {
const progressField = this.parsedOptions.progressField;
if (progressField) {
taskRecord[progressField] = progress;
- const indexs = this.getRecordIndexByTaskShowIndex(index);
- this._updateRecordToListTable(taskRecord, indexs);
- this._refreshTaskBar(index, sub_task_index);
+ const recordIndex = this.getRecordIndexByTaskShowIndex(index);
+ this._updateRecordToListTable(taskRecord, Array.isArray(recordIndex) ? recordIndex : index);
+ if (!this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index)) {
+ this._refreshTaskBar(index, sub_task_index);
+ }
}
}
diff --git a/packages/vtable-gantt/src/event/event-manager.ts b/packages/vtable-gantt/src/event/event-manager.ts
index b02f464efd..cc583d883d 100644
--- a/packages/vtable-gantt/src/event/event-manager.ts
+++ b/packages/vtable-gantt/src/event/event-manager.ts
@@ -114,12 +114,8 @@ function bindTableGroupListener(event: EventManager) {
return false;
});
if (downBarNode) {
- // 获取任务记录
- // const { taskRecord } = scene._gantt.getTaskInfoByTaskListIndex(
- // downBarNode.task_index,
- // downBarNode.sub_task_index
- // );
- const taskRecord = downBarNode.record;
+ const taskRecord = scene._gantt.getRecordByIndex(downBarNode.task_index, downBarNode.sub_task_index);
+ downBarNode.record = taskRecord;
// 检查是否是project类型
const isProjectTask = taskRecord?.type === TaskType.PROJECT;
if (!isProjectTask) {
diff --git a/packages/vtable-gantt/src/state/gantt-table-sync.ts b/packages/vtable-gantt/src/state/gantt-table-sync.ts
index 5fecbd3fc4..bb6475c9ff 100644
--- a/packages/vtable-gantt/src/state/gantt-table-sync.ts
+++ b/packages/vtable-gantt/src/state/gantt-table-sync.ts
@@ -70,12 +70,50 @@ export function syncTreeChangeFromTable(gantt: Gantt) {
});
}
export function syncSortFromTable(gantt: Gantt) {
- gantt.taskListTableInstance?.on('after_sort', (args: any) => {
+ const taskListTableInstance = gantt.taskListTableInstance as any;
+ if (!taskListTableInstance || taskListTableInstance._vtableGanttSortSyncPatched) {
+ return;
+ }
+
+ const syncTaskBarsAfterSort = (attempt: number = 0) => {
gantt.scenegraph.refreshTaskBars();
+
+ const taskCount = Math.min(gantt.itemCount ?? 0, 10);
+ const taskKeyField = gantt.parsedOptions.taskKeyField;
+ let taskBarsSynced = true;
+ for (let index = 0; index < taskCount; index++) {
+ const taskBarNode = gantt.scenegraph.taskBar.getTaskBarNodeByIndex(index);
+ const visibleRecord = gantt.getRecordByIndex(index);
+ if (taskBarNode && taskBarNode.record?.[taskKeyField] !== visibleRecord?.[taskKeyField]) {
+ taskBarsSynced = false;
+ break;
+ }
+ }
+
+ if (!taskBarsSynced && attempt < 10) {
+ setTimeout(() => syncTaskBarsAfterSort(attempt + 1), 16);
+ return;
+ }
+
const left = gantt.stateManager.scroll.horizontalBarPos;
const top = gantt.stateManager.scroll.verticalBarPos;
gantt.scenegraph.setX(-left);
gantt.scenegraph.setY(-top);
+ };
+
+ const originalUpdateSortState = taskListTableInstance.updateSortState?.bind(taskListTableInstance);
+ if (originalUpdateSortState) {
+ taskListTableInstance.updateSortState = (...args: any[]) => {
+ const result = originalUpdateSortState(...args);
+ syncTaskBarsAfterSort();
+ return result;
+ };
+ }
+
+ taskListTableInstance._vtableGanttSortSyncPatched = true;
+ taskListTableInstance.on('after_sort', () => {
+ // Retry until task bars bind the latest sorted records instead of stale pre-sort records.
+ syncTaskBarsAfterSort();
});
}
export function syncDragOrderFromTable(gantt: Gantt) {
diff --git a/packages/vtable-gantt/src/state/state-manager.ts b/packages/vtable-gantt/src/state/state-manager.ts
index 07c242b40e..f2255b642b 100644
--- a/packages/vtable-gantt/src/state/state-manager.ts
+++ b/packages/vtable-gantt/src/state/state-manager.ts
@@ -356,6 +356,7 @@ export class StateManager {
if (target.name === 'task-bar-hover-shadow') {
target = target.parent;
}
+ syncTaskBarNodeRecord(target, this._gantt);
this.moveTaskBar.moving = true;
this.moveTaskBar.target = target;
this.moveTaskBar.targetStartX = target.attribute.x;
@@ -417,11 +418,13 @@ export class StateManager {
);
// 判断横向拖动 更新数据的date
let dateChanged: 'left' | 'right';
+ let reorderedTaskShowIndex: number | undefined;
if (createDateAtMidnight(oldStartDate).getTime() !== newStartDate.getTime()) {
dateChanged = createDateAtMidnight(oldStartDate).getTime() > newStartDate.getTime() ? 'left' : 'right';
// this._gantt._updateDateToTaskRecord('move', days, taskIndex, sub_task_index);
this._gantt._updateStartEndDateToTaskRecord(newStartDate, newEndDate, taskIndex, sub_task_index);
- const newRecord = this._gantt.getRecordByIndex(taskIndex, sub_task_index);
+ const newRecord = target.record;
+ reorderedTaskShowIndex = getTaskShowIndexByTaskRecord(newRecord, this._gantt);
if (this._gantt.hasListeners(GANTT_EVENT_TYPE.CHANGE_DATE_RANGE)) {
this._gantt.fireListeners(GANTT_EVENT_TYPE.CHANGE_DATE_RANGE, {
@@ -435,7 +438,7 @@ export class StateManager {
}
const indexs = getTaskIndexsByTaskY(targetEndY, this._gantt);
- const newRowIndex = indexs.task_index;
+ const newRowIndex = isValid(reorderedTaskShowIndex) ? reorderedTaskShowIndex : indexs.task_index;
// 触发通用拖拽事件
if (this._gantt.hasListeners(GANTT_EVENT_TYPE.MOVE_END_TASK_BAR)) {
this._gantt.fireListeners(GANTT_EVENT_TYPE.MOVE_END_TASK_BAR, {
@@ -506,39 +509,48 @@ export class StateManager {
}
// target = this._gantt.scenegraph.taskBar.getTaskBarNodeByIndex(indexs.task_index, indexs.sub_task_index);
} else {
- let newX = startDateColIndex >= 1 ? this._gantt.getDateColsWidth(0, startDateColIndex - 1) : 0;
- if (target.record.type === TaskType.MILESTONE) {
- const milestoneTaskbarHeight = this._gantt.parsedOptions.taskBarMilestoneStyle.width;
- newX -= milestoneTaskbarHeight / 2;
- }
- moveTaskBar(target, newX - (target as Group).attribute.x, targetEndY - (target as Group).attribute.y, this);
-
- // 为了确保拖拽后 保持startDate日期晚的显示在上层不被盖住 这里需要重新排序一下
- if (dateChanged === 'right') {
- let insertAfterNode = target;
- while (
- (insertAfterNode as Group).nextSibling &&
- (insertAfterNode as Group).nextSibling.attribute.y === (target as Group).attribute.y &&
- (insertAfterNode as Group).nextSibling.record[this._gantt.parsedOptions.startDateField] <=
- target.record[this._gantt.parsedOptions.startDateField]
- ) {
- insertAfterNode = (insertAfterNode as Group).nextSibling as any;
- }
- if (insertAfterNode !== target) {
- ((insertAfterNode as Group).parent as any).insertAfter(target, insertAfterNode);
- }
- } else if (dateChanged === 'left') {
- let insertBeforeNode = target;
- while (
- (insertBeforeNode as Group).previousSibling &&
- (insertBeforeNode as Group).previousSibling.attribute.y === (target as Group).attribute.y &&
- (insertBeforeNode as Group).previousSibling.record[this._gantt.parsedOptions.startDateField] >=
- target.record[this._gantt.parsedOptions.startDateField]
- ) {
- insertBeforeNode = (insertBeforeNode as Group).previousSibling as any;
+ if (dateChanged && isValid(reorderedTaskShowIndex) && reorderedTaskShowIndex !== taskIndex) {
+ this._gantt._syncPropsFromTable();
+ this._gantt.scenegraph.refreshTaskBarsAndGrid();
+ const left = this._gantt.stateManager.scroll.horizontalBarPos;
+ const top = this._gantt.stateManager.scroll.verticalBarPos;
+ this._gantt.scenegraph.setX(-left);
+ this._gantt.scenegraph.setY(-top);
+ } else {
+ let newX = startDateColIndex >= 1 ? this._gantt.getDateColsWidth(0, startDateColIndex - 1) : 0;
+ if (target.record.type === TaskType.MILESTONE) {
+ const milestoneTaskbarHeight = this._gantt.parsedOptions.taskBarMilestoneStyle.width;
+ newX -= milestoneTaskbarHeight / 2;
}
- if (insertBeforeNode !== target) {
- ((insertBeforeNode as Group).parent as any).insertBefore(target, insertBeforeNode);
+ moveTaskBar(target, newX - (target as Group).attribute.x, targetEndY - (target as Group).attribute.y, this);
+
+ // 为了确保拖拽后 保持startDate日期晚的显示在上层不被盖住 这里需要重新排序一下
+ if (dateChanged === 'right') {
+ let insertAfterNode = target;
+ while (
+ (insertAfterNode as Group).nextSibling &&
+ (insertAfterNode as Group).nextSibling.attribute.y === (target as Group).attribute.y &&
+ (insertAfterNode as Group).nextSibling.record[this._gantt.parsedOptions.startDateField] <=
+ target.record[this._gantt.parsedOptions.startDateField]
+ ) {
+ insertAfterNode = (insertAfterNode as Group).nextSibling as any;
+ }
+ if (insertAfterNode !== target) {
+ ((insertAfterNode as Group).parent as any).insertAfter(target, insertAfterNode);
+ }
+ } else if (dateChanged === 'left') {
+ let insertBeforeNode = target;
+ while (
+ (insertBeforeNode as Group).previousSibling &&
+ (insertBeforeNode as Group).previousSibling.attribute.y === (target as Group).attribute.y &&
+ (insertBeforeNode as Group).previousSibling.record[this._gantt.parsedOptions.startDateField] >=
+ target.record[this._gantt.parsedOptions.startDateField]
+ ) {
+ insertBeforeNode = (insertBeforeNode as Group).previousSibling as any;
+ }
+ if (insertBeforeNode !== target) {
+ ((insertBeforeNode as Group).parent as any).insertBefore(target, insertBeforeNode);
+ }
}
}
}
@@ -688,6 +700,7 @@ export class StateManager {
// if (target.name === 'task-bar-hover-shadow') {
// target = target.parent.parent;
// }
+ syncTaskBarNodeRecord(target as GanttTaskBarNode, this._gantt);
this.resizeTaskBar.onIconName = onIconName;
this.resizeTaskBar.resizing = true;
this.resizeTaskBar.target = target as GanttTaskBarNode;
@@ -822,6 +835,7 @@ export class StateManager {
this._gantt.scenegraph.updateNextFrame();
}
startAdjustProgressBar(target: GanttTaskBarNode, x: number, y: number) {
+ syncTaskBarNodeRecord(target, this._gantt);
// 验证目标任务条是否有效
if (!target || !target.record) {
console.warn('Invalid target for progress adjustment');
@@ -1405,6 +1419,29 @@ function reCreateCustomNode(gantt: Gantt, taskBarGroup: Group, taskIndex: number
}
}
+function syncTaskBarNodeRecord(target: GanttTaskBarNode, gantt: Gantt) {
+ if (!target) {
+ return;
+ }
+ target.record = gantt.getRecordByIndex(target.task_index, target.sub_task_index);
+}
+
+function getTaskShowIndexByTaskRecord(record: any, gantt: Gantt): number | undefined {
+ if (!record) {
+ return undefined;
+ }
+ const taskKeyField = gantt.parsedOptions.taskKeyField;
+ const taskKey = record?.[taskKeyField];
+ if (!isValid(taskKey)) {
+ return undefined;
+ }
+ const matchedRecord = findRecordByTaskKey(gantt.records, taskKeyField, taskKey);
+ if (!matchedRecord) {
+ return undefined;
+ }
+ return gantt.getTaskShowIndexByRecordIndex(matchedRecord.index);
+}
+
function moveTaskBar(target: GanttTaskBarNode, dx: number, dy: number, state: StateManager) {
// const taskIndex = getTaskIndexByY(state.moveTaskBar.startOffsetY, state._gantt);
const taskIndex = target.task_index;
diff --git a/packages/vtable-plugins/jest.config.js b/packages/vtable-plugins/jest.config.js
index e3fb8ebf89..ae54607661 100644
--- a/packages/vtable-plugins/jest.config.js
+++ b/packages/vtable-plugins/jest.config.js
@@ -40,6 +40,7 @@ module.exports = {
}
}
: undefined,
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'd3-array': path.resolve(
__dirname,
diff --git a/packages/vtable-search/jest.config.js b/packages/vtable-search/jest.config.js
index 30e0a6bb85..3e3e54a768 100644
--- a/packages/vtable-search/jest.config.js
+++ b/packages/vtable-search/jest.config.js
@@ -15,6 +15,7 @@ module.exports = {
},
__DEV__: true
},
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'@visactor/vtable$': '/../vtable/src/index',
'@visactor/vtable/es/(.*)': '/../vtable/src/$1',
diff --git a/packages/vtable-sheet/__tests__/formula-input-editor.test.ts b/packages/vtable-sheet/__tests__/formula-input-editor.test.ts
new file mode 100644
index 0000000000..29dac02bde
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/formula-input-editor.test.ts
@@ -0,0 +1,38 @@
+// @ts-nocheck
+import { FormulaInputEditor } from '../src/formula/formula-editor';
+
+const createEditor = (formulaInput: HTMLInputElement | null = null) => {
+ const editor = new FormulaInputEditor();
+ const input = document.createElement('input');
+ input.value = 'updated value';
+
+ (editor as any).element = input;
+ (editor as any).sheet = {
+ formulaUIManager: {
+ formulaInput
+ },
+ formulaManager: {
+ cellHighlightManager: {
+ highlightFormulaCells: jest.fn(),
+ clearHighlights: jest.fn()
+ }
+ }
+ };
+
+ return { editor, input };
+};
+
+test('FormulaInputEditor does not throw when formula bar input is unavailable', () => {
+ const { editor } = createEditor(null);
+
+ expect(() => (editor as any).handleFormulaInput(new Event('input'))).not.toThrow();
+});
+
+test('FormulaInputEditor syncs cell editor input to formula bar when available', () => {
+ const formulaInput = document.createElement('input');
+ const { editor, input } = createEditor(formulaInput);
+
+ (editor as any).handleFormulaInput(new Event('input'));
+
+ expect(formulaInput.value).toBe(input.value);
+});
diff --git a/packages/vtable-sheet/__tests__/multi-header-record-data.test.ts b/packages/vtable-sheet/__tests__/multi-header-record-data.test.ts
new file mode 100644
index 0000000000..755dacaeee
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/multi-header-record-data.test.ts
@@ -0,0 +1,63 @@
+// @ts-nocheck
+import { VTableSheet } from '../src/index';
+import { createDiv, removeDom } from './dom';
+
+(global as any).__VERSION__ = 'none';
+
+test('VTableSheet keeps record fields when top-level columns mix leaf and grouped headers', () => {
+ const container = createDiv() as HTMLDivElement;
+ container.style.position = 'relative';
+ container.style.width = '1000px';
+ container.style.height = '800px';
+
+ const sheet = new VTableSheet(container, {
+ showFormulaBar: false,
+ showSheetTab: false,
+ defaultRowHeight: 25,
+ defaultColWidth: 100,
+ sheets: [
+ {
+ sheetKey: 'multiHeaderSheet',
+ sheetTitle: '多级表头示例',
+ active: true,
+ columns: [
+ { title: '计划编码', field: 'planningCode', width: 120 },
+ { title: '采购类别', field: 'type', width: 120 },
+ {
+ title: '采购需求计划完成时间',
+ columns: [
+ { title: '计划完成时间', field: 'demandPlanningTime', width: 150 },
+ { title: '实际完成时间', field: 'demandActualTime', width: 150 }
+ ]
+ },
+ { title: '备注', field: 'description', width: 120 }
+ ],
+ data: [
+ {
+ planningCode: 'PC-001',
+ type: 'type1-1',
+ demandPlanningTime: '2026-06-20',
+ demandActualTime: '2026-06-21',
+ description: 'record data'
+ }
+ ] as any
+ }
+ ]
+ });
+
+ try {
+ const table = sheet.getActiveSheet().tableInstance;
+ const dataRow = table.columnHeaderLevelCount;
+
+ expect(table.internalProps.layoutMap.getHeaderField(0, dataRow - 1)).toBe('planningCode');
+ expect(table.internalProps.layoutMap.getHeaderField(2, dataRow - 1)).toBe('demandPlanningTime');
+ expect(table.getCellValue(0, dataRow)).toBe('PC-001');
+ expect(table.getCellValue(1, dataRow)).toBe('type1-1');
+ expect(table.getCellValue(2, dataRow)).toBe('2026-06-20');
+ expect(table.getCellValue(3, dataRow)).toBe('2026-06-21');
+ expect(table.getCellValue(4, dataRow)).toBe('record data');
+ } finally {
+ sheet.release();
+ removeDom(container);
+ }
+});
diff --git a/packages/vtable-sheet/examples/menu.ts b/packages/vtable-sheet/examples/menu.ts
index 5b95730a0d..dfaba92593 100644
--- a/packages/vtable-sheet/examples/menu.ts
+++ b/packages/vtable-sheet/examples/menu.ts
@@ -19,6 +19,10 @@ export const menus = [
path: 'sheet',
name: 'sheet-deleteRecord'
},
+ {
+ path: 'sheet',
+ name: 'issue-5184-multi-header'
+ },
{
path: 'sheet',
name: 'history'
diff --git a/packages/vtable-sheet/examples/sheet/issue-5184-multi-header.ts b/packages/vtable-sheet/examples/sheet/issue-5184-multi-header.ts
new file mode 100644
index 0000000000..a7b1e79fcd
--- /dev/null
+++ b/packages/vtable-sheet/examples/sheet/issue-5184-multi-header.ts
@@ -0,0 +1,125 @@
+import { VTableSheet } from '../../src/index';
+
+const CONTAINER_ID = 'vTable';
+
+const columns = [
+ { title: '计划编码', field: 'planningCode', width: 120 },
+ {
+ title: '采购类别',
+ field: 'type',
+ width: 120,
+ fieldFormat: (data: any) => {
+ const typeMap: Record = {
+ 'type1-1': '类型1-1',
+ 'type1-2': '类型1-2',
+ 'type1-2-1': '类型1-2-1',
+ 'type1-2-2': '类型1-2-2',
+ type2: '类型2',
+ type3: '类型3'
+ };
+ return typeMap[data.type] || data.type;
+ }
+ },
+ { title: '责任人', field: 'personChargeName', width: 120 },
+ { title: '采购名称', field: 'procurementName', width: 120 },
+ {
+ title: '采购进度',
+ field: 'completeRate',
+ width: 180,
+ cellType: 'progressbar',
+ style: {
+ barColor: 'red',
+ barHeight: 24,
+ barBottom: 4,
+ textAlign: 'right'
+ },
+ fieldFormat: (data: any) => (data.completeRate ? `${data.completeRate}%` : '')
+ },
+ { title: '对外价格(不含税)', field: 'totalExternalPrice', width: 200 },
+ { title: '投标成本总价(不含税)', field: 'totalBidCost', width: 220 },
+ { title: '指导价/限价(不含税)', field: 'priceLimit', width: 220 },
+ {
+ title: '采购需求计划完成时间',
+ columns: [
+ { title: '计划完成时间', field: 'demandPlanningTime', width: 150 },
+ { title: '实际完成时间', field: 'demandActualTime', width: 150 }
+ ]
+ },
+ {
+ title: '采购谈判计划完成时间',
+ columns: [
+ { title: '计划完成时间', field: 'comparisonPlanningTime', width: 150 },
+ { title: '实际完成时间', field: 'comparisonActualTime', width: 150 }
+ ]
+ },
+ {
+ title: '合同签订计划完成时间',
+ columns: [
+ { title: '计划完成时间', field: 'contractPlanningTime', width: 150 },
+ { title: '实际完成时间', field: 'contractActualTime', width: 150 }
+ ]
+ },
+ { title: '签订合同金额(不含税)', field: 'contractNoTaxAmount', width: 220 },
+ { title: '签订合同金额(含税)', field: 'contractTaxAmount', width: 220 },
+ {
+ title: '成本偏差',
+ columns: [
+ { title: '对外(不含税)', field: 'actualTotalExternalPrice', width: 140 },
+ { title: '投标(不含税)', field: 'actualTotalBidCost', width: 140 },
+ { title: '指导价/限价(不含税)', field: 'actualPriceLimit', width: 200 }
+ ]
+ },
+ {
+ title: '盈亏率',
+ field: 'lossRate',
+ width: 120,
+ fieldFormat: (data: any) => `${data.lossRate}%`
+ },
+ { title: '合同编号', field: 'contractNum', width: 120 },
+ { title: '备注', field: 'description', width: 120 }
+];
+
+const data = Array.from({ length: 12 }, (_, index) => ({
+ planningCode: `PC-${String(index + 1).padStart(3, '0')}`,
+ type: index % 2 ? 'type2' : 'type1-1',
+ personChargeName: index % 2 ? '李四' : '张三',
+ procurementName: `采购项目${index + 1}`,
+ completeRate: 20 + index * 6,
+ totalExternalPrice: 100000 + index * 1000,
+ totalBidCost: 86000 + index * 800,
+ priceLimit: 120000 + index * 1200,
+ demandPlanningTime: '2026-06-20',
+ demandActualTime: '2026-06-21',
+ comparisonPlanningTime: '2026-06-25',
+ comparisonActualTime: '2026-06-26',
+ contractPlanningTime: '2026-07-01',
+ contractActualTime: '2026-07-02',
+ contractNoTaxAmount: 98000 + index * 900,
+ contractTaxAmount: 106000 + index * 950,
+ actualTotalExternalPrice: 1200 + index * 10,
+ actualTotalBidCost: 900 + index * 10,
+ actualPriceLimit: 1500 + index * 10,
+ lossRate: 8 + index,
+ contractNum: `HT-${String(index + 1).padStart(3, '0')}`,
+ description: `第 ${index + 1} 行`
+}));
+
+export function createTable() {
+ const container = document.getElementById(CONTAINER_ID)!;
+
+ window.sheetInstance = new VTableSheet(container, {
+ showFormulaBar: false,
+ showSheetTab: false,
+ defaultRowHeight: 32,
+ defaultColWidth: 120,
+ sheets: [
+ {
+ sheetKey: 'multiHeaderSheet',
+ sheetTitle: 'Issue 5184',
+ columns,
+ data: data as any,
+ active: true
+ }
+ ]
+ });
+}
diff --git a/packages/vtable-sheet/jest.config.js b/packages/vtable-sheet/jest.config.js
index 86e4b03785..e5dd6e8d49 100644
--- a/packages/vtable-sheet/jest.config.js
+++ b/packages/vtable-sheet/jest.config.js
@@ -44,6 +44,7 @@ module.exports = {
statements: 60
}
},
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'd3-array': path.resolve(
__dirname,
diff --git a/packages/vtable-sheet/src/core/WorkSheet.ts b/packages/vtable-sheet/src/core/WorkSheet.ts
index e75bc6d38a..a1b0f45fff 100644
--- a/packages/vtable-sheet/src/core/WorkSheet.ts
+++ b/packages/vtable-sheet/src/core/WorkSheet.ts
@@ -48,6 +48,34 @@ type WorkSheetUpdateOptions = Pick<
theme?: TYPES.VTableThemes.ITableThemeDefine;
};
+const normalizeColumnsField = (columns: IWorkSheetOptions['columns'], startFieldIndex = 0): number => {
+ if (!columns?.length) {
+ return startFieldIndex;
+ }
+
+ let fieldIndex = startFieldIndex;
+
+ columns.forEach(column => {
+ const childColumns = (column as IWorkSheetOptions['columns'][number] & { columns?: IWorkSheetOptions['columns'] })
+ .columns;
+
+ if (childColumns?.length) {
+ fieldIndex = normalizeColumnsField(childColumns, fieldIndex);
+ return;
+ }
+
+ if (!isValid(column.field)) {
+ column.field = fieldIndex;
+ }
+ if (!isValid(column.key)) {
+ column.key = column.field as any;
+ }
+ fieldIndex++;
+ });
+
+ return fieldIndex;
+};
+
export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource {
/** 选项 */
options: IWorkSheetOptions;
@@ -274,10 +302,7 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource {
isShowTableHeader = isValid(isShowTableHeader) ? isShowTableHeader : false;
this.options.columns = [];
} else {
- for (let i = 0; i < this.options.columns.length; i++) {
- this.options.columns[i].field = i;
- this.options.columns[i].key = i as any;
- }
+ normalizeColumnsField(this.options.columns);
}
if (!this.options.data) {
this.options.data = [];
diff --git a/packages/vtable-sheet/src/formula/formula-editor.ts b/packages/vtable-sheet/src/formula/formula-editor.ts
index 080020d276..3ae1e69676 100644
--- a/packages/vtable-sheet/src/formula/formula-editor.ts
+++ b/packages/vtable-sheet/src/formula/formula-editor.ts
@@ -17,7 +17,7 @@ export class FormulaInputEditor extends VTable_editors.InputEditor {
return this.element;
}
targetIsOnEditor(target: HTMLElement): boolean {
- return target === this.element || target === this.sheet.formulaUIManager.formulaInput;
+ return target === this.element || target === this.sheet?.formulaUIManager.formulaInput;
}
/**
* 创建编辑器元素
@@ -65,7 +65,10 @@ export class FormulaInputEditor extends VTable_editors.InputEditor {
const value = this.element.value;
// 同步内容到顶部输入栏
- this.sheet.formulaUIManager.formulaInput.value = value;
+ const formulaInput = this.sheet.formulaUIManager.formulaInput;
+ if (formulaInput) {
+ formulaInput.value = value;
+ }
// const inputEvent = new Event('input', { bubbles: true });
// Object.defineProperty(inputEvent, 'isFormulaInsertion', { value: true });
// this.sheet.formulaUIManager.formulaInput.dispatchEvent(inputEvent);
diff --git a/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts
new file mode 100644
index 0000000000..6c2c4bf7e5
--- /dev/null
+++ b/packages/vtable/__tests__/api/listTable-scrollToRow.test.ts
@@ -0,0 +1,36 @@
+// @ts-nocheck
+import { ListTable } from '../../src';
+import { createDiv } from '../dom';
+
+global.__VERSION__ = 'none';
+
+describe('listTable scrollToRow api', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ test('keeps fractional row target when scrolling with animation', () => {
+ const container = createDiv();
+ container.style.position = 'relative';
+ container.style.width = '400px';
+ container.style.height = '240px';
+
+ const table = new ListTable({
+ container,
+ columns: [{ field: 'name', title: 'Name', width: 120 }],
+ records: Array.from({ length: 20 }, (_, index) => ({ name: `row-${index}` })),
+ defaultRowHeight: 40
+ });
+
+ 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' });
+
+ expect(scrollTo).toHaveBeenCalledWith({ row: 1.5 }, { duration: 100, easing: 'linear' });
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
+
+ table.release();
+ });
+});
diff --git a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts
index eb9077cf56..14a5b62fc8 100644
--- a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts
+++ b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts
@@ -142,4 +142,28 @@ describe('listTable init test', () => {
rowEnd: 29
});
});
+
+ test('listTable should support decreasing rightFrozenColCount by setter with row series number', () => {
+ const optionWithRightFrozen = {
+ ...option,
+ bottomFrozenRowCount: 0,
+ rowSeriesNumber: {
+ title: 'index',
+ dragOrder: true,
+ width: 'auto'
+ },
+ container: createDiv(),
+ records
+ };
+ optionWithRightFrozen.container.style.position = 'relative';
+ optionWithRightFrozen.container.style.width = '1000px';
+ optionWithRightFrozen.container.style.height = '800px';
+
+ const rightFrozenTable = new ListTable(optionWithRightFrozen);
+
+ expect(() => {
+ rightFrozenTable.rightFrozenColCount = 1;
+ }).not.toThrow();
+ expect(rightFrozenTable.rightFrozenColCount).toBe(1);
+ });
});
diff --git a/packages/vtable/__tests__/pivotTable-tree.test.ts b/packages/vtable/__tests__/pivotTable-tree.test.ts
index 2c4097d1d1..494d1c7844 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/__tests__/scenegraph-utils-padding.test.ts b/packages/vtable/__tests__/scenegraph-utils-padding.test.ts
new file mode 100644
index 0000000000..c243155e75
--- /dev/null
+++ b/packages/vtable/__tests__/scenegraph-utils-padding.test.ts
@@ -0,0 +1,17 @@
+import { getQuadProps } from '../src/scenegraph/utils/padding';
+
+describe('getQuadProps', () => {
+ test('preserves non-number quad arrays used by border styles', () => {
+ const borderColor = ['#E1E4E8', '#E1E4E8', '#E1E4E8', '#E1E4E8'];
+ const borderLineDash = [null, [12, 6], null, [12, 6]];
+
+ expect(getQuadProps(borderColor)).toEqual(borderColor);
+ expect(getQuadProps(borderLineDash)).toEqual(borderLineDash);
+ });
+
+ test('normalizes numeric padding values', () => {
+ expect(getQuadProps(8)).toEqual([8, 8, 8, 8]);
+ expect(getQuadProps([8.6, 12, 8.6, 12])).toEqual([8.6, 12, 8.6, 12]);
+ expect(getQuadProps({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual([1, 2, 3, 4]);
+ });
+});
diff --git a/packages/vtable/jest.config.js b/packages/vtable/jest.config.js
index adea010325..26480d1ffe 100644
--- a/packages/vtable/jest.config.js
+++ b/packages/vtable/jest.config.js
@@ -40,6 +40,7 @@ module.exports = {
statements: 60
}
},
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'd3-color': path.resolve(__dirname, './node_modules/d3-color/dist/d3-color.min.js'),
'd3-array': path.resolve(process.cwd(), './node_modules/d3-array/dist/d3-array.min.js'),
diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts
index bab1bc9290..de64c0e3fc 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) {
diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts
index 45c880566a..be40871fb1 100644
--- a/packages/vtable/src/core/BaseTable.ts
+++ b/packages/vtable/src/core/BaseTable.ts
@@ -5275,18 +5275,21 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
// anmiation
scrollToRow(row: number, animationOption?: ITableAnimationOption | boolean) {
- const targetRow = Math.min(Math.max(Math.floor(row), 0), this.rowCount - 1);
+ const targetRow = Math.min(Math.max(row, 0), this.rowCount - 1);
+ const targetRowInt = Math.floor(targetRow);
this.clearCorrectTimer();
if (!animationOption) {
- this.scrollToCell({ row: targetRow });
- this._scheduleScrollToRowCorrect(targetRow);
+ this.scrollToCell({ row: targetRowInt });
+ this._scheduleScrollToRowCorrect(targetRowInt);
return;
}
const duration = !isBoolean(animationOption) ? animationOption?.duration ?? 3000 : 3000;
this.animationManager.scrollTo({ row: targetRow }, animationOption);
- this._scrollToRowCorrectTimer = setTimeout(() => {
- this.scrollToRow(targetRow, false);
- }, duration);
+ if (targetRowInt === targetRow) {
+ this._scrollToRowCorrectTimer = setTimeout(() => {
+ this.scrollToRow(targetRowInt, false);
+ }, duration);
+ }
}
scrollToCol(col: number, animationOption?: ITableAnimationOption | boolean) {
if (!animationOption) {
diff --git a/packages/vtable/src/scenegraph/layout/frozen.ts b/packages/vtable/src/scenegraph/layout/frozen.ts
index e506c06b8d..5d0aadfd96 100644
--- a/packages/vtable/src/scenegraph/layout/frozen.ts
+++ b/packages/vtable/src/scenegraph/layout/frozen.ts
@@ -235,7 +235,9 @@ export function dealRightFrozen(distRightFrozenCol: number, scene: Scenegraph) {
const headerColGroup = scene.getColGroup(col, true);
insertBefore(rightTopCornerGroup, headerColGroup, rightTopCornerGroup.firstChild as Group);
const bottomColGroup = scene.getColGroupInBottom(col);
- insertBefore(rightBottomCornerGroup, bottomColGroup, rightBottomCornerGroup.firstChild as Group);
+ if (bottomColGroup) {
+ insertBefore(rightBottomCornerGroup, bottomColGroup, rightBottomCornerGroup.firstChild as Group);
+ }
}
// reset cell y
let x = 0;
@@ -268,12 +270,14 @@ export function dealRightFrozen(distRightFrozenCol: number, scene: Scenegraph) {
);
colHeaderGroup.appendChild(headerColGroup);
const bottomColGroup = scene.getColGroupInRightBottomCorner(col);
- bottomColGroup.setAttribute(
- 'x',
- (bottomFrozenGroup.lastChild as Group).attribute.x +
- table.getColWidth((bottomFrozenGroup.lastChild as Group).col)
- );
- bottomFrozenGroup.appendChild(bottomColGroup);
+ if (bottomColGroup) {
+ const lastBottomColGroup = bottomFrozenGroup.lastChild as Group | undefined;
+ bottomColGroup.setAttribute(
+ 'x',
+ lastBottomColGroup ? lastBottomColGroup.attribute.x + table.getColWidth(lastBottomColGroup.col) : 0
+ );
+ bottomFrozenGroup.appendChild(bottomColGroup);
+ }
}
// reset cell y
let x = 0;
diff --git a/packages/vtable/src/scenegraph/utils/padding.ts b/packages/vtable/src/scenegraph/utils/padding.ts
index 65baad7327..e48fbdf62e 100644
--- a/packages/vtable/src/scenegraph/utils/padding.ts
+++ b/packages/vtable/src/scenegraph/utils/padding.ts
@@ -1,25 +1,61 @@
-import { parsePadding } from '@src/vrender';
-import { isArray, isNumber, isString } from '@visactor/vutils';
-
-export function getQuadProps(
- paddingOrigin: number | string | number[] | { left?: number; right?: number; top?: number; bottom?: number }
-): [number, number, number, number] {
- if (isNumber(paddingOrigin) || isString(paddingOrigin) || isArray(paddingOrigin)) {
- let padding = parsePadding(paddingOrigin as number);
- if (typeof padding === 'number' || typeof padding === 'string') {
- padding = [padding, padding, padding, padding];
- } else if (Array.isArray(padding)) {
- padding = padding.slice(0) as any;
- }
- return padding as any;
- } else if (
- paddingOrigin &&
- (isFinite(paddingOrigin.bottom) ||
- isFinite(paddingOrigin.left) ||
- isFinite(paddingOrigin.right) ||
- isFinite(paddingOrigin.top))
+type PaddingObject = { left?: number; right?: number; top?: number; bottom?: number };
+type Quad = [any, any, any, any];
+
+function normalizeQuadArray(values: any[]): Quad {
+ if (values.length === 0) {
+ return [0, 0, 0, 0];
+ }
+ if (values.length === 1) {
+ return [values[0], values[0], values[0], values[0]];
+ }
+ if (values.length === 2) {
+ return [values[0], values[1], values[0], values[1]];
+ }
+ if (values.length === 3) {
+ return [values[0], values[1], values[2], values[1]];
+ }
+ return [values[0], values[1], values[2], values[3]];
+}
+
+function parseStringQuad(padding: string): Quad {
+ const tokens = padding.trim().split(/\s+/);
+ const values = tokens.map(token => Number.parseFloat(token)).filter(value => Number.isFinite(value));
+
+ if (values.length === tokens.length && values.length > 0) {
+ return normalizeQuadArray(values);
+ }
+
+ return [padding, padding, padding, padding];
+}
+
+function normalizePaddingObject(paddingOrigin: PaddingObject): Quad {
+ if (
+ Number.isFinite(paddingOrigin.bottom) ||
+ Number.isFinite(paddingOrigin.left) ||
+ Number.isFinite(paddingOrigin.right) ||
+ Number.isFinite(paddingOrigin.top)
) {
return [paddingOrigin.top ?? 0, paddingOrigin.right ?? 0, paddingOrigin.bottom ?? 0, paddingOrigin.left ?? 0];
}
return [0, 0, 0, 0];
}
+
+export function getQuadProps(paddingOrigin: number | string | any[] | PaddingObject): Quad {
+ if (Array.isArray(paddingOrigin)) {
+ return normalizeQuadArray(paddingOrigin.slice(0, 4));
+ }
+
+ if (typeof paddingOrigin === 'number' && Number.isFinite(paddingOrigin)) {
+ return [paddingOrigin, paddingOrigin, paddingOrigin, paddingOrigin];
+ }
+
+ if (typeof paddingOrigin === 'string') {
+ return parseStringQuad(paddingOrigin);
+ }
+
+ if (paddingOrigin && typeof paddingOrigin === 'object') {
+ return normalizePaddingObject(paddingOrigin);
+ }
+
+ return [0, 0, 0, 0];
+}
diff --git a/packages/vue-vtable/jest.config.js b/packages/vue-vtable/jest.config.js
index 79a0a11b48..a9e269b8b2 100644
--- a/packages/vue-vtable/jest.config.js
+++ b/packages/vue-vtable/jest.config.js
@@ -18,6 +18,7 @@ module.exports = {
},
__DEV__: true
},
+ cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'@visactor/vtable$': '/../vtable/src/index',
'@visactor/vtable/es/(.*)': '/../vtable/src/$1'