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'