diff --git a/common/changes/@visactor/vtable-editors/fix-issue-4810-input-editor-remount_2026-07-27-20-25.json b/common/changes/@visactor/vtable-editors/fix-issue-4810-input-editor-remount_2026-07-27-20-25.json new file mode 100644 index 0000000000..cb398d7203 --- /dev/null +++ b/common/changes/@visactor/vtable-editors/fix-issue-4810-input-editor-remount_2026-07-27-20-25.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-editors", + "comment": "fix: safely remount and reposition input editor when editing restarts", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-editors", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-gantt/fix-issue-4778-taskbar-relative-rect_2026-07-27-18-45.json b/common/changes/@visactor/vtable-gantt/fix-issue-4778-taskbar-relative-rect_2026-07-27-18-45.json new file mode 100644 index 0000000000..b9ca3c5c70 --- /dev/null +++ b/common/changes/@visactor/vtable-gantt/fix-issue-4778-taskbar-relative-rect_2026-07-27-18-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-gantt", + "comment": "fix: return null for missing gantt task bars and support sub task rect lookup", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-gantt", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-5185-master-detail-set-records_2026-07-27-19-20.json b/common/changes/@visactor/vtable-plugins/fix-issue-5185-master-detail-set-records_2026-07-27-19-20.json new file mode 100644 index 0000000000..f128fbcd56 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-5185-master-detail-set-records_2026-07-27-19-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-plugins", + "comment": "fix: keep master detail expandable after list table setRecords", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json b/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json new file mode 100644 index 0000000000..54808331df --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: support context menu plugin on canvas blank area", + "type": "patch", + "packageName": "@visactor/vtable-plugins" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "github@visactor.io" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-5227-excel-leading-blank-row_2026-07-15-09-34.json b/common/changes/@visactor/vtable-plugins/fix-issue-5227-excel-leading-blank-row_2026-07-15-09-34.json new file mode 100644 index 0000000000..d4f9d08cf9 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-5227-excel-leading-blank-row_2026-07-15-09-34.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: preserve blank rows when importing excel files (GitHub #5227)", + "type": "patch", + "packageName": "@visactor/vtable-plugins" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "biukam.w@gmail.com" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-5235-rotate-wheel_2026-07-25-12-00.json b/common/changes/@visactor/vtable-plugins/fix-issue-5235-rotate-wheel_2026-07-25-12-00.json new file mode 100644 index 0000000000..3096575108 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-5235-rotate-wheel_2026-07-25-12-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-plugins", + "comment": "fix: keep wheel events cancelable after restoring a rotated table (GitHub #5235)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "biukam.w@gmail.com" +} diff --git a/common/changes/@visactor/vtable-sheet/fix-issue-5234-formula-chain_2026-07-27-15-55.json b/common/changes/@visactor/vtable-sheet/fix-issue-5234-formula-chain_2026-07-27-15-55.json new file mode 100644 index 0000000000..ab121f9786 --- /dev/null +++ b/common/changes/@visactor/vtable-sheet/fix-issue-5234-formula-chain_2026-07-27-15-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-sheet", + "comment": "fix: recalculate multi-level formula dependents", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-sheet", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vue-vtable/fix-issue-5150-vue-custom-layout-sort_2026-07-28-00-00.json b/common/changes/@visactor/vue-vtable/fix-issue-5150-vue-custom-layout-sort_2026-07-28-00-00.json new file mode 100644 index 0000000000..e3bf74b7f8 --- /dev/null +++ b/common/changes/@visactor/vue-vtable/fix-issue-5150-vue-custom-layout-sort_2026-07-28-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: refresh vue custom layout dom content after sort", + "type": "patch", + "packageName": "@visactor/vue-vtable" + } + ], + "packageName": "@visactor/vue-vtable", + "email": "github@visactor.io" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e7322bd7a6..b36bdbfeb9 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -27,34 +27,34 @@ importers: specifier: ^2.11.0 version: 2.57.0(vue@3.5.31(typescript@4.9.5)) '@visactor/openinula-vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/openinula-vtable '@visactor/react-vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/react-vtable '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable-editors '@visactor/vtable-export': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable-export '@visactor/vtable-gantt': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable-gantt '@visactor/vtable-search': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable-search '@visactor/vtable-sheet': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vtable-sheet '@visactor/vue-vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../packages/vue-vtable '@visactor/vutils': specifier: ~1.0.17 @@ -148,7 +148,7 @@ importers: ../../packages/openinula-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -278,7 +278,7 @@ importers: ../../packages/react-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -456,7 +456,7 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -637,7 +637,7 @@ importers: ../../packages/vtable-calendar: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -860,7 +860,7 @@ importers: specifier: 2.0.7 version: 2.0.7 '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1020,10 +1020,10 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -1256,13 +1256,13 @@ importers: specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-gantt '@vitejs/plugin-react': specifier: 3.1.0 @@ -1361,7 +1361,7 @@ importers: ../../packages/vtable-search: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1515,13 +1515,13 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-editors '@visactor/vtable-plugins': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable-plugins '@visactor/vutils': specifier: ~1.0.17 @@ -1660,7 +1660,7 @@ importers: ../../packages/vue-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1841,19 +1841,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vtable': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../../packages/vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../../packages/vtable-gantt '@visactor/vtable-plugins': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../../packages/vtable-plugins '@visactor/vtable-sheet': - specifier: workspace:1.26.5 + specifier: workspace:1.26.6 version: link:../../packages/vtable-sheet devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 6f8ac0608d..8110d7329f 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.5","mainProject":"@visactor/vtable","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.6","mainProject":"@visactor/vtable","nextBump":"patch"}] diff --git a/docs/assets/api/en/GanttAPI.md b/docs/assets/api/en/GanttAPI.md index 1b7ab7b384..6bb5f5e492 100644 --- a/docs/assets/api/en/GanttAPI.md +++ b/docs/assets/api/en/GanttAPI.md @@ -149,12 +149,12 @@ Get or set the horizontal scroll value to a specified position. Get the position of the task bar. The position relative to the top-left corner of the Gantt chart. ``` - getTaskBarRelativeRect:(index: number) =>{ + getTaskBarRelativeRect:(index: number, sub_task_index?: number) =>{ left: number; top: number; width: number; height: number; - } + } | null ``` ## Events diff --git a/docs/assets/api/en/methods.md b/docs/assets/api/en/methods.md index 9bbbf11349..d24cc48048 100644 --- a/docs/assets/api/en/methods.md +++ b/docs/assets/api/en/methods.md @@ -2016,9 +2016,9 @@ Modify data, supports multiple data items /** * Modify data, supports multiple data items * @param records Modified data items - * @param recordIndexs Corresponding index of modified data (index displayed in body, i.e., which row of data in the body part to modify), in tree (grouping) structures, recordIndex may be an array, representing the index position of each level from the root node for that node. + * @param recordIndexs Corresponding index of modified data (index displayed in body, i.e., which row of data in the body part to modify), in tree (grouping) structures, recordIndex may be an array, representing the index position of each level from the root node for that node. When omitted, the records are updated by their array order. */ - updateRecords(records: any[], recordIndexs: number[]|number[][]) + updateRecords(records: any[], recordIndexs?: number[]|number[][]) ``` ## getBodyVisibleCellRange(Function) @@ -2107,14 +2107,21 @@ Get the selection state of all checkbox data under a field, the order correspond getCheckboxState(field?: string | number): Array ``` +- field: Optional checkbox field. If omitted, returns checkbox states for all fields +- return: Checkbox state array. Tree data keeps state by children path + ## getCellCheckboxState(Function) Get the state of a checkbox in a specific cell ``` -getCellCheckboxState(col: number, row: number): Array +getCellCheckboxState(col: number, row: number): boolean | 'indeterminate' | undefined ``` +- col: Column number +- row: Row number +- return: Checkbox state of the cell + ## getRadioState(Function) Get the selection state of all radio data under a field, the order corresponds to the original input data records, not the state value of the table display row @@ -2143,6 +2150,38 @@ setCellCheckboxState(col: number, row: number, checked: boolean) => void - row: Row number - checked: Whether selected +## setCellCheckboxStateByRecordIndex(Function) + +Set the checkbox state by source records index and field. For tree tables, pass a children path such as `[0, 1]` for the second child of the first root record. The state is updated even when the target node is collapsed and not currently visible. + +``` +setCellCheckboxStateByRecordIndex(recordIndex: number | number[], field: string | number, checked: boolean | 'indeterminate') => void +``` + +- recordIndex: Source data index; number for normal tables, number[] for tree tables +- field: Field of the checkbox column +- checked: Checkbox state, including `'indeterminate'` + +## clearCheckboxState(Function) + +Clear all checkbox checked states under the specified field. `clearAllCheckboxState(field)` is an alias of this method. + +``` +clearCheckboxState(field: string | number) => void +``` + +- field: Field of the checkbox column + +## clearAllCheckboxState(Function) + +Alias of `clearCheckboxState(field)`. Clear all checkbox checked states under the specified field. + +``` +clearAllCheckboxState(field: string | number) => void +``` + +- field: Field of the checkbox column + ## setCellRadioState(Function) Set the radio state of the cell to selected state diff --git a/docs/assets/api/zh/GanttAPI.md b/docs/assets/api/zh/GanttAPI.md index be2c0b889f..13d90f4c1b 100644 --- a/docs/assets/api/zh/GanttAPI.md +++ b/docs/assets/api/zh/GanttAPI.md @@ -150,12 +150,12 @@ const info = ganttInstance.getBaselineInfoByTaskListIndex(0); 获取任务条的位置。相对应甘特图表左上角的位置。 ``` - getTaskBarRelativeRect:(index: number) =>{ + getTaskBarRelativeRect:(index: number, sub_task_index?: number) =>{ left: number; top: number; width: number; height: number; - } + } | null ``` ## Events diff --git a/docs/assets/api/zh/methods.md b/docs/assets/api/zh/methods.md index 0d64ac15c9..0168642727 100644 --- a/docs/assets/api/zh/methods.md +++ b/docs/assets/api/zh/methods.md @@ -2015,9 +2015,9 @@ changeCellValuesByRecords 的别名形式(位置参数)。 /** * 修改数据 支持多条数据 * @param records 修改数据条目 - * @param recordIndexs 对应修改数据的索引(显示在body中的索引,即要修改的是body部分的第几行数据),在树形(分组)结构中,recordIndex可能是一个数组,代表改节点从根节点开始的每级索引位置。 + * @param recordIndexs 对应修改数据的索引(显示在body中的索引,即要修改的是body部分的第几行数据),在树形(分组)结构中,recordIndex可能是一个数组,代表改节点从根节点开始的每级索引位置。省略时会按 records 顺序更新对应索引。 */ - updateRecords(records: any[], recordIndexs: number[]|number[][]) + updateRecords(records: any[], recordIndexs?: number[]|number[][]) ``` ## getBodyVisibleCellRange(Function) @@ -2106,14 +2106,21 @@ arrangeCustomCellStyle: (cellPosition: { col?: number; row?: number; range?: Cel getCheckboxState(field?: string | number): Array ``` +- field: 可选,checkbox 所在字段;不传时返回所有字段的 checkbox 状态 +- 返回值: checkbox 状态数组,树形数据会按 children 路径组织状态 + ## getCellCheckboxState(Function) 获取某个单元格 checkbox 的状态 ``` -getCellCheckboxState(col: number, row: number): Array +getCellCheckboxState(col: number, row: number): boolean | 'indeterminate' | undefined ``` +- col: 列号 +- row: 行号 +- 返回值: 当前单元格 checkbox 状态 + ## getRadioState(Function) 获取某个字段下 radio 全部数据的选中状态 顺序对应原始传入数据 records 不是对应表格展示 row 的状态值 @@ -2142,6 +2149,38 @@ setCellCheckboxState(col: number, row: number, checked: boolean) => void - row: 行号 - checked: 是否选中 +## setCellCheckboxStateByRecordIndex(Function) + +根据源数据 records 的 index 和 field 设置 checkbox 状态。树形表格可传入 children 路径,例如 `[0, 1]` 表示第 1 条根节点下第 2 条子节点;即使该节点当前处于折叠不可见状态,也会更新其 checkbox 状态。 + +``` +setCellCheckboxStateByRecordIndex(recordIndex: number | number[], field: string | number, checked: boolean | 'indeterminate') => void +``` + +- recordIndex: 源数据索引;普通表格为 number,树形表格为 number[] +- field: checkbox 所在字段 +- checked: 是否选中,支持半选状态 `'indeterminate'` + +## clearCheckboxState(Function) + +清除指定 field 下所有 checkbox 的选中状态。`clearAllCheckboxState(field)` 是该方法的别名。 + +``` +clearCheckboxState(field: string | number) => void +``` + +- field: checkbox 所在字段 + +## clearAllCheckboxState(Function) + +`clearCheckboxState(field)` 的别名,用于清除指定 field 下所有 checkbox 的选中状态。 + +``` +clearAllCheckboxState(field: string | number) => void +``` + +- field: checkbox 所在字段 + ## setCellRadioState(Function) 将单元格的 radio 状态设置为选中状态 diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 19233703a5..6dc535311f 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -1,3 +1,20 @@ +# v1.26.6 + +2026-08-03 + +**🐛 Bug Fixes** + +- **@visactor/vtable**: refresh rows when updateRecords is called without record indexes +- **@visactor/vtable**: refresh functional icons after theme updates +- **@visactor/vtable**: hide row series number in aggregation rows +- **@visactor/vtable**: delete reverse-selected rows from context menu +- **@visactor/vtable**: allow plugins to initialize before first render +- **@visactor/vtable**: support updating checkbox state by record index + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.5...v1.26.6 + +[more detail about v1.26.6](https://github.com/VisActor/VTable/releases/tag/v1.26.6) + # v1.26.5 2026-07-15 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 61e2327a9f..ea1efd7643 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -1,3 +1,20 @@ +# v1.26.6 + +2026-08-03 + +**🐛 问题修复** + +- **@visactor/vtable**: 在调用 updateRecords 且未传入记录索引时刷新行 +- **@visactor/vtable**: 主题更新后刷新功能图标 +- **@visactor/vtable**: 在聚合行中隐藏行序号 +- **@visactor/vtable**: 支持通过右键菜单删除反向选择的行 +- **@visactor/vtable**: 允许插件在首次渲染前完成初始化 +- **@visactor/vtable**: 支持通过记录索引更新 checkbox 状态 + +**完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.5...v1.26.6 + +[更多详情请查看 v1.26.6](https://github.com/VisActor/VTable/releases/tag/v1.26.6) + # v1.26.5 2026-07-15 diff --git a/docs/package.json b/docs/package.json index e05038b658..040de82b26 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,15 +12,15 @@ "dependencies": { "@arco-design/web-react": "2.66.12", "@arco-design/web-vue": "^2.11.0", - "@visactor/vtable": "workspace:1.26.5", - "@visactor/vtable-gantt": "workspace:1.26.5", - "@visactor/react-vtable": "workspace:1.26.5", - "@visactor/vue-vtable": "workspace:1.26.5", - "@visactor/openinula-vtable": "workspace:1.26.5", - "@visactor/vtable-editors": "workspace:1.26.5", - "@visactor/vtable-export": "workspace:1.26.5", - "@visactor/vtable-search": "workspace:1.26.5", - "@visactor/vtable-sheet": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable-gantt": "workspace:1.26.6", + "@visactor/react-vtable": "workspace:1.26.6", + "@visactor/vue-vtable": "workspace:1.26.6", + "@visactor/openinula-vtable": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-export": "workspace:1.26.6", + "@visactor/vtable-search": "workspace:1.26.6", + "@visactor/vtable-sheet": "workspace:1.26.6", "buble": "^0.20.0", "@visactor/vchart": "2.1.3", "markdown-it": "^13.0.0", diff --git a/packages/openinula-vtable/package.json b/packages/openinula-vtable/package.json index 8ee511ea6a..9ca95d26a1 100644 --- a/packages/openinula-vtable/package.json +++ b/packages/openinula-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/openinula-vtable", - "version": "1.26.5", + "version": "1.26.6", "description": "The openinula version of VTable", "keywords": [ "openinula", @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/react-vtable/demo/src/App.tsx b/packages/react-vtable/demo/src/App.tsx index 50c12c4cee..d4c9a43699 100644 --- a/packages/react-vtable/demo/src/App.tsx +++ b/packages/react-vtable/demo/src/App.tsx @@ -1,4 +1,5 @@ import listTable from './list-table/list-table'; +import issue5203ViteReact19 from './list-table/issue-5203-vite-react19'; import listOptionRecord from './list-table/list-option-records'; import listComponent from './list-table/list-component'; import listCustomLayout from './list-table/list-custom-layout'; @@ -32,6 +33,7 @@ import { Component, useEffect, useMemo, useState } from 'react'; declare const globalThis: any; const demoList = [ + { key: 'issue5203ViteReact19', Comp: issue5203ViteReact19 }, { key: 'listTable', Comp: listTable }, { key: 'listEditor', Comp: listEditor }, { key: 'listOptionRecord', Comp: listOptionRecord }, diff --git a/packages/react-vtable/demo/src/list-table/issue-5203-vite-react19.tsx b/packages/react-vtable/demo/src/list-table/issue-5203-vite-react19.tsx new file mode 100644 index 0000000000..4686cfa513 --- /dev/null +++ b/packages/react-vtable/demo/src/list-table/issue-5203-vite-react19.tsx @@ -0,0 +1,45 @@ +import { ListTable } from '../../../src'; + +declare const globalThis: any; + +const columns = [ + { + field: 'id', + title: 'ID', + width: 120 + }, + { + field: 'name', + title: 'Name', + width: 200 + }, + { + field: 'age', + title: 'Age', + width: 120 + } +]; + +const records = [ + { id: 1, name: 'Alice', age: 28 }, + { id: 2, name: 'Bob', age: 31 }, + { id: 3, name: 'Carol', age: 24 } +]; + +export default function Issue5203ViteReact19() { + return ( + { + (globalThis as any).tableInstance = table; + (globalThis as any).__issue5203Ready = true; + }} + onError={error => { + (globalThis as any).__issue5203Error = error; + }} + /> + ); +} diff --git a/packages/react-vtable/demo/vite.config.issue5203.ts b/packages/react-vtable/demo/vite.config.issue5203.ts new file mode 100644 index 0000000000..ad5ee89152 --- /dev/null +++ b/packages/react-vtable/demo/vite.config.issue5203.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const react19Root = path.resolve(__dirname, '../../../.react19-deps/node_modules'); + +export default defineConfig({ + plugins: [react({ fastRefresh: false })] as any, + define: { + __DEV__: true, + __VERSION__: JSON.stringify(require('../../vtable/package.json').version) + }, + server: { + host: '0.0.0.0', + port: 3102 + }, + resolve: { + alias: [ + { find: /^react$/, replacement: path.resolve(react19Root, 'react/index.js') }, + { find: /^react\/jsx-runtime(\.js)?$/, replacement: path.resolve(react19Root, 'react/jsx-runtime.js') }, + { find: /^react\/jsx-dev-runtime(\.js)?$/, replacement: path.resolve(react19Root, 'react/jsx-dev-runtime.js') }, + { find: /^react-dom$/, replacement: path.resolve(react19Root, 'react-dom/index.js') }, + { find: /^react-dom\/client(\.js)?$/, replacement: path.resolve(react19Root, 'react-dom/client.js') }, + { find: '@visactor/vtable/es/vrender', replacement: path.resolve(__dirname, '../../vtable/src/vrender.ts') }, + { find: '@visactor/vtable/es', replacement: path.resolve(__dirname, '../../vtable/src/') }, + { find: '@visactor/vtable', replacement: path.resolve(__dirname, '../../vtable/src/index.ts') }, + { find: '@visactor/vtable-plugins', replacement: path.resolve(__dirname, '../../vtable-plugins/src/index.ts') }, + { find: '@src', replacement: path.resolve(__dirname, '../../vtable/src/') }, + { find: '@vutils-extension', replacement: path.resolve(__dirname, '../../vtable/src/vutil-extension-temp') } + ] + } +}); diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 9b5dec4c02..3295617b53 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vtable", - "version": "1.26.5", + "version": "1.26.6", "description": "The react version of VTable", "keywords": [ "react", @@ -57,7 +57,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17", "react-is": "^18.2.0", "react-reconciler": "0.29.0" diff --git a/packages/react-vtable/src/table-components/custom/custom-layout.tsx b/packages/react-vtable/src/table-components/custom/custom-layout.tsx index 1bf72f7dae..9731ded216 100644 --- a/packages/react-vtable/src/table-components/custom/custom-layout.tsx +++ b/packages/react-vtable/src/table-components/custom/custom-layout.tsx @@ -1,12 +1,11 @@ /* eslint-disable react-hooks/rules-of-hooks */ import type { PropsWithChildren, ReactElement } from 'react'; -import React, { isValidElement, useCallback, useContext, useLayoutEffect, useRef } from 'react'; +import React, { isValidElement, useCallback, useContext, useLayoutEffect, useRef, useState } from 'react'; import RootTableContext from '../../context/table'; import { Group } from '@visactor/vtable/es/vrender'; import type { ICustomLayoutFuc, CustomRenderFunctionArg } from '@visactor/vtable/es/ts-types'; import type { FiberRoot } from 'react-reconciler'; -import type { ReconcilerErrorReporter } from './reconciler'; -import { reconcilor, createReconcilerContainer } from './reconciler'; +import type { ReconcilerErrorReporter, ReconcilerErrorType } from './reconciler'; type CustomLayoutProps = { componentId: string }; @@ -22,6 +21,8 @@ export const CustomLayout: React.FC = (props: PropsWithChildr } const context = useContext(RootTableContext); const { table, onError } = context; + const [reconcilerReady, setReconcilerReady] = useState(false); + const reconcilerModule = useRef(null); const isHeaderCustomLayout = children.props.role === 'header-custom-layout'; @@ -45,22 +46,48 @@ export const CustomLayout: React.FC = (props: PropsWithChildr [onError] ); + useLayoutEffect(() => { + let released = false; + // Load the custom-layout reconciler only when CustomLayout is actually used. + import('./reconciler') + .then(module => { + if (released) { + return; + } + reconcilerModule.current = module; + setReconcilerReady(true); + }) + .catch(error => { + reportReconcilerError('uncaught', error); + }); + return () => { + released = true; + }; + }, [reportReconcilerError]); + // customLayout function for vtable const createGraphic: ICustomLayoutFuc = useCallback( - args => { + (args: any) => { + const module = reconcilerModule.current; + if (!module) { + return { + rootContainer: new Group({}), + renderDefault: !!children.props.renderDefault + }; + } const key = `${args.originCol ?? args.col}-${args.originRow ?? args.row}${ args.forComputation ? '-forComputation' : '' }`; let group; if (container.current.has(key)) { const currentContainer = container.current.get(key); - reconcilorUpdateContainer(children, currentContainer, args); + reconcilorUpdateContainer(module, children, currentContainer, args); group = currentContainer.containerInfo; } else { group = new Group({}); - const currentContainer = createReconcilerContainer(group as any, 'custom', reportReconcilerError); + const currentContainer = module.createReconcilerContainer(group as any, 'custom', reportReconcilerError); container.current.set(key, currentContainer); - reconcilorUpdateContainer(children, currentContainer, args); + reconcilorUpdateContainer(module, children, currentContainer, args); } return { @@ -72,10 +99,14 @@ export const CustomLayout: React.FC = (props: PropsWithChildr ); const removeContainer = useCallback((col: number, row: number) => { + const module = reconcilerModule.current; + if (!module) { + return; + } const key = `${col}-${row}`; if (container.current.has(key)) { const currentContainer = container.current.get(key); - reconcilor.updateContainer(null, currentContainer, null); + module.reconcilor.updateContainer(null, currentContainer, null); // group = currentContainer.containerInfo; currentContainer.containerInfo.delete(); container.current.delete(key); @@ -83,9 +114,14 @@ export const CustomLayout: React.FC = (props: PropsWithChildr }, []); const removeAllContainer = useCallback(() => { + const module = reconcilerModule.current; + if (!module) { + container.current.clear(); + return; + } container.current.forEach((value, key) => { const currentContainer = value; - reconcilor.updateContainer(null, currentContainer, null); + module.reconcilor.updateContainer(null, currentContainer, null); currentContainer.containerInfo.delete(); }); container.current.clear(); @@ -108,6 +144,9 @@ export const CustomLayout: React.FC = (props: PropsWithChildr // eslint-disable-next-line no-undef console.log('update props', props, table); + if (!reconcilerReady) { + return; + } table?.checkReactCustomLayout(); // init reactCustomLayout component table?.reactCustomLayout?.setReactRemoveAllGraphic(componentId, removeAllContainer, isHeaderCustomLayout); // set customLayout function @@ -129,6 +168,10 @@ export const CustomLayout: React.FC = (props: PropsWithChildr ); // update customLayout function // update all container container.current.forEach((value, key) => { + const module = reconcilerModule.current; + if (!module) { + return; + } const [col, row] = key.split('-').map(Number); // const width = table.getColWidth(col); // to be fixed: may be merge cell // const height = table.getRowHeight(row); // to be fixed: may be merge cell @@ -151,7 +194,7 @@ export const CustomLayout: React.FC = (props: PropsWithChildr }; // update element in container const group = currentContainer.containerInfo; - reconcilorUpdateContainer(children, currentContainer, args); + reconcilorUpdateContainer(module, children, currentContainer, args); // reconcilor.updateContainer(React.cloneElement(children, { ...args }), currentContainer, null); table.scenegraph.updateNextFrame(); }); @@ -161,8 +204,18 @@ export const CustomLayout: React.FC = (props: PropsWithChildr return null; }; -function reconcilorUpdateContainer(children: ReactElement, currentContainer: any, args: any) { +type ReconcilerModule = { + reconcilor: any; + createReconcilerContainer: ( + container: any, + identifierPrefix?: string, + reportError?: (type: ReconcilerErrorType, error: unknown) => void + ) => FiberRoot; +}; + +function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElement, currentContainer: any, args: any) { const element = React.cloneElement(children, { ...args }); + const { reconcilor } = module; const updateContainerSync = (reconcilor as any).updateContainerSync; if (typeof updateContainerSync === 'function') { updateContainerSync(element, currentContainer, null); diff --git a/packages/vtable-calendar/package.json b/packages/vtable-calendar/package.json index 82426ee528..4444fbd5ff 100644 --- a/packages/vtable-calendar/package.json +++ b/packages/vtable-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-calendar", - "version": "1.26.5", + "version": "1.26.6", "description": "The calendar component of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17", "date-fns": "3.6.0" }, diff --git a/packages/vtable-editors/package.json b/packages/vtable-editors/package.json index f1311d8053..600baa8154 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-editors", - "version": "1.26.5", + "version": "1.26.6", "description": "", "sideEffects": false, "main": "cjs/index.js", diff --git a/packages/vtable-editors/src/input-editor.ts b/packages/vtable-editors/src/input-editor.ts index ece3c60398..9d190491bd 100644 --- a/packages/vtable-editors/src/input-editor.ts +++ b/packages/vtable-editors/src/input-editor.ts @@ -98,8 +98,20 @@ export class InputEditor implements IEditor { this.eventHandlers.push({ type: 'paste', handler: pasteHandler }); } - setValue(value: string) { - this.element.value = typeof value !== 'undefined' ? value : ''; + protected ensureElementMounted(container: HTMLElement) { + if (!this.element) { + this.createElement(); + return; + } + + if (!container.contains(this.element)) { + this.element.parentElement?.removeChild(this.element); + container.appendChild(this.element); + } + } + + setValue(value: string | null | undefined) { + this.element.value = value ?? ''; } getValue() { @@ -118,14 +130,7 @@ export class InputEditor implements IEditor { if (selectCell.col !== this.col || selectCell.row !== this.row) { return; } - if (!this.element) { - this.createElement(); - } else { - if (!container.contains(this.element)) { - this.element.parentElement.removeChild(this.element); - this.container.appendChild(this.element); - } - } + this.ensureElementMounted(container); this.element.style.opacity = '0'; //这个pointerEvents = 'none'很重要,如果没有的话会引起vtable.getElement()元素和这里的element元素的focus和blur的切换, //也会引起mouseleave_table mouseleave_cell和mouseenter的切换 @@ -141,19 +146,10 @@ export class InputEditor implements IEditor { this.table = table; this.col = col; this.row = row; - if (!this.element) { - this.createElement(); - if (referencePosition?.rect) { - this.adjustPosition(referencePosition.rect); - } - } else { - if (!container.contains(this.element)) { - this.element.parentElement.removeChild(this.element); - this.container.appendChild(this.element); - } - } - if (value !== undefined && value !== null) { - this.setValue(value); + this.ensureElementMounted(container); + this.setValue(value); + if (referencePosition?.rect) { + this.adjustPosition(referencePosition.rect); } //防止调用过prepareEdit 后,元素的显示和可操作性被影响 this.element.style.opacity = '1'; diff --git a/packages/vtable-editors/src/textArea-editor.ts b/packages/vtable-editors/src/textArea-editor.ts index 4eede430ca..78612cc6ab 100644 --- a/packages/vtable-editors/src/textArea-editor.ts +++ b/packages/vtable-editors/src/textArea-editor.ts @@ -57,26 +57,33 @@ export class TextAreaEditor implements IEditor { }); } - setValue(value: string) { - this.element.value = typeof value !== 'undefined' ? value : ''; + setValue(value: string | null | undefined) { + this.element.value = value ?? ''; } getValue() { return this.element?.value; } - onStart({ value, referencePosition, container, endEdit }: EditContext) { - this.container = container; - this.successCallback = endEdit; + protected ensureElementMounted(container: HTMLElement) { if (!this.element) { this.createElement(); + return; + } - if (value !== undefined && value !== null) { - this.setValue(value); - } - if (referencePosition?.rect) { - this.adjustPosition(referencePosition.rect); - } + if (!container.contains(this.element)) { + this.element.parentElement?.removeChild(this.element); + container.appendChild(this.element); + } + } + + onStart({ value, referencePosition, container, endEdit }: EditContext) { + this.container = container; + this.successCallback = endEdit; + this.ensureElementMounted(container); + this.setValue(value); + if (referencePosition?.rect) { + this.adjustPosition(referencePosition.rect); } this.element.focus(); // do nothing diff --git a/packages/vtable-export/package.json b/packages/vtable-export/package.json index 07e72d5733..8fceb141f8 100644 --- a/packages/vtable-export/package.json +++ b/packages/vtable-export/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-export", - "version": "1.26.5", + "version": "1.26.6", "description": "The export util of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17", "file-saver": "2.0.5", "@types/file-saver": "2.0.7", diff --git a/packages/vtable-gantt/examples/gantt/issue-4778-taskbar-relative-rect.ts b/packages/vtable-gantt/examples/gantt/issue-4778-taskbar-relative-rect.ts new file mode 100644 index 0000000000..c580d86d64 --- /dev/null +++ b/packages/vtable-gantt/examples/gantt/issue-4778-taskbar-relative-rect.ts @@ -0,0 +1,118 @@ +import type { ColumnsDefine } from '@visactor/vtable'; +import type { GanttConstructorOptions } from '../../src/index'; +import { Gantt } from '../../src/index'; +import { TasksShowMode, TaskType } from '../../src/ts-types'; + +const CONTAINER_ID = 'vTable'; + +const createStatusBar = () => { + const container = document.getElementById(CONTAINER_ID)!; + const status = document.createElement('div'); + status.id = 'issue4778Status'; + status.style.cssText = 'height: 32px; line-height: 32px; font-size: 13px; color: #333;'; + status.textContent = 'Click "Check getTaskBarRelativeRect" to verify issue #4778.'; + + const button = document.createElement('button'); + button.textContent = 'Check getTaskBarRelativeRect'; + button.style.cssText = 'margin: 0 0 8px 8px;'; + button.onclick = () => checkRects(); + + container.parentElement?.insertBefore(status, container); + status.appendChild(button); +}; + +const isValidRect = (rect: any) => + rect && + Number.isFinite(rect.left) && + Number.isFinite(rect.top) && + Number.isFinite(rect.width) && + Number.isFinite(rect.height); + +const checkRects = () => { + const ganttInstance = (window as any).ganttInstance as Gantt; + const status = document.getElementById('issue4778Status')!; + let emptyRect: any; + let firstChildRect: any; + let secondChildRect: any; + + try { + emptyRect = ganttInstance.getTaskBarRelativeRect(1); + firstChildRect = ganttInstance.getTaskBarRelativeRect(0, [0, 0]); + secondChildRect = ganttInstance.getTaskBarRelativeRect(0, [0, 1]); + } catch (err) { + status.textContent = `FAIL | ${(err as Error).message}`; + return status.textContent; + } + + const pass = + emptyRect === null && + isValidRect(firstChildRect) && + isValidRect(secondChildRect) && + firstChildRect.left !== secondChildRect.left; + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | empty=${JSON.stringify(emptyRect)}, first=${JSON.stringify( + firstChildRect + )}, second=${JSON.stringify(secondChildRect)}`; + return status.textContent; +}; + +export function createTable() { + const records = [ + { + id: 1, + title: 'Collapsed project with inline subtasks', + type: TaskType.PROJECT, + children: [ + { id: 11, title: 'Sub task A', start: '2024-07-01', end: '2024-07-04', progress: 30 }, + { id: 12, title: 'Sub task B', start: '2024-07-08', end: '2024-07-12', progress: 60 } + ] + }, + { + id: 2, + title: 'Empty task row' + } + ]; + + const columns: ColumnsDefine = [ + { field: 'title', title: 'title', width: 220 }, + { field: 'start', title: 'start', width: 120 }, + { field: 'end', title: 'end', width: 120 }, + { field: 'progress', title: 'progress', width: 100 } + ]; + + const option: GanttConstructorOptions = { + records, + taskListTable: { + columns, + tableWidth: 260 + }, + taskKeyField: 'id', + tasksShowMode: TasksShowMode.Project_Sub_Tasks_Inline, + taskBar: { + startDateField: 'start', + endDateField: 'end', + progressField: 'progress' + }, + minDate: '2024-07-01', + maxDate: '2024-07-20', + timelineHeader: { + colWidth: 36, + scales: [{ unit: 'day', step: 1 }] + }, + grid: { + verticalLine: { + lineWidth: 1, + lineColor: '#e1e4e8' + }, + horizontalLine: { + lineWidth: 1, + lineColor: '#e1e4e8' + } + } + }; + + createStatusBar(); + const ganttInstance = new Gantt(document.getElementById(CONTAINER_ID)!, option); + (window as any).ganttInstance = ganttInstance; + (window as any).issue4778Run = checkRects; +} diff --git a/packages/vtable-gantt/examples/menu.ts b/packages/vtable-gantt/examples/menu.ts index c5e88f7d7a..1bcfe17a99 100644 --- a/packages/vtable-gantt/examples/menu.ts +++ b/packages/vtable-gantt/examples/menu.ts @@ -178,6 +178,10 @@ export const menus = [ { path: 'gantt', name: 'gantt-issue-5162-sort-drag' + }, + { + path: 'gantt', + name: 'issue-4778-taskbar-relative-rect' } // ] // } diff --git a/packages/vtable-gantt/package.json b/packages/vtable-gantt/package.json index 973a50d806..06e96c8088 100644 --- a/packages/vtable-gantt/package.json +++ b/packages/vtable-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-gantt", - "version": "1.26.5", + "version": "1.26.6", "description": "canvas table width high performance", "keywords": [ "vtable-gantt", @@ -50,8 +50,8 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", - "@visactor/vtable-editors": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.6", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index bf77edc652..0cfcca00c1 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -1027,12 +1027,12 @@ export class Gantt extends EventTarget { return this.records[taskShowIndex]; } - _refreshTaskBar(taskShowIndex: number, sub_task_index?: number) { + _refreshTaskBar(taskShowIndex: number, sub_task_index?: number | number[]) { // this.taskListTableInstance.updateRecords([record], [index]); this.scenegraph.taskBar.updateTaskBarNode(taskShowIndex, sub_task_index); this.scenegraph.refreshRecordLinkNodes( taskShowIndex, - undefined, + sub_task_index, this.scenegraph.taskBar.getTaskBarNodeByIndex(taskShowIndex, sub_task_index) as GanttTaskBarNode ); this.scenegraph.updateNextFrame(); @@ -1262,12 +1262,13 @@ export class Gantt extends EventTarget { } this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index); } else if (Array.isArray(sub_task_index)) { + this._updateRecordToListTable(taskRecord, sub_task_index); // 递归更新父级project任务的时间范围 this.stateManager.updateProjectTaskTimes(sub_task_index); } } - _updateEndDateToTaskRecord(endDate: Date, index: number, sub_task_index?: number) { + _updateEndDateToTaskRecord(endDate: Date, index: number, sub_task_index?: number | number[]) { const taskRecord = this.getRecordByIndex(index, sub_task_index); const endDateField = this.parsedOptions.endDateField; const dateFormat = this.parsedOptions.dateFormat ?? parseDateFormat(taskRecord[endDateField]); @@ -1284,12 +1285,13 @@ export class Gantt extends EventTarget { } this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index); } else if (Array.isArray(sub_task_index)) { + this._updateRecordToListTable(taskRecord, sub_task_index); // 递归更新父级project任务的时间范围 this.stateManager.updateProjectTaskTimes(sub_task_index); } } - _updateStartEndDateToTaskRecord(startDate: Date, endDate: Date, index: number, sub_task_index?: number) { + _updateStartEndDateToTaskRecord(startDate: Date, endDate: Date, index: number, sub_task_index?: number | number[]) { const taskRecord = this.getRecordByIndex(index, sub_task_index); const startDateField = this.parsedOptions.startDateField; const endDateField = this.parsedOptions.endDateField; @@ -1308,6 +1310,7 @@ export class Gantt extends EventTarget { } this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index); } else if (Array.isArray(sub_task_index)) { + this._updateRecordToListTable(taskRecord, sub_task_index); // 递归更新父级project任务的时间范围 this.stateManager.updateProjectTaskTimes(sub_task_index); } @@ -1319,11 +1322,16 @@ export class Gantt extends EventTarget { * @param index 对应的一定是左侧表格body的index * @param sub_task_index 子任务的index, 当taskShowMode是sub_tasks_*模式时,会传入sub_task_index。如果是tasks_separate模式,sub_task_index传入undefined。 */ - _updateProgressToTaskRecord(progress: number, index: number, sub_task_index?: number) { + _updateProgressToTaskRecord(progress: number, index: number, sub_task_index?: number | number[]) { const taskRecord = this.getRecordByIndex(index, sub_task_index); const progressField = this.parsedOptions.progressField; if (progressField) { taskRecord[progressField] = progress; + if (Array.isArray(sub_task_index)) { + this._updateRecordToListTable(taskRecord, sub_task_index); + this._refreshTaskBar(index, sub_task_index); + return; + } const recordIndex = this.getRecordIndexByTaskShowIndex(index); this._updateRecordToListTable(taskRecord, Array.isArray(recordIndex) ? recordIndex : index); if (!this._refreshSortedTaskBarsAfterRecordUpdate(recordIndex, index)) { @@ -1353,11 +1361,11 @@ export class Gantt extends EventTarget { * 如果TasksShowModes是 sub_tasks_*** 模式 则需要传入task_index和sub_task_index */ updateTaskRecord(record: any, task_index: number | number[]): void; - updateTaskRecord(record: any, task_index: number, sub_task_index: number): void; - updateTaskRecord(record: any, task_index: number | number[], sub_task_index?: number) { + updateTaskRecord(record: any, task_index: number, sub_task_index: number | number[]): void; + updateTaskRecord(record: any, task_index: number | number[], sub_task_index?: number | number[]) { if (isValid(sub_task_index)) { const index = typeof task_index === 'number' ? task_index : task_index[0]; - this._updateRecordToListTable(record, [index, sub_task_index]); + this._updateRecordToListTable(record, Array.isArray(sub_task_index) ? sub_task_index : [index, sub_task_index]); this._refreshTaskBar(index, sub_task_index); return; } @@ -1637,8 +1645,11 @@ export class Gantt extends EventTarget { this.stateManager.setScrollLeft(value); } /** 获取任务条的位置。相对应甘特图表左上角的位置。 */ - getTaskBarRelativeRect(index: number) { - const taskBarNode = this.scenegraph.taskBar.getTaskBarNodeByIndex(index); + getTaskBarRelativeRect(index: number, sub_task_index?: number | number[]) { + const taskBarNode = this.scenegraph.taskBar.getTaskBarNodeByIndex(index, sub_task_index); + if (!taskBarNode) { + return null; + } const left = taskBarNode.attribute.x + this.taskListTableInstance.tableNoFrameWidth + diff --git a/packages/vtable-gantt/src/scenegraph/gantt-node.ts b/packages/vtable-gantt/src/scenegraph/gantt-node.ts index e92027d0f0..6a37d207e6 100644 --- a/packages/vtable-gantt/src/scenegraph/gantt-node.ts +++ b/packages/vtable-gantt/src/scenegraph/gantt-node.ts @@ -15,7 +15,7 @@ export class GanttTaskBarNode extends Group { textLabel?: IText; declare name: string; task_index: number; - sub_task_index?: number; + sub_task_index?: number | number[]; record?: any; labelStyle?: ITaskBarLabelTextStyle; diff --git a/packages/vtable-gantt/src/scenegraph/scenegraph.ts b/packages/vtable-gantt/src/scenegraph/scenegraph.ts index d68465655d..88c98dbc29 100644 --- a/packages/vtable-gantt/src/scenegraph/scenegraph.ts +++ b/packages/vtable-gantt/src/scenegraph/scenegraph.ts @@ -338,7 +338,12 @@ export class Scenegraph { this.toolTip.hide(); } - refreshRecordLinkNodes(taskIndex: number, sub_task_index: number, target: GanttTaskBarNode, dy: number = 0) { + refreshRecordLinkNodes( + taskIndex: number, + sub_task_index: number | number[], + target: GanttTaskBarNode, + dy: number = 0 + ) { const gantt: Gantt = this._gantt; const record = gantt.getRecordByIndex(taskIndex, sub_task_index); const vtable_gantt_linkedTo = record.vtable_gantt_linkedTo; diff --git a/packages/vtable-gantt/src/scenegraph/task-bar.ts b/packages/vtable-gantt/src/scenegraph/task-bar.ts index 244f477370..850632174b 100644 --- a/packages/vtable-gantt/src/scenegraph/task-bar.ts +++ b/packages/vtable-gantt/src/scenegraph/task-bar.ts @@ -19,6 +19,23 @@ const LOCATE_ICON_BG_HOVER = '#4080ff'; const LOCATE_ICON_ARROW = '#4e5969'; const LOCATE_ICON_ARROW_HOVER = '#ffffff'; +const isSameSubTaskIndex = (source?: number | number[], target?: number | number[]) => { + if (!isValid(target)) { + return true; + } + + if (Array.isArray(source) || Array.isArray(target)) { + return ( + Array.isArray(source) && + Array.isArray(target) && + source.length === target.length && + source.every((value, index) => value === target[index]) + ); + } + + return source === target; +}; + export class TaskBar { formatMilestoneText(text: string, record: any): string { if (!text) { @@ -524,7 +541,7 @@ export class TaskBar { } return { barGroupBox, baselineBar }; } - updateTaskBarNode(index: number, sub_task_index?: number) { + updateTaskBarNode(index: number, sub_task_index?: number | number[]) { const taskbarGroup = this.getTaskBarNodeByIndex(index, sub_task_index); if (taskbarGroup) { this.barContainer.removeChild(taskbarGroup); @@ -1054,16 +1071,13 @@ export class TaskBar { this.selectedBorders[0].appendChild(line); } - getTaskBarNodeByIndex(index: number, sub_task_index?: number): GanttTaskBarNode { + getTaskBarNodeByIndex(index: number, sub_task_index?: number | number[]): GanttTaskBarNode { let c = this.barContainer.firstChild as GanttTaskBarNode; if (!c) { return null; } for (let i = 0; i < this.barContainer.childrenCount; i++) { - if ( - c.task_index === index && - (!isValid(sub_task_index) || (isValid(sub_task_index) && c.sub_task_index === sub_task_index)) - ) { + if (c.task_index === index && isSameSubTaskIndex(c.sub_task_index, sub_task_index)) { return c; } c = c._next as GanttTaskBarNode; diff --git a/packages/vtable-gantt/src/state/state-manager.ts b/packages/vtable-gantt/src/state/state-manager.ts index f2255b642b..47f5560c3e 100644 --- a/packages/vtable-gantt/src/state/state-manager.ts +++ b/packages/vtable-gantt/src/state/state-manager.ts @@ -479,9 +479,9 @@ export class StateManager { const indexs = getTaskIndexsByTaskY(targetEndY, this._gantt); this._gantt._dragOrderTaskRecord( target.task_index, - target.sub_task_index, + target.sub_task_index as number, indexs.task_index, - indexs.sub_task_index + indexs.sub_task_index as number ); clearRecordShowIndex(this._gantt.records); this._gantt.taskListTableInstance.renderWithRecreateCells(); @@ -494,18 +494,20 @@ export class StateManager { Math.abs(Math.round(deltaY / this._gantt.parsedOptions.rowHeight)) >= 1 ) { const indexs = getTaskIndexsByTaskY(targetEndY, this._gantt); - this._gantt._dragOrderTaskRecord( - target.task_index, - target.sub_task_index, - indexs.task_index, - indexs.sub_task_index - ); - if (this._gantt.parsedOptions.tasksShowMode === TasksShowMode.Sub_Tasks_Separate) { - this._gantt.taskListTableInstance.renderWithRecreateCells(); - this._gantt.scenegraph.refreshTaskBarsAndGrid(); - } else { - this._gantt.scenegraph.taskBar.refresh(); - this._gantt.scenegraph.dependencyLink.refresh(); + if (!Array.isArray(target.sub_task_index) && !Array.isArray(indexs.sub_task_index)) { + this._gantt._dragOrderTaskRecord( + target.task_index, + target.sub_task_index, + indexs.task_index, + indexs.sub_task_index + ); + if (this._gantt.parsedOptions.tasksShowMode === TasksShowMode.Sub_Tasks_Separate) { + this._gantt.taskListTableInstance.renderWithRecreateCells(); + this._gantt.scenegraph.refreshTaskBarsAndGrid(); + } else { + this._gantt.scenegraph.taskBar.refresh(); + this._gantt.scenegraph.dependencyLink.refresh(); + } } // target = this._gantt.scenegraph.taskBar.getTaskBarNodeByIndex(indexs.task_index, indexs.sub_task_index); } else { @@ -1364,6 +1366,8 @@ export class StateManager { // 更新父任务的时间范围 parent[startDateField] = formatDateValue(earliestStart); parent[endDateField] = formatDateValue(latestEnd); + const parentRecordIndex = parentPath.length === 0 ? childIndex : [...parentPath, childIndex]; + this._gantt._updateRecordToListTable(parent, parentRecordIndex); } } } @@ -1377,7 +1381,7 @@ export class StateManager { } } -function reCreateCustomNode(gantt: Gantt, taskBarGroup: Group, taskIndex: number, sub_task_index?: number) { +function reCreateCustomNode(gantt: Gantt, taskBarGroup: Group, taskIndex: number, sub_task_index?: number | number[]) { const taskBarCustomLayout = gantt.parsedOptions.taskBarCustomLayout; if (taskBarCustomLayout) { let customLayoutObj; diff --git a/packages/vtable-gantt/src/ts-types/events.ts b/packages/vtable-gantt/src/ts-types/events.ts index 55f33ecc80..6f75fe741a 100644 --- a/packages/vtable-gantt/src/ts-types/events.ts +++ b/packages/vtable-gantt/src/ts-types/events.ts @@ -3,6 +3,8 @@ import type { IMarkLine, ITaskLink, ITimelineDateInfo } from './gantt-engine'; import type { IPosition } from './common'; import type { IZoomEventArgs } from './zoom-scale'; +type SubTaskIndex = number | number[]; + export type TableEventListener = ( args: TableEventHandlersEventArgumentMap[TYPE] ) => TableEventHandlersReturnMap[TYPE]; //AnyFunction; @@ -19,7 +21,7 @@ export interface TableEventHandlersEventArgumentMap { mouseenter_task_bar: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; record: any; event: Event; federatedEvent: FederatedPointerEvent; @@ -27,7 +29,7 @@ export interface TableEventHandlersEventArgumentMap { mouseleave_task_bar: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; record: any; event: Event; federatedEvent: FederatedPointerEvent; @@ -35,7 +37,7 @@ export interface TableEventHandlersEventArgumentMap { click_task_bar: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; record: any; event: Event; federatedEvent: FederatedPointerEvent; @@ -43,7 +45,7 @@ export interface TableEventHandlersEventArgumentMap { contextmenu_task_bar: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; record: any; event: Event; federatedEvent: FederatedPointerEvent; @@ -51,7 +53,7 @@ export interface TableEventHandlersEventArgumentMap { change_date_range: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; /** 改变后的起始日期 */ startDate: Date; /** 改变后的结束日期 */ @@ -66,7 +68,7 @@ export interface TableEventHandlersEventArgumentMap { move_end_task_bar: { /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; /** 改变后的起始日期 */ startDate: Date; /** 改变后的结束日期 */ @@ -87,7 +89,7 @@ export interface TableEventHandlersEventArgumentMap { event: Event; /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; /** 改变后的起始日期 */ startDate: string; /** 改变后的结束日期 */ @@ -114,7 +116,7 @@ export interface TableEventHandlersEventArgumentMap { point: 'start' | 'end'; /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; record: any; }; contextmenu_dependency_link: { @@ -138,7 +140,7 @@ export interface TableEventHandlersEventArgumentMap { event: Event; /** 第几条数据 */ index: number; - sub_task_index?: number; + sub_task_index?: SubTaskIndex; /** 新的进度值 */ progress: number; /** 原来的进度值 */ diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts new file mode 100644 index 0000000000..b9579f9562 --- /dev/null +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -0,0 +1,193 @@ +// @ts-nocheck +import { ListTable } from '@visactor/vtable'; +import { createDiv } from '../../../vtable/__tests__/dom'; +import { ContextMenuPlugin } from '../../src/context-menu'; +import { MenuHandler } from '../../src/contextmenu/handle-menu-helper'; +import { TableSeriesNumber } from '../../src/table-series-number'; + +global.__VERSION__ = 'none'; + +describe('Context menu row deletion', () => { + let table: ListTable; + + afterEach(() => { + table?.release(); + document.body.innerHTML = ''; + }); + + test('deletes all rows in a reverse-dragged row selection', () => { + const container = createDiv(); + container.style.width = '600px'; + container.style.height = '400px'; + + const seriesNumberPlugin = new TableSeriesNumber({ + rowCount: 5, + colCount: 2 + }); + table = new ListTable({ + container, + showHeader: false, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records: [ + { id: 0, name: 'A' }, + { id: 1, name: 'B' }, + { id: 2, name: 'C' }, + { id: 3, name: 'D' }, + { id: 4, name: 'E' } + ], + syncRecordOperationsToSourceRecords: true, + plugins: [seriesNumberPlugin] + }); + + table.stateManager.select.ranges = [ + { + start: { col: 0, row: 3 }, + end: { col: table.colCount - 1, row: 1 } + } + ]; + + const selectCells = jest.spyOn(table, 'selectCells'); + seriesNumberPlugin['handleSeriesNumberCellRightClick']({ + detail: { + seriesNumberCell: { id: 2, name: 'row-series-number-cell' }, + event: new MouseEvent('contextmenu') + } + }); + + expect(selectCells).not.toHaveBeenCalled(); + + new MenuHandler().handleDeleteRow(table); + + expect(table.records.map(record => record.id)).toEqual([0, 4]); + }); +}); + +describe('Context menu canvas option', () => { + let table: ListTable; + + afterEach(() => { + table?.release(); + document.body.innerHTML = ''; + }); + + test('ContextMenuPlugin enables canvas context menu through contextMenuWorkOnlyCell', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + expect(table.options.menu.contextMenuWorkOnlyCell).toBe(false); + expect(contextMenuPlugin.runTime).toContain(ListTable.EVENT_TYPE.CONTEXTMENU_CANVAS); + }); + + test('ContextMenuPlugin shows canvasMenuItems on CONTEXTMENU_CANVAS event', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false, + bodyCellMenuItems: [{ text: 'Body Item', menuKey: 'body_item' }], + canvasMenuItems: [{ text: 'Canvas Item', menuKey: 'canvas_item' }] + }); + const showMenu = jest.spyOn(contextMenuPlugin['menuManager'], 'showMenu'); + const preventDefault = jest.fn(); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + contextMenuPlugin.run( + { + col: -1, + row: -1, + event: { + clientX: 10, + clientY: 20, + preventDefault + } + }, + ListTable.EVENT_TYPE.CONTEXTMENU_CANVAS, + table + ); + + expect(preventDefault).toHaveBeenCalled(); + expect(showMenu).toHaveBeenCalledWith( + [{ text: 'Canvas Item', menuKey: 'canvas_item' }], + 10, + 20, + { + rowIndex: -1, + colIndex: -1 + }, + table + ); + }); + + test('ContextMenuPlugin init uses new options when added through updateOption', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }] + }); + + table.updateOption({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + expect(table.options.menu.contextMenuWorkOnlyCell).toBe(false); + }); + + test('PluginManager only releases plugins removed through updateOption', () => { + const container = createDiv(); + const removedPlugin = { + id: 'removed-plugin', + name: 'removed-plugin', + runTime: [], + run: jest.fn(), + release: jest.fn() + }; + const keptPlugin = { + id: 'kept-plugin', + name: 'kept-plugin', + runTime: [], + run: jest.fn(), + release: jest.fn() + }; + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [removedPlugin, keptPlugin] + }); + + table.updateOption({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [keptPlugin] + }); + + expect(removedPlugin.release).toHaveBeenCalledTimes(1); + expect(keptPlugin.release).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vtable-plugins/__tests__/excel-import/excel.test.ts b/packages/vtable-plugins/__tests__/excel-import/excel.test.ts new file mode 100644 index 0000000000..2535e43d7a --- /dev/null +++ b/packages/vtable-plugins/__tests__/excel-import/excel.test.ts @@ -0,0 +1,22 @@ +import ExcelJS from 'exceljs'; +import { parseWorksheetToSheetData } from '../../src/excel-import/excel'; + +describe('Excel worksheet import', () => { + test('preserves data after a blank first row', async () => { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Sheet1'); + worksheet.getCell('A2').value = 'Name'; + worksheet.getCell('B2').value = 'Age'; + worksheet.getCell('A3').value = 'Alice'; + worksheet.getCell('B3').value = 30; + + const result = await parseWorksheetToSheetData(worksheet, 0); + + expect(result.rowCount).toBe(3); + expect(result.data).toEqual([ + [null, null], + ['Name', 'Age'], + ['Alice', 30] + ]); + }); +}); diff --git a/packages/vtable-plugins/__tests__/rotate-table-plugin.test.ts b/packages/vtable-plugins/__tests__/rotate-table-plugin.test.ts new file mode 100644 index 0000000000..3fd7f61bda --- /dev/null +++ b/packages/vtable-plugins/__tests__/rotate-table-plugin.test.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +jest.mock('@visactor/vtable', () => { + const vrender = jest.requireActual('../../vtable/src/vrender'); + + return { + matrixAllocate: vrender.matrixAllocate, + transformPointForCanvas: vrender.transformPointForCanvas, + mapToCanvasPointForCanvas: vrender.mapToCanvasPointForCanvas, + registerGlobalEventTransformer: vrender.registerGlobalEventTransformer, + registerWindowEventTransformer: vrender.registerWindowEventTransformer, + vglobal: { + mapToCanvasPoint: jest.fn(), + setEventListenerTransformer: jest.fn() + }, + TABLE_EVENT_TYPE: { + INITIALIZED: 'initialized' + } + }; +}); + +import { vglobal } from '@visactor/vtable'; +import { cancelTransform } from '../src/rotate-table'; + +global.__VERSION__ = 'none'; + +describe('RotateTablePlugin cancel transform - issue #5235', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('keeps wheel preventDefault connected to the native event after canceling rotation', () => { + const tableElement = document.createElement('div'); + jest.spyOn(tableElement, 'getBoundingClientRect').mockReturnValue({ + x: 40, + y: 80, + left: 40, + top: 80, + right: 640, + bottom: 480, + width: 600, + height: 400, + toJSON: () => ({}) + }); + + const originalMapToCanvasPoint = vglobal.mapToCanvasPoint; + const windowSetEventListenerTransformer = jest.fn(); + let globalTransformer; + + jest.spyOn(vglobal, 'setEventListenerTransformer').mockImplementation(transformer => { + globalTransformer = transformer; + }); + + const table = { + rotateDegree: 90, + getElement: () => tableElement, + scenegraph: { + stage: { + window: { + setEventListenerTransformer: windowSetEventListenerTransformer + } + } + }, + pluginManager: { + getPluginByName: () => ({ + vglobal_mapToCanvasPoint: originalMapToCanvasPoint + }) + } + }; + + cancelTransform.call(table, document.createElement('div')); + + const nativeWheelEvent = new WheelEvent('wheel', { + cancelable: true, + clientX: 120, + clientY: 160, + deltaY: 40 + }); + const transformedWheelEvent = globalTransformer(nativeWheelEvent); + + transformedWheelEvent.preventDefault(); + + expect(transformedWheelEvent).toBe(nativeWheelEvent); + expect(nativeWheelEvent.defaultPrevented).toBe(true); + expect(windowSetEventListenerTransformer).toHaveBeenCalledTimes(1); + expect(table.rotateDegree).toBe(0); + expect(vglobal.mapToCanvasPoint).toBe(originalMapToCanvasPoint); + }); +}); diff --git a/packages/vtable-plugins/demo/context-menu/issue-5214-reverse-selected-row-delete.ts b/packages/vtable-plugins/demo/context-menu/issue-5214-reverse-selected-row-delete.ts new file mode 100644 index 0000000000..b0cdfcc0d7 --- /dev/null +++ b/packages/vtable-plugins/demo/context-menu/issue-5214-reverse-selected-row-delete.ts @@ -0,0 +1,111 @@ +import * as VTable from '@visactor/vtable'; +import { MenuHandler } from '../../src/contextmenu/handle-menu-helper'; +import { TableSeriesNumber } from '../../src/table-series-number'; + +const CONTAINER_ID = 'vTable'; + +const initialRecords = [ + { id: 0, name: 'A' }, + { id: 1, name: 'B' }, + { id: 2, name: 'C' }, + { id: 3, name: 'D' }, + { id: 4, name: 'E' } +]; + +const removeDemoToolbar = () => { + document.getElementById('issue5214Toolbar')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const statusNode = document.getElementById('issue5214Status'); + if (!statusNode) { + return; + } + statusNode.textContent = message; + statusNode.style.color = pass ? '#237804' : '#cf1322'; +}; + +export function createTable() { + removeDemoToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '640px'; + container.style.height = '360px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5214Toolbar'; + toolbar.style.cssText = [ + 'display: flex', + 'gap: 8px', + 'align-items: center', + 'height: 48px', + 'font-size: 12px' + ].join(';'); + toolbar.innerHTML = ` + + + 预期:删除 id=1,2,3,仅保留 0 和 4。 + + `; + container.before(toolbar); + + const seriesNumberPlugin = new TableSeriesNumber({ + rowCount: 5, + colCount: 2 + }); + + const tableInstance = new VTable.ListTable({ + container, + showHeader: false, + columns: [ + { field: 'id', title: 'ID', width: 120 }, + { field: 'name', title: 'Name', width: 160 } + ], + records: initialRecords.map(record => ({ ...record })), + syncRecordOperationsToSourceRecords: true, + plugins: [seriesNumberPlugin], + defaultRowHeight: 36 + }); + + const getRecordIds = () => tableInstance.records.map((record: { id: number }) => record.id); + const updateStatusFromRecords = (prefix: string, pass: boolean) => { + setStatus(`${prefix} | records=[${getRecordIds().join(',')}]`, pass); + }; + + document.getElementById('issue5214Reset')?.addEventListener('click', () => { + tableInstance.setRecords(initialRecords.map(record => ({ ...record }))); + updateStatusFromRecords('RESET', true); + }); + + document.getElementById('issue5214Reproduce')?.addEventListener('click', () => { + tableInstance.stateManager.select.ranges = [ + { + start: { col: 0, row: 3 }, + end: { col: tableInstance.colCount - 1, row: 1 } + } + ]; + + seriesNumberPlugin['handleSeriesNumberCellRightClick']({ + detail: { + seriesNumberCell: { id: 2, name: 'row-series-number-cell' }, + event: new MouseEvent('contextmenu') + } + }); + + new MenuHandler().handleDeleteRow(tableInstance); + + const ids = getRecordIds(); + const pass = ids.length === 2 && ids[0] === 0 && ids[1] === 4; + updateStatusFromRecords(pass ? 'PASS' : 'FAIL', pass); + }); + + updateStatusFromRecords('READY', true); + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + removeDemoToolbar(); + release(); + }; + + (window as any).tableInstance = tableInstance; +} diff --git a/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts b/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts new file mode 100644 index 0000000000..30e960df6a --- /dev/null +++ b/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts @@ -0,0 +1,78 @@ +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '../../src/context-menu'; + +const CONTAINER_ID = 'vTable'; + +export function createTableInstance() { + const records = [ + { id: 1, name: 'alpha', value: 12 }, + { id: 2, name: 'beta', value: 34 } + ]; + + const plugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false, + bodyCellMenuItems: [ + { text: 'Copy from plugin', menuKey: 'copy' }, + '---', + { text: 'Body cell item', menuKey: 'body_cell_item' } + ], + canvasMenuItems: [ + { text: 'Canvas blank area', menuKey: 'canvas_blank_area' }, + { text: 'Canvas menu item', menuKey: 'canvas_menu_item' } + ], + beforeShowAdjustMenuItems: (menuItems, _table, col, row) => { + if (col === -1 && row === -1) { + return [ + ...menuItems, + { + text: `Blank canvas: col ${col}, row ${row}`, + menuKey: 'canvas_position' + } + ]; + } + return menuItems.filter(item => item !== '---'); + } + }); + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records, + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 160 }, + { field: 'value', title: 'Value', width: 120 } + ], + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + widthMode: 'standard', + heightMode: 'standard', + plugins: [plugin] + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; + return tableInstance; +} + +export function createTable() { + const container = document.createElement('div'); + container.id = CONTAINER_ID; + container.style.width = '100%'; + container.style.height = '420px'; + document.body.appendChild(container); + + const info = document.createElement('div'); + info.style.margin = '10px'; + info.style.padding = '10px'; + info.style.border = '1px solid #ddd'; + info.style.borderRadius = '4px'; + info.style.backgroundColor = '#f9f9f9'; + info.innerHTML = ` +

Issue 5215: Canvas context menu

+

Right-click the blank canvas area outside the table cells. The context menu should show canvas-specific items.

+

Right-click body cells to verify cell menu items are still independent from blank canvas menu items.

+ `; + + document.body.insertBefore(info, container); + return createTableInstance(); +} diff --git a/packages/vtable-plugins/demo/excel-import/issue-5227-leading-blank-row.ts b/packages/vtable-plugins/demo/excel-import/issue-5227-leading-blank-row.ts new file mode 100644 index 0000000000..0958dea612 --- /dev/null +++ b/packages/vtable-plugins/demo/excel-import/issue-5227-leading-blank-row.ts @@ -0,0 +1,107 @@ +import ExcelJS from 'exceljs'; +import * as VTable from '@visactor/vtable'; +import { parseWorksheetToSheetData } from '../../src/excel-import/excel'; + +const CONTAINER_ID = 'vTable'; + +const removeDemoToolbar = () => { + document.getElementById('issue5227Toolbar')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const statusNode = document.getElementById('issue5227Status'); + if (!statusNode) { + return; + } + statusNode.textContent = message; + statusNode.style.color = pass ? '#237804' : '#cf1322'; +}; + +const createWorksheetWithLeadingBlankRow = () => { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Sheet1'); + worksheet.getCell('A2').value = 'Name'; + worksheet.getCell('B2').value = 'Age'; + worksheet.getCell('A3').value = 'Alice'; + worksheet.getCell('B3').value = 30; + return worksheet; +}; + +const renderImportedData = (container: HTMLElement, data: unknown[][]) => { + if ((window as any).tableInstance) { + (window as any).tableInstance.release(); + } + + const maxColumnCount = data.reduce((max, row) => Math.max(max, row.length), 0); + const records = data.map((row, index) => { + const record: Record = { rowIndex: index + 1 }; + for (let col = 0; col < maxColumnCount; col++) { + record[`col${col}`] = row[col] ?? null; + } + return record; + }); + + const columns: VTable.ColumnsDefine = [ + { field: 'rowIndex', title: 'Excel Row', width: 100 }, + ...Array.from({ length: maxColumnCount }, (_, col) => ({ + field: `col${col}`, + title: String.fromCharCode(65 + col), + width: 140 + })) + ]; + + const tableInstance = new VTable.ListTable({ + container, + columns, + records, + defaultRowHeight: 36 + }); + + (window as any).tableInstance = tableInstance; +}; + +export function createTable() { + removeDemoToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '640px'; + container.style.height = '360px'; + container.innerHTML = ''; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5227Toolbar'; + toolbar.style.cssText = [ + 'display: flex', + 'gap: 8px', + 'align-items: center', + 'height: 48px', + 'font-size: 12px' + ].join(';'); + toolbar.innerHTML = ` + + 预期:保留空白第 1 行,rowCount=3。 + + `; + container.before(toolbar); + + document.getElementById('issue5227Import')?.addEventListener('click', async () => { + const worksheet = createWorksheetWithLeadingBlankRow(); + const result = await parseWorksheetToSheetData(worksheet, 0); + const pass = + result.rowCount === 3 && + result.columnCount === 2 && + result.data[0]?.[0] === null && + result.data[1]?.[0] === 'Name' && + result.data[2]?.[0] === 'Alice'; + + renderImportedData(container, result.data); + setStatus( + `${pass ? 'PASS' : 'FAIL'} | rowCount=${result.rowCount}, columnCount=${result.columnCount}, data=${JSON.stringify( + result.data + )}`, + pass + ); + }); + + setStatus('READY', true); +} diff --git a/packages/vtable-plugins/demo/filter/issue-4625-filter-popup-position.ts b/packages/vtable-plugins/demo/filter/issue-4625-filter-popup-position.ts new file mode 100644 index 0000000000..dc1658e3d0 --- /dev/null +++ b/packages/vtable-plugins/demo/filter/issue-4625-filter-popup-position.ts @@ -0,0 +1,61 @@ +import * as VTable from '@visactor/vtable'; +import { FilterPlugin } from '../../src/filter'; + +const CONTAINER_ID = 'vTable'; + +const generateDemoData = (count: number) => { + const departments = ['研发部', '市场部', '销售部', '人事部', '财务部']; + const statuses = ['在职', '请假', '离职']; + + return Array.from(new Array(count)).map((_, i) => ({ + id: i + 1, + name: `员工${i + 1}`, + age: 22 + (i % 20), + department: departments[i % departments.length], + salary: 5000 + i * 300, + status: statuses[i % statuses.length], + isFullTime: i % 3 !== 0, + status1: statuses[(i + 1) % statuses.length], + status2: statuses[(i + 2) % statuses.length], + status3: statuses[i % statuses.length], + status4: statuses[(i + 1) % statuses.length], + status5: statuses[(i + 2) % statuses.length] + })); +}; + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '800px'; + container.style.height = '500px'; + + const filterPlugin = new FilterPlugin({ + filterModes: ['byValue', 'byCondition'] + }); + + const tableInstance = new VTable.ListTable({ + container, + records: generateDemoData(50), + columns: [ + { field: 'id', title: 'ID', width: 60 }, + { field: 'name', title: '姓名', width: 120 }, + { field: 'age', title: '年龄', width: 100 }, + { field: 'department', title: '部门', width: 120 }, + { + field: 'salary', + title: '薪资', + width: 120, + fieldFormat: record => `¥${record.salary}` + }, + { field: 'status', title: '状态', width: 100 }, + { field: 'isFullTime', title: '全职', width: 80, cellType: 'checkbox' }, + { field: 'status1', title: '状态1', width: 100 }, + { field: 'status2', title: '状态2', width: 100 }, + { field: 'status3', title: '状态3', width: 100 }, + { field: 'status4', title: '状态4', width: 100 }, + { field: 'status5', title: '状态5', width: 100 } + ], + plugins: [filterPlugin] + }); + + window.tableInstance = tableInstance; +} diff --git a/packages/vtable-plugins/demo/master-detail-plugin/issue-5185-set-records-expand.ts b/packages/vtable-plugins/demo/master-detail-plugin/issue-5185-set-records-expand.ts new file mode 100644 index 0000000000..86c27fe46a --- /dev/null +++ b/packages/vtable-plugins/demo/master-detail-plugin/issue-5185-set-records-expand.ts @@ -0,0 +1,97 @@ +import * as VTable from '@visactor/vtable'; +import { MasterDetailPlugin } from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const columns: VTable.ColumnsDefine = [ + { field: 'name', title: 'Name', width: 180 }, + { field: 'department', title: 'Department', width: 160 }, + { field: 'status', title: 'Status', width: 120 } +]; + +const detailColumns: VTable.ColumnsDefine = [ + { field: 'project', title: 'Project', width: 180 }, + { field: 'role', title: 'Role', width: 140 } +]; + +const createRecords = (prefix: string) => [ + { + id: `${prefix}-1`, + name: `${prefix} Employee 1`, + department: 'Engineering', + status: 'Active', + children: [ + { project: `${prefix} Project A`, role: 'Owner' }, + { project: `${prefix} Project B`, role: 'Reviewer' } + ] + }, + { + id: `${prefix}-2`, + name: `${prefix} Employee 2`, + department: 'Design', + status: 'Active', + children: [{ project: `${prefix} Project C`, role: 'Designer' }] + } +]; + +const createStatusBar = () => { + const container = document.getElementById(CONTAINER_ID)!; + const status = document.createElement('div'); + status.id = 'issue5185Status'; + status.style.cssText = 'height: 32px; line-height: 32px; font-size: 13px; color: #333;'; + status.textContent = 'Click "Check setRecords expand" to verify issue #5185.'; + + const button = document.createElement('button'); + button.textContent = 'Check setRecords expand'; + button.style.cssText = 'margin: 0 0 8px 8px;'; + button.onclick = () => checkSetRecordsExpand(); + + container.parentElement?.insertBefore(status, container); + status.appendChild(button); +}; + +const getSubTableCount = (tableInstance: VTable.ListTable) => + ((tableInstance as any).internalProps.subTableInstances as Map)?.size ?? 0; + +const checkSetRecordsExpand = () => { + const tableInstance = (window as any).tableInstance as VTable.ListTable; + const status = document.getElementById('issue5185Status')!; + + tableInstance.setRecords(createRecords('After')); + tableInstance.toggleHierarchyState(0, tableInstance.columnHeaderLevelCount); + + const subTableCount = getSubTableCount(tableInstance); + const firstRecord = tableInstance.records?.[0] as any; + const pass = subTableCount > 0 && firstRecord?.hierarchyState === VTable.TYPES.HierarchyState.expand; + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | subTableCount=${subTableCount}, hierarchyState=${ + firstRecord?.hierarchyState + }`; + return status.textContent; +}; + +export function createTable() { + const option: VTable.ListTableConstructorOptions = { + records: createRecords('Initial'), + columns, + widthMode: 'standard', + defaultRowHeight: 36, + plugins: [ + new MasterDetailPlugin({ + detailTableOptions: { + columns: detailColumns, + heightMode: 'autoHeight', + defaultRowHeight: 30, + style: { + height: 90 + } + } + }) + ] + }; + + createStatusBar(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, option); + (window as any).tableInstance = tableInstance; + (window as any).issue5185Run = checkSetRecordsExpand; +} diff --git a/packages/vtable-plugins/demo/menu.ts b/packages/vtable-plugins/demo/menu.ts index f5e19298ca..f110a5cb4b 100644 --- a/packages/vtable-plugins/demo/menu.ts +++ b/packages/vtable-plugins/demo/menu.ts @@ -11,6 +11,10 @@ export const menus = [ path: 'excel-import', name: 'excel-import' }, + { + path: 'excel-import', + name: 'issue-5227-leading-blank-row' + }, { path: 'filter', name: 'filter' @@ -23,6 +27,10 @@ export const menus = [ path: 'filter', name: 'issue-5137' }, + { + path: 'filter', + name: 'issue-4625-filter-popup-position' + }, { path: 'filter', name: 'value-filter' @@ -83,6 +91,10 @@ export const menus = [ path: 'rotate-table', name: 'rotate-table' }, + { + path: 'rotate-table', + name: 'issue-5235-rotate-wheel' + }, { path: 'table-series-number', name: 'table-series-number' @@ -127,6 +139,10 @@ export const menus = [ path: 'master-detail-plugin', name: 'master-detail-plugin9' }, + { + path: 'master-detail-plugin', + name: 'issue-5185-set-records-expand' + }, { menu: 'pivot-plugin', children: [ @@ -140,6 +156,14 @@ export const menus = [ path: 'context-menu', name: 'context-menu' }, + { + path: 'context-menu', + name: 'issue-5215-context-menu-canvas' + }, + { + path: 'context-menu', + name: 'issue-5214-reverse-selected-row-delete' + }, { path: 'table-export', name: 'table-export' diff --git a/packages/vtable-plugins/demo/rotate-table/issue-5235-rotate-wheel.ts b/packages/vtable-plugins/demo/rotate-table/issue-5235-rotate-wheel.ts new file mode 100644 index 0000000000..1858232ed1 --- /dev/null +++ b/packages/vtable-plugins/demo/rotate-table/issue-5235-rotate-wheel.ts @@ -0,0 +1,132 @@ +import * as VTable from '@visactor/vtable'; +import { RotateTablePlugin } from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const records = Array.from({ length: 80 }, (_, index) => ({ + id: index + 1, + name: `Name ${index + 1}`, + email: `${index + 1}@example.com`, + city: `City ${index + 1}` +})); + +const columns: VTable.ColumnsDefine = [ + { field: 'id', title: 'ID', width: 80, sort: true }, + { field: 'name', title: 'Name', width: 180 }, + { field: 'email', title: 'Email', width: 220 }, + { field: 'city', title: 'City', width: 180 } +]; + +const removeToolbar = () => { + document.getElementById('issue5235Toolbar')?.remove(); + document.getElementById('issue5235Spacer')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const status = document.getElementById('issue5235Status'); + if (!status) { + return; + } + status.textContent = message; + status.style.color = pass ? '#237804' : '#cf1322'; +}; + +const getRotateDom = () => document.getElementById(CONTAINER_ID) as HTMLElement; + +const dispatchWheelAndCheckPrevented = () => { + const canvas = document.querySelector(`#${CONTAINER_ID} canvas`); + if (!canvas) { + return false; + } + + const rect = canvas.getBoundingClientRect(); + const wheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + deltaY: 80 + }); + + canvas.dispatchEvent(wheelEvent); + return wheelEvent.defaultPrevented; +}; + +export function createTable() { + removeToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '720px'; + container.style.height = '360px'; + container.style.margin = '24px auto'; + container.style.border = '1px solid #d9d9d9'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5235Toolbar'; + toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;height:48px;font-size:12px;'; + toolbar.innerHTML = ` + + + + 预期:旋转再还原后,表格内 wheel 的原始事件仍会被 preventDefault。 + + `; + container.before(toolbar); + + const spacer = document.createElement('div'); + spacer.id = 'issue5235Spacer'; + spacer.style.cssText = 'height:900px;background:linear-gradient(#fff,#f5f5f5);'; + spacer.textContent = '页面滚动占位:用于观察表格内滚轮是否带动外层页面滚动。'; + container.after(spacer); + + const rotatePlugin = new RotateTablePlugin(); + const tableInstance = new VTable.ListTable(container, { + records, + columns, + widthMode: 'standard', + rowSeriesNumber: {}, + overscrollBehavior: 'none', + plugins: [rotatePlugin] + }); + + const rotateTable = () => { + const rotateDom = getRotateDom(); + const { width, height } = rotateDom.getBoundingClientRect(); + rotateDom.style.width = `${height}px`; + rotateDom.style.height = `${width}px`; + tableInstance.rotate90WithTransform?.(rotateDom); + setStatus('READY | 已旋转,请点击还原后检查滚轮。', true); + }; + + const restoreTable = () => { + const rotateDom = getRotateDom(); + const { width, height } = rotateDom.getBoundingClientRect(); + rotateDom.style.width = `${height}px`; + rotateDom.style.height = `${width}px`; + tableInstance.cancelTransform?.(rotateDom); + setStatus('READY | 已还原,请点击滚轮检查。', true); + }; + + const checkWheel = () => { + const prevented = dispatchWheelAndCheckPrevented(); + setStatus(`${prevented ? 'PASS' : 'FAIL'} | wheel.defaultPrevented=${prevented}`, prevented); + }; + + document.getElementById('issue5235Rotate')?.addEventListener('click', rotateTable); + document.getElementById('issue5235Restore')?.addEventListener('click', restoreTable); + document.getElementById('issue5235Check')?.addEventListener('click', checkWheel); + + (window as any).tableInstance = tableInstance; + (window as any).issue5235Run = () => { + rotateTable(); + restoreTable(); + checkWheel(); + return document.getElementById('issue5235Status')?.textContent; + }; + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + removeToolbar(); + release(); + }; +} diff --git a/packages/vtable-plugins/package.json b/packages/vtable-plugins/package.json index 7ba93aab23..417b64ec98 100644 --- a/packages/vtable-plugins/package.json +++ b/packages/vtable-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-plugins", - "version": "1.26.5", + "version": "1.26.6", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -61,9 +61,9 @@ "devDependencies": { "cross-env": "^7.0.3", "increase-memory-limit": "^1.0.7", - "@visactor/vtable": "workspace:1.26.5", - "@visactor/vtable-editors": "workspace:1.26.5", - "@visactor/vtable-gantt": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-gantt": "workspace:1.26.6", "@visactor/vchart": "2.1.3", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 6ecc46b94f..47f9cf06bc 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -29,6 +29,10 @@ export interface ContextMenuOptions { headerCellMenuItems?: MenuItemOrSeparator[]; /** 表体菜单项 */ bodyCellMenuItems?: MenuItemOrSeparator[]; + /** 空白画布区域菜单项。仅 contextMenuWorkOnlyCell 为 false 时生效。 */ + canvasMenuItems?: MenuItemOrSeparator[]; + /** 右键菜单是否只工作在单元格上。默认 true;配置 false 时空白画布区域也弹出菜单。 */ + contextMenuWorkOnlyCell?: boolean; /** 自定义菜单样式 */ customMenuAttributions?: MenuAttributions; /** 菜单点击回调。如果设置是函数,则忽略内部默认的菜单项处理逻辑。如果这里配置的是个对象(对象的key为menuKey),则有匹配的menuKey时忽略内部默认的菜单项处理逻辑, @@ -56,7 +60,7 @@ export type MenuClickCallback = (args: MenuClickEventArgs, table: ListTable) => export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { id = `context-menu`; name = 'Context Menu'; - runTime = [TABLE_EVENT_TYPE.CONTEXTMENU_CELL, TABLE_EVENT_TYPE.PLUGIN_EVENT]; + runTime = [TABLE_EVENT_TYPE.CONTEXTMENU_CELL, TABLE_EVENT_TYPE.CONTEXTMENU_CANVAS, TABLE_EVENT_TYPE.PLUGIN_EVENT]; pluginOptions: ContextMenuOptions; table: ListTable; /** 菜单管理器 */ @@ -156,6 +160,22 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } }; + /** + * 处理空白画布右键菜单事件 + */ + private handleContextMenuCanvas = (eventArgs: any, table: BaseTableAPI): void => { + let menuItems = this.pluginOptions.canvasMenuItems || []; + const { col = -1, row = -1 } = eventArgs; + + if (this.pluginOptions.beforeShowAdjustMenuItems) { + menuItems = this.pluginOptions.beforeShowAdjustMenuItems(menuItems, table as ListTable, col, row); + } + + if (menuItems.length > 0) { + this.showContextMenu(menuItems, eventArgs.event.clientX, eventArgs.event.clientY, col, row); + } + }; + /** * 处理插件事件 */ @@ -191,6 +211,15 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { /** * 运行插件 */ + init(_table: BaseTableAPI, options: BaseTableAPI['options']) { + if (this.pluginOptions.contextMenuWorkOnlyCell === false) { + options.menu = { + ...options.menu, + contextMenuWorkOnlyCell: false + }; + } + } + run(...args: any[]) { const eventArgs = args[0]; const runTime = args[1]; @@ -203,6 +232,8 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { // 根据事件类型处理不同的右键菜单 if (runTime === TABLE_EVENT_TYPE.CONTEXTMENU_CELL) { this.handleContextMenuCell(eventArgs, table); + } else if (runTime === TABLE_EVENT_TYPE.CONTEXTMENU_CANVAS) { + this.handleContextMenuCanvas(eventArgs, table); } else if (runTime === TABLE_EVENT_TYPE.PLUGIN_EVENT) { this.handlePluginEvent(eventArgs, table); } diff --git a/packages/vtable-plugins/src/contextmenu/handle-menu-helper.ts b/packages/vtable-plugins/src/contextmenu/handle-menu-helper.ts index 1528a775e9..94fdf081e4 100644 --- a/packages/vtable-plugins/src/contextmenu/handle-menu-helper.ts +++ b/packages/vtable-plugins/src/contextmenu/handle-menu-helper.ts @@ -105,7 +105,9 @@ export class MenuHandler { const deleteRowIndexs: number[] = []; for (let i = 0; i < selectRanges.length; i++) { const range = selectRanges[i]; - for (let j = range.start.row; j <= range.end.row; j++) { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + for (let j = startRow; j <= endRow; j++) { if (!deleteRowIndexs.includes(j)) { deleteRowIndexs.push(j); } diff --git a/packages/vtable-plugins/src/excel-import/excel.ts b/packages/vtable-plugins/src/excel-import/excel.ts index 3e64674de5..6d7de56b0a 100644 --- a/packages/vtable-plugins/src/excel-import/excel.ts +++ b/packages/vtable-plugins/src/excel-import/excel.ts @@ -150,9 +150,16 @@ export async function parseWorksheetToSheetData( const sheetTitle = worksheet.name || `Sheet${sheetIndex + 1}`; const sheetKey = `sheet_${Date.now()}_${sheetIndex}`; - // 获取实际数据范围 - const rowCount = worksheet.actualRowCount || 0; - const columnCount = worksheet.actualColumnCount || 0; + // 获取实际数据范围。actualRowCount/actualColumnCount 只统计非空行列的数量, + // 当数据前面或中间存在空白行列时,不能作为最后一个数据单元格的索引。 + let rowCount = 0; + let columnCount = 0; + worksheet.eachRow((row, rowNumber) => { + rowCount = Math.max(rowCount, rowNumber); + row.eachCell((_cell, colNumber) => { + columnCount = Math.max(columnCount, colNumber); + }); + }); if (rowCount === 0 || columnCount === 0) { // 空 sheet,但仍需要解析合并单元格信息(可能只有合并单元格没有数据) diff --git a/packages/vtable-plugins/src/master-detail-plugin/config.ts b/packages/vtable-plugins/src/master-detail-plugin/config.ts index fe37a9d5b0..36d98c12f7 100644 --- a/packages/vtable-plugins/src/master-detail-plugin/config.ts +++ b/packages/vtable-plugins/src/master-detail-plugin/config.ts @@ -7,6 +7,7 @@ import type { DetailTableOptions, MasterDetailPluginOptions } from './types'; export class ConfigManager { private expandRowCallback?: (rowIndex: number) => void; private childrenKey: string; + private expansionVersion = 0; constructor(private pluginOptions: MasterDetailPluginOptions, private table: VTable.ListTable) { this.childrenKey = pluginOptions.childrenKey || 'children'; @@ -161,7 +162,8 @@ export class ConfigManager { /** * 处理记录的层级状态 */ - private processRecordsHierarchyStates(records: unknown[]): void { + processRecordsHierarchyStates(records: unknown[], expandInitialRows: boolean = true): void { + const expansionVersion = ++this.expansionVersion; const HierarchyState = VTable.TYPES.HierarchyState; // 兼容处理headerExpandLevel const hierarchyExpandLevel = this.table.options.hierarchyExpandLevel || this.table.options.headerExpandLevel; @@ -193,14 +195,16 @@ export class ConfigManager { }); }; processRecords(records); - this.performInitialExpansion(); + if (expandInitialRows) { + this.performInitialExpansion(expansionVersion); + } } /** * 遍历所有记录,根据 hierarchyState 状态执行初始展开 * 与VTable的异步CellGroup创建过程同步,在每个CellGroup创建后检查是否需要展开 */ - private performInitialExpansion(): void { + private performInitialExpansion(expansionVersion: number): void { // 获取需要展开的记录索引列表 const expandableRecords = this.getExpandableRecords(); if (expandableRecords.length === 0) { @@ -208,7 +212,7 @@ export class ConfigManager { } // 开始异步展开过程,与VTable的渲染频率同步 - this.startAsyncExpansion(expandableRecords); + this.startAsyncExpansion(expandableRecords, expansionVersion); } /** @@ -268,11 +272,16 @@ export class ConfigManager { * 开始异步展开过程,与VTable的异步渲染同步 */ private startAsyncExpansion( - expandableRecords: Array<{ recordIndex: number; actualRowIndex: number; record: unknown }> + expandableRecords: Array<{ recordIndex: number; actualRowIndex: number; record: unknown }>, + expansionVersion: number ): void { let currentIndex = 0; const processNextExpansion = (): void => { + if (expansionVersion !== this.expansionVersion) { + return; + } + if (currentIndex >= expandableRecords.length) { return; // 所有展开操作完成 } @@ -337,6 +346,7 @@ export class ConfigManager { * 释放所有资源和引用 */ release(): void { + this.expansionVersion++; this.isRowExpanded = () => false; // 清理对表格的引用 (this as unknown as { table: VTable.ListTable | null }).table = null; diff --git a/packages/vtable-plugins/src/master-detail-plugin/core.ts b/packages/vtable-plugins/src/master-detail-plugin/core.ts index cea11d6ff5..2d37acdac3 100644 --- a/packages/vtable-plugins/src/master-detail-plugin/core.ts +++ b/packages/vtable-plugins/src/master-detail-plugin/core.ts @@ -171,13 +171,39 @@ export class MasterDetailPlugin implements pluginsDefinition.IVTablePlugin { collapseRow: (rowIndex: number) => this.collapseRow(rowIndex), updateSubTablePositions: () => this.subTableManager.recalculateAllSubTablePositions(), updateRowHeightForExpand: (rowIndex: number, deltaHeight: number) => - this.updateRowHeightForExpand(rowIndex, deltaHeight) + this.updateRowHeightForExpand(rowIndex, deltaHeight), + resetMasterDetailStateBeforeSetRecords: () => this.resetMasterDetailStateBeforeSetRecords() }); // 执行API扩展 this.tableAPIExtensions.extendTableAPI(); } + /** + * setRecords 前清理旧主从表状态,避免新数据复用旧展开行和子表实例 + */ + private resetMasterDetailStateBeforeSetRecords(): void { + const internalProps = getInternalProps(this.table); + const expandedRows = [...this.eventManager.getExpandedRows()]; + expandedRows.forEach(rowIndex => { + try { + this.collapseRowToNoRealRecordIndex(rowIndex); + } catch (error) { + console.warn(`Failed to collapse master detail row ${rowIndex} before setRecords:`, error); + } + }); + + const subTableRowIndices = Array.from(internalProps.subTableInstances?.keys() ?? []); + subTableRowIndices.forEach(bodyRowIndex => { + this.subTableManager.removeSubTable(bodyRowIndex); + }); + + internalProps.expandedRecordIndices?.splice(0); + internalProps.originalRowHeights?.clear(); + internalProps.subTableCheckboxStates?.clear(); + this.eventManager.setExpandedRows([]); + } + /** * 在 adaptive 处理后更新原始高度缓存 */ @@ -266,9 +292,6 @@ export class MasterDetailPlugin implements pluginsDefinition.IVTablePlugin { this.updateRowHeightForExpand(rowIndex, deltaHeight); this.table.scenegraph.updateContainerHeight(rowIndex, deltaHeight); internalProps._heightResizedRowMap.add(rowIndex); - if (rowIndex === 96) { - console.log('wokk'); - } this.subTableManager.renderSubTable(bodyRowIndex, childrenData, (record, bodyRowIndex) => this.configManager.getDetailConfigForRecord(record, bodyRowIndex) ); diff --git a/packages/vtable-plugins/src/master-detail-plugin/table-api-extensions.ts b/packages/vtable-plugins/src/master-detail-plugin/table-api-extensions.ts index 33b1ef3fb2..4d73c6b406 100644 --- a/packages/vtable-plugins/src/master-detail-plugin/table-api-extensions.ts +++ b/packages/vtable-plugins/src/master-detail-plugin/table-api-extensions.ts @@ -23,6 +23,7 @@ export class TableAPIExtensions { private originalUpdateChartSizeForResizeColWidth?: (col: number) => void; private originalUpdateChartSizeForResizeRowHeight?: (row: number) => void; private originalUpdateRowHeight?: (row: number, detaY: number, skipTableHeightMap?: boolean) => void; + private originalSetRecords?: (records: Array, option?: Parameters[1]) => void; private originalGetResizeColAt?: ( abstractX: number, abstractY: number, @@ -50,6 +51,7 @@ export class TableAPIExtensions { collapseRow: (rowIndex: number) => void; updateSubTablePositions: () => void; updateRowHeightForExpand: (rowIndex: number, deltaHeight: number) => void; + resetMasterDetailStateBeforeSetRecords: () => void; }; constructor( @@ -67,6 +69,7 @@ export class TableAPIExtensions { collapseRow: (rowIndex: number) => void; updateSubTablePositions: () => void; updateRowHeightForExpand: (rowIndex: number, deltaHeight: number) => void; + resetMasterDetailStateBeforeSetRecords: () => void; } ) { this.table = table; @@ -140,6 +143,8 @@ export class TableAPIExtensions { this.extendUpdateRowHeight(); // 处理展开行的列宽调整检测 this.extendGetResizeColAt(); + // 处理 setRecords 后主从表层级状态重建 + this.extendSetRecords(); } /** @@ -197,6 +202,24 @@ export class TableAPIExtensions { }; } + /** + * 扩展 setRecords 方法 + */ + private extendSetRecords(): void { + const table = this.table; + this.originalSetRecords = table.setRecords.bind(table); + table.setRecords = (records: Array, option?: Parameters[1]) => { + this.callbacks.resetMasterDetailStateBeforeSetRecords(); + if (Array.isArray(records)) { + this.configManager.processRecordsHierarchyStates(records, false); + } + this.originalSetRecords?.(records, option); + if (Array.isArray(records)) { + this.configManager.processRecordsHierarchyStates(records); + } + }; + } + /** * 扩展 updateResizeRow 方法 */ diff --git a/packages/vtable-plugins/src/rotate-table.ts b/packages/vtable-plugins/src/rotate-table.ts index 95955bc00d..582c154a2d 100644 --- a/packages/vtable-plugins/src/rotate-table.ts +++ b/packages/vtable-plugins/src/rotate-table.ts @@ -154,7 +154,6 @@ export function cancelTransform(this: ListTable, rotateDom: HTMLElement) { }; const getMatrix = () => { const matrix = matrixAllocate.allocate(1, 0, 0, 1, 0, 0); - matrix.translate(x1, y1); return matrix; }; registerGlobalEventTransformer(vglobal, this.getElement(), getMatrix, getRect as any, transformPointForCanvas); diff --git a/packages/vtable-plugins/src/table-series-number.ts b/packages/vtable-plugins/src/table-series-number.ts index ea87ff781a..1c05719d6c 100644 --- a/packages/vtable-plugins/src/table-series-number.ts +++ b/packages/vtable-plugins/src/table-series-number.ts @@ -348,9 +348,11 @@ export class TableSeriesNumber implements pluginsDefinition.IVTablePlugin { const rowIndex = seriesNumberCell.id; //判断rowIndex整行是否被选中 const isRowSelected = this.table.stateManager.select.ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); return ( - range.start.row <= rowIndex && - rowIndex <= range.end.row && + startRow <= rowIndex && + rowIndex <= endRow && range.start.col === 0 && range.end.col === this.table.colCount - 1 ); diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 9e7e52e62f..9d988fcd35 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-search", - "version": "1.26.5", + "version": "1.26.6", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vtable-sheet/__tests__/recalculation-issue-test.test.ts b/packages/vtable-sheet/__tests__/recalculation-issue-test.test.ts index 74519b9fbd..2e6f13c6d0 100644 --- a/packages/vtable-sheet/__tests__/recalculation-issue-test.test.ts +++ b/packages/vtable-sheet/__tests__/recalculation-issue-test.test.ts @@ -71,6 +71,24 @@ describe('Recalculation Chain Issue Test', () => { } }); + test('should recalculate downstream formulas after an intermediate formula has a cached value', () => { + engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 1 }, 1); // B2 + engine.setCellContent({ sheet: 'Sheet1', row: 2, col: 1 }, 10); // B3 + engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 2 }, '=B2'); // C2 + engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 3 }, '=C2+B3'); // D2 + + expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 2 })).toEqual({ value: 1, error: undefined }); + expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 3 })).toEqual({ value: 11, error: undefined }); + + engine.setCellContent({ sheet: 'Sheet1', row: 2, col: 1 }, 20); + expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 3 })).toEqual({ value: 21, error: undefined }); + + engine.setCellContent({ sheet: 'Sheet1', row: 1, col: 1 }, 5); + + expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 2 })).toEqual({ value: 5, error: undefined }); + expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 3 })).toEqual({ value: 25, error: undefined }); + }); + test('测试重新计算过程中的中间状态', () => { console.log('\n=== 测试重新计算过程中的中间状态 ==='); diff --git a/packages/vtable-sheet/examples/menu.ts b/packages/vtable-sheet/examples/menu.ts index 2265304622..2ed8ae521c 100644 --- a/packages/vtable-sheet/examples/menu.ts +++ b/packages/vtable-sheet/examples/menu.ts @@ -30,5 +30,9 @@ export const menus = [ { path: 'sheet', name: 'issue-5204-formula-manager' + }, + { + path: 'sheet', + name: 'issue-5234-formula-chain' } ]; diff --git a/packages/vtable-sheet/examples/sheet/issue-5234-formula-chain.ts b/packages/vtable-sheet/examples/sheet/issue-5234-formula-chain.ts new file mode 100644 index 0000000000..6c65222ec3 --- /dev/null +++ b/packages/vtable-sheet/examples/sheet/issue-5234-formula-chain.ts @@ -0,0 +1,186 @@ +import { VTableSheet } from '../../src/index'; + +const CONTAINER_ID = 'vTable'; +const SHEET_KEY = 'issue5234'; + +const removeDemoToolbar = () => { + document.getElementById('issue5234Toolbar')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const statusNode = document.getElementById('issue5234Status'); + if (!statusNode) { + return; + } + statusNode.textContent = message; + statusNode.style.color = pass ? '#237804' : '#cf1322'; +}; + +const getActiveWorkSheet = (sheetInstance: VTableSheet) => sheetInstance.getActiveSheet(); + +const getCellValue = (sheetInstance: VTableSheet, col: number, row: number) => { + const worksheet = getActiveWorkSheet(sheetInstance); + return worksheet.tableInstance?.getCellValue(col, row); +}; + +const getFormulaValue = (sheetInstance: VTableSheet, col: number, row: number) => + sheetInstance.formulaManager.getCellValue({ + sheet: SHEET_KEY, + row, + col + }).value; + +const collectDependents = ( + sheetInstance: VTableSheet, + cell: { sheet: string; row: number; col: number }, + visited = new Set() +): Array<{ sheet: string; row: number; col: number }> => { + const cellKey = `${cell.sheet}!${cell.row},${cell.col}`; + if (visited.has(cellKey)) { + return []; + } + visited.add(cellKey); + + const directDependents = sheetInstance.formulaManager.getCellDependents(cell); + return directDependents.reduce>( + (dependents, dependent) => dependents.concat(dependent, collectDependents(sheetInstance, dependent, visited)), + [] + ); +}; + +const syncDependentsToTable = (sheetInstance: VTableSheet, col: number, row: number) => { + const worksheet = getActiveWorkSheet(sheetInstance); + const dependents = collectDependents(sheetInstance, { + sheet: SHEET_KEY, + row, + col + }); + + dependents.forEach(dependent => { + const result = sheetInstance.formulaManager.getCellValue(dependent); + worksheet.tableInstance?.changeCellValue( + dependent.col, + dependent.row, + result.error ? '#ERROR!' : result.value, + false, + false + ); + }); +}; + +const setCell = (sheetInstance: VTableSheet, col: number, row: number, value: unknown) => { + const worksheet = getActiveWorkSheet(sheetInstance); + sheetInstance.formulaManager.setCellContent( + { + sheet: SHEET_KEY, + row, + col + }, + value + ); + + if (typeof value === 'string' && value.startsWith('=')) { + const result = sheetInstance.formulaManager.getCellValue({ + sheet: SHEET_KEY, + row, + col + }); + worksheet.tableInstance?.changeCellValue(col, row, result.error ? '#ERROR!' : result.value, false, false); + } else { + worksheet.tableInstance?.changeCellValue(col, row, value, false, false); + } + + syncDependentsToTable(sheetInstance, col, row); +}; + +export function createTable() { + removeDemoToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '760px'; + container.style.height = '420px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5234Toolbar'; + toolbar.style.cssText = ['display: flex', 'gap: 8px', 'align-items: center', 'height: 48px', 'font-size: 12px'].join( + ';' + ); + toolbar.innerHTML = ` + + + + 公式:C2=B2,D2=C2+B3;预期最终 C2=5、D2=25。 + + `; + container.before(toolbar); + + const sheetInstance = new VTableSheet(container, { + showFormulaBar: true, + showSheetTab: true, + defaultRowHeight: 36, + defaultColWidth: 120, + sheets: [ + { + sheetKey: SHEET_KEY, + sheetTitle: 'Issue 5234', + active: true, + rowCount: 12, + columnCount: 8, + showHeader: false, + data: [ + ['A', 'B', 'C', 'D'], + ['', 1, '', ''], + ['', 10, '', ''] + ] + } + ] + }); + + const resetFormulaChain = () => { + setCell(sheetInstance, 1, 1, 1); // B2 + setCell(sheetInstance, 1, 2, 10); // B3 + setCell(sheetInstance, 2, 1, '=B2'); // C2 + setCell(sheetInstance, 3, 1, '=C2+B3'); // D2 + + const c2 = getCellValue(sheetInstance, 2, 1); + const d2 = getCellValue(sheetInstance, 3, 1); + setStatus(`READY | C2=${String(c2)}, D2=${String(d2)}`, true); + }; + + const changeB3 = () => { + setCell(sheetInstance, 1, 2, 20); + const d2 = getCellValue(sheetInstance, 3, 1); + setStatus(`STEP1 | B3=20, D2=${String(d2)},预期 21`, d2 === 21); + }; + + const changeB2AndCheck = () => { + setCell(sheetInstance, 1, 1, 5); + + const c2 = getCellValue(sheetInstance, 2, 1); + const d2 = getCellValue(sheetInstance, 3, 1); + const engineC2 = getFormulaValue(sheetInstance, 2, 1); + const engineD2 = getFormulaValue(sheetInstance, 3, 1); + const pass = c2 === 5 && d2 === 25 && engineC2 === 5 && engineD2 === 25; + + setStatus( + `${pass ? 'PASS' : 'FAIL'} | table C2=${String(c2)}, D2=${String(d2)}; engine C2=${String(engineC2)}, D2=${String( + engineD2 + )}; 预期 C2=5, D2=25`, + pass + ); + }; + + document.getElementById('issue5234Reset')?.addEventListener('click', resetFormulaChain); + document.getElementById('issue5234ChangeB3')?.addEventListener('click', changeB3); + document.getElementById('issue5234ChangeB2')?.addEventListener('click', changeB2AndCheck); + + resetFormulaChain(); + + const release = sheetInstance.release.bind(sheetInstance); + sheetInstance.release = () => { + removeDemoToolbar(); + release(); + }; + + (window as any).sheetInstance = sheetInstance; +} diff --git a/packages/vtable-sheet/package.json b/packages/vtable-sheet/package.json index 18708a86c9..93fb7abfde 100644 --- a/packages/vtable-sheet/package.json +++ b/packages/vtable-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-sheet", - "version": "1.26.5", + "version": "1.26.6", "description": "Lightweight editable spreadsheet component based on VTable", "keywords": [ "vtable-sheet", @@ -50,9 +50,9 @@ "build:es": "bundle --clean -f es --ignorePostTasks" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", - "@visactor/vtable-editors": "workspace:1.26.5", - "@visactor/vtable-plugins": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-plugins": "workspace:1.26.6", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-sheet/src/core/WorkSheet.ts b/packages/vtable-sheet/src/core/WorkSheet.ts index 0473123dea..e5c8c16a33 100644 --- a/packages/vtable-sheet/src/core/WorkSheet.ts +++ b/packages/vtable-sheet/src/core/WorkSheet.ts @@ -48,6 +48,14 @@ type WorkSheetUpdateOptions = Pick< theme?: TYPES.VTableThemes.ITableThemeDefine; }; +type WorkSheetColumn = IWorkSheetOptions['columns'][number] & { + columns?: IWorkSheetOptions['columns']; + children?: IWorkSheetOptions['columns']; +}; + +const getChildColumns = (column: IWorkSheetOptions['columns'][number]): IWorkSheetOptions['columns'] | undefined => + (column as WorkSheetColumn).columns ?? (column as WorkSheetColumn).children; + const normalizeColumnsField = (columns: IWorkSheetOptions['columns'], startFieldIndex = 0): number => { if (!columns?.length) { return startFieldIndex; @@ -56,8 +64,7 @@ const normalizeColumnsField = (columns: IWorkSheetOptions['columns'], startField let fieldIndex = startFieldIndex; columns.forEach(column => { - const childColumns = (column as IWorkSheetOptions['columns'][number] & { columns?: IWorkSheetOptions['columns'] }) - .columns; + const childColumns = getChildColumns(column); if (childColumns?.length) { fieldIndex = normalizeColumnsField(childColumns, fieldIndex); @@ -76,6 +83,87 @@ const normalizeColumnsField = (columns: IWorkSheetOptions['columns'], startField return fieldIndex; }; +const collectLeafColumns = (columns: IWorkSheetOptions['columns'] = []): IWorkSheetOptions['columns'] => { + const leafColumns: IWorkSheetOptions['columns'] = []; + + columns.forEach(column => { + const childColumns = getChildColumns(column); + if (childColumns?.length) { + leafColumns.push(...collectLeafColumns(childColumns)); + return; + } + leafColumns.push(column); + }); + + return leafColumns; +}; + +const hasObjectFieldColumn = (columns: IWorkSheetOptions['columns'] = []): boolean => + columns.some(column => { + const childColumns = getChildColumns(column); + if (childColumns?.length) { + return hasObjectFieldColumn(childColumns); + } + return isValid(column.field) && typeof column.field !== 'number'; + }); + +const getAddRecordRule = (options: IWorkSheetOptions): ListTableConstructorOptions['addRecordRule'] => { + if (options.addRecordRule) { + return options.addRecordRule; + } + + const hasObjectRecord = options.data?.some(record => record && typeof record === 'object' && !Array.isArray(record)); + return hasObjectRecord || hasObjectFieldColumn(options.columns) ? 'Object' : 'Array'; +}; + +const getValueByField = (record: Record, field: any): any => { + if (Array.isArray(field)) { + return field.reduce((value, key) => value?.[key], record); + } + if (typeof field === 'string') { + if (Object.prototype.hasOwnProperty.call(record, field)) { + return record[field]; + } + return field.split('.').reduce((value, key) => value?.[key], record); + } + return record[field]; +}; + +const setValueByField = (record: Record, field: any, value: any): void => { + if (Array.isArray(field)) { + let target = record; + field.forEach((key, index) => { + if (index === field.length - 1) { + target[key] = value; + return; + } + if (typeof target[key] !== 'object' || target[key] === null) { + target[key] = {}; + } + target = target[key]; + }); + return; + } + + if (typeof field === 'string' && !Object.prototype.hasOwnProperty.call(record, field) && field.includes('.')) { + const keys = field.split('.'); + let target = record; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + target[key] = value; + return; + } + if (typeof target[key] !== 'object' || target[key] === null) { + target[key] = {}; + } + target = target[key]; + }); + return; + } + + record[field] = value; +}; + export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { /** 选项 */ options: IWorkSheetOptions; @@ -333,12 +421,13 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { showCopyCellBorder: true, cutSelected: true }; + const addRecordRule = getAddRecordRule(this.options); return { ...(this.options as any), dragOrder: { maintainArrayDataOrder: true }, - addRecordRule: 'Array', + addRecordRule, syncRecordOperationsToSourceRecords: true, defaultCursor: 'cell', records: this.options.data, @@ -787,9 +876,66 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { return this.options.data || []; } + private getLeafColumns(): IWorkSheetOptions['columns'] { + return collectLeafColumns(this.options.columns); + } + + private getDataCellByTableCell(col: number, row: number): { col: number; row: number } | null { + const bodyIndex = this.tableInstance?.getBodyIndexByTableIndex?.(col, row) ?? { col, row }; + if (bodyIndex.col < 0 || bodyIndex.row < 0) { + return null; + } + return bodyIndex; + } + + private getDataCellValue(col: number, row: number): any { + const rowData = this.getData()[row]; + if (Array.isArray(rowData)) { + return rowData[col]; + } + if (rowData && typeof rowData === 'object') { + const field = this.getLeafColumns()[col]?.field; + if (isValid(field)) { + return getValueByField(rowData as Record, field); + } + } + return undefined; + } + + private getDataColumnCount(rowData: any): number { + return Array.isArray(rowData) ? rowData.length : this.getLeafColumns().length; + } + + private setDataCellValue(col: number, row: number, value: any, tableCol?: number, tableRow?: number): void { + const rowData = this.getData()[row]; + if (Array.isArray(rowData)) { + rowData[col] = value; + } else if (rowData && typeof rowData === 'object') { + const field = this.getLeafColumns()[col]?.field; + if (isValid(field)) { + setValueByField(rowData as Record, field, value); + } + } + + if (this.tableInstance) { + const tableIndex = + isValid(tableCol) && isValid(tableRow) + ? { col: tableCol, row: tableRow } + : this.tableInstance.getTableIndexByBodyIndex(col, row); + this.tableInstance.changeCellValue(tableIndex.col, tableIndex.row, value); + } + } + getCopiedData(): any[][] { // 为了避免影响当前数据,所以需要复制一份数据 - return this.getData().map(row => (Array.isArray(row) ? row.slice() : [])); + const leafColumns = this.getLeafColumns(); + return this.getData().map(row => + Array.isArray(row) + ? row.slice() + : leafColumns.map(column => + row && typeof row === 'object' ? getValueByField(row as Record, column.field) : undefined + ) + ); } /** * 获取指定坐标的单元格值 @@ -810,7 +956,8 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { if (Array.isArray(rowData) && rowData[col] !== undefined) { return rowData[col]; } - return null; + const dataCell = this.getDataCellByTableCell(col, row); + return dataCell ? this.getDataCellValue(dataCell.col, dataCell.row) ?? null : null; } /** * 获取指定坐标的单元格值 @@ -848,14 +995,9 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { * @param value 新值 */ setCellValue(col: number, row: number, value: any): void { - const rowData = this.getData()[row]; - if (Array.isArray(rowData)) { - rowData[col] = value; - - // 更新表格实例 - if (this.tableInstance) { - this.tableInstance.changeCellValue(col, row, value); - } + const dataCell = this.getDataCellByTableCell(col, row); + if (dataCell) { + this.setDataCellValue(dataCell.col, dataCell.row, value, col, row); } } @@ -1074,7 +1216,6 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { return []; } - const data = this.getData(); const result: string[][] = []; // 获取第一个选择范围 @@ -1087,8 +1228,9 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { for (let col = 0; col < cols; col++) { const actualRow = selection.startRow + row; const actualCol = selection.startCol + col; + const dataCell = this.getDataCellByTableCell(actualCol, actualRow); - if (data[actualRow] && data[actualRow][actualCol] !== undefined) { + if (dataCell && this.getData()[dataCell.row]) { // 如果是公式,返回公式字符串;否则返回值 if ( this.vtableSheet.formulaManager.isCellFormula({ @@ -1104,7 +1246,7 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { }); rowData.push(formula); } else { - rowData.push(data[actualRow][actualCol]); + rowData.push(this.getDataCellValue(dataCell.col, dataCell.row) ?? ''); } } else { rowData.push(''); @@ -1149,15 +1291,18 @@ export class WorkSheet implements IWorkSheetAPI, IWorksheetEventSource { for (let col = 0; col < processedData[row].length; col++) { const targetRow = targetStartRow + row; const targetCol = targetStartCol + col; + const dataCell = this.getDataCellByTableCell(targetCol, targetRow); + const rowData = dataCell ? dataArray[dataCell.row] : undefined; + const columnCount = this.getDataColumnCount(rowData); - if (targetRow < dataArray.length && targetCol < dataArray[targetRow].length) { + if (rowData && dataCell && dataCell.col < columnCount) { const value = processedData[row][col]; // 如果是公式,设置公式;否则设置普通值 if (FormulaPasteProcessor.needsFormulaAdjustment(value)) { this.setCellFormula(targetRow, targetCol, value as string); } else { - this.setCellValue(targetRow, targetCol, value); + this.setDataCellValue(dataCell.col, dataCell.row, value, targetCol, targetRow); } } } diff --git a/packages/vtable-sheet/src/core/table-plugins.ts b/packages/vtable-sheet/src/core/table-plugins.ts index 6b1fca355b..322d6470c9 100644 --- a/packages/vtable-sheet/src/core/table-plugins.ts +++ b/packages/vtable-sheet/src/core/table-plugins.ts @@ -77,7 +77,8 @@ export function getTablePlugins( } else { const addRowColumnPlugin = new AddRowColumnPlugin({ addRowCallback: (row: number, tableInstance: VTable.ListTable) => { - tableInstance.addRecord([], row - tableInstance.columnHeaderLevelCount); + const record = tableInstance.options.addRecordRule === 'Object' ? {} : []; + tableInstance.addRecord(record, row - tableInstance.columnHeaderLevelCount); }, ...userPluginOptions }); diff --git a/packages/vtable-sheet/src/formula/formula-engine.ts b/packages/vtable-sheet/src/formula/formula-engine.ts index 52e17f9b97..b0a470651a 100644 --- a/packages/vtable-sheet/src/formula/formula-engine.ts +++ b/packages/vtable-sheet/src/formula/formula-engine.ts @@ -2487,11 +2487,6 @@ export class FormulaEngine { private recalculateDependentsWithTracking(changedCell: FormulaCell, visited: Set): void { const cellKey = this.getCellKey(changedCell); - // 防止循环依赖导致的无限递归 - if (visited.has(cellKey)) { - return; - } - const dependents = this.dependents.get(cellKey); if (!dependents || dependents.size === 0) { @@ -2502,6 +2497,9 @@ export class FormulaEngine { const sortedDependents = this.sortCellsByDependency([...dependents]); for (const dependentKey of sortedDependents) { + if (visited.has(dependentKey)) { + continue; + } this.recalculateSingleCellWithTracking(dependentKey, visited); } } diff --git a/packages/vtable/CHANGELOG.json b/packages/vtable/CHANGELOG.json index de275ffbc7..f481544ad1 100644 --- a/packages/vtable/CHANGELOG.json +++ b/packages/vtable/CHANGELOG.json @@ -1,6 +1,35 @@ { "name": "@visactor/vtable", "entries": [ + { + "version": "1.26.6", + "tag": "@visactor/vtable_v1.26.6", + "date": "Mon, 03 Aug 2026 11:18:03 GMT", + "comments": { + "patch": [ + { + "comment": "fix: refresh rows when updateRecords is called without record indexes" + }, + { + "comment": "fix: refresh functional icons after theme updates" + }, + { + "comment": "fix: allow plugins to initialize before first render" + }, + { + "comment": "fix: support updating checkbox state by record index" + } + ], + "none": [ + { + "comment": "fix: hide row series number in aggregation rows" + }, + { + "comment": "fix: delete reverse-selected rows from context menu" + } + ] + } + }, { "version": "1.26.5", "tag": "@visactor/vtable_v1.26.5", diff --git a/packages/vtable/CHANGELOG.md b/packages/vtable/CHANGELOG.md index 21d1c62770..1020fe84cb 100644 --- a/packages/vtable/CHANGELOG.md +++ b/packages/vtable/CHANGELOG.md @@ -1,6 +1,21 @@ # Change Log - @visactor/vtable -This log was last generated on Wed, 15 Jul 2026 10:41:26 GMT and should not be manually modified. +This log was last generated on Mon, 03 Aug 2026 11:18:03 GMT and should not be manually modified. + +## 1.26.6 +Mon, 03 Aug 2026 11:18:03 GMT + +### Patches + +- fix: refresh rows when updateRecords is called without record indexes +- fix: refresh functional icons after theme updates +- fix: allow plugins to initialize before first render +- fix: support updating checkbox state by record index + +### Updates + +- fix: hide row series number in aggregation rows +- fix: delete reverse-selected rows from context menu ## 1.26.5 Wed, 15 Jul 2026 10:41:26 GMT diff --git a/packages/vtable/__tests__/functional-icons-theme.test.ts b/packages/vtable/__tests__/functional-icons-theme.test.ts new file mode 100644 index 0000000000..97dba2dc48 --- /dev/null +++ b/packages/vtable/__tests__/functional-icons-theme.test.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import * as VTable from '../src'; +import { createDiv, removeDom } from './dom'; + +global.__VERSION__ = 'none'; + +describe('functional icon theme updates', () => { + let container: HTMLElement; + let table: VTable.ListTable; + + const columns = [{ field: 'name', title: 'Name', sort: true, tree: true }]; + const records = [{ name: 'Parent', children: [{ name: 'Child' }] }]; + + const createTable = () => { + container = createDiv(); + container.style.width = '600px'; + container.style.height = '400px'; + table = new VTable.ListTable(container, { + columns, + records, + rowSeriesNumber: { dragOrder: true }, + theme: { + functionalIconsStyle: { + sort_color: '#111111', + collapse_color: '#222222', + dragReorder_color: '#333333' + } + } + }); + }; + + afterEach(() => { + table?.release(); + removeDom(container); + }); + + test('refreshes functional icons after updateOption', () => { + createTable(); + + table.updateOption({ + columns, + records, + rowSeriesNumber: { dragOrder: true }, + theme: { + functionalIconsStyle: { + sort_color: '#123456', + collapse_color: '#234567', + dragReorder_color: '#345678' + } + } + }); + + expect(table.internalProps.headerHelper.normalIcon.svg).toContain('#123456'); + expect(table.internalProps.bodyHelper.collapseIcon.svg).toContain('#234567'); + expect(table.internalProps.rowSeriesNumberHelper.dragReorderIconName.svg).toContain('#345678'); + }); + + test('refreshes functional icons after updateTheme', () => { + createTable(); + + table.updateTheme({ + functionalIconsStyle: { + sort_color: '#456789', + collapse_color: '#56789a', + dragReorder_color: '#6789ab' + } + }); + + expect(table.internalProps.headerHelper.normalIcon.svg).toContain('#456789'); + expect(table.internalProps.bodyHelper.collapseIcon.svg).toContain('#56789a'); + expect(table.internalProps.rowSeriesNumberHelper.dragReorderIconName.svg).toContain('#6789ab'); + }); + + test('refreshes functional icons after setting theme', () => { + createTable(); + + table.theme = { + functionalIconsStyle: { + sort_color: '#789abc', + collapse_color: '#89abcd', + dragReorder_color: '#9abcde' + } + }; + + expect(table.internalProps.headerHelper.normalIcon.svg).toContain('#789abc'); + expect(table.internalProps.bodyHelper.collapseIcon.svg).toContain('#89abcd'); + expect(table.internalProps.rowSeriesNumberHelper.dragReorderIconName.svg).toContain('#9abcde'); + }); +}); diff --git a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts new file mode 100644 index 0000000000..6cfa42c657 --- /dev/null +++ b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts @@ -0,0 +1,163 @@ +// @ts-nocheck +import { ListTable, TYPES } from '../src'; +import { createDiv } from './dom'; + +global.__VERSION__ = 'none'; + +describe('ListTable checkbox record index api', () => { + let table: ListTable; + + afterEach(() => { + table?.release(); + document.body.innerHTML = ''; + }); + + test('sets collapsed tree child checkbox state by record index path', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'task', + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox' + } + ], + records: [ + { + task: { text: 'parent', checked: true }, + hierarchyState: TYPES.HierarchyState.collapse, + children: [{ task: { text: 'child', checked: true } }] + } + ], + enableCheckboxCascade: false + }); + const renderAsync = jest.spyOn(table, 'renderAsync').mockImplementation(() => Promise.resolve()); + + table.setCellCheckboxStateByRecordIndex([0, 0], 'task', false); + + expect(table.stateManager.checkedState.get('0,0').task).toBe(false); + expect(renderAsync).toHaveBeenCalledTimes(1); + }); + + test('clears all checkbox states for a field', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'task', + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox' + } + ], + records: [ + { + task: { text: 'parent', checked: true }, + hierarchyState: TYPES.HierarchyState.collapse, + children: [{ task: { text: 'child', checked: true } }] + }, + { task: { text: 'sibling', checked: true } } + ], + enableCheckboxCascade: false + }); + const renderAsync = jest.spyOn(table, 'renderAsync').mockImplementation(() => Promise.resolve()); + + table.clearAllCheckboxState('task'); + + expect(table.stateManager.checkedState.get('0').task).toBe(false); + expect(table.stateManager.checkedState.get('0,0').task).toBe(false); + expect(table.stateManager.checkedState.get('1').task).toBe(false); + expect(table.stateManager.headerCheckedState.task).toBe(false); + expect(renderAsync).toHaveBeenCalledTimes(1); + }); + + test('keeps header indeterminate when checkedState is sparse', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'task', + title: 'Task', + cellType: 'checkbox', + headerType: 'checkbox' + } + ], + records: [{ task: { text: 'checked', checked: true } }, { task: { text: 'unchecked' } }], + enableCheckboxCascade: false + }); + table.stateManager.checkedState.delete('1'); + + table.setCellCheckboxStateByRecordIndex(0, 'task', true); + + expect(table.stateManager.headerCheckedState.task).toBe('indeterminate'); + }); + + test('does not create false state for records that are already unchecked when clearing', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'task', + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox' + } + ], + records: [ + { + task: { text: 'parent', checked: true }, + hierarchyState: TYPES.HierarchyState.collapse, + children: [{ task: { text: 'child', checked: true } }] + }, + { task: { text: 'already unchecked', checked: false } } + ], + enableCheckboxCascade: false + }); + table.stateManager.checkedState.delete('1'); + + table.clearAllCheckboxState('task'); + + expect(table.stateManager.checkedState.get('0').task).toBe(false); + expect(table.stateManager.checkedState.get('0,0').task).toBe(false); + expect(table.stateManager.checkedState.has('1')).toBe(false); + }); + + test('updates children and parent state when cascade is enabled', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'task', + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox' + } + ], + records: [ + { + task: { text: 'parent', checked: true }, + hierarchyState: TYPES.HierarchyState.collapse, + children: [{ task: { text: 'child 1', checked: true } }, { task: { text: 'child 2', checked: true } }] + } + ], + enableCheckboxCascade: true + }); + + table.setCellCheckboxStateByRecordIndex([0, 0], 'task', false); + + expect(table.stateManager.checkedState.get('0').task).toBe('indeterminate'); + expect(table.stateManager.checkedState.get('0,0').task).toBe(false); + expect(table.stateManager.checkedState.get('0,1').task).toBe(true); + + table.setCellCheckboxStateByRecordIndex(0, 'task', false); + + expect(table.stateManager.checkedState.get('0').task).toBe(false); + expect(table.stateManager.checkedState.get('0,0').task).toBe(false); + expect(table.stateManager.checkedState.get('0,1').task).toBe(false); + }); +}); 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 4a243da287..c6081a0e07 100644 --- a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts +++ b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts @@ -143,6 +143,168 @@ describe('listTable init test', () => { }); }); + test('listTable bottom frozen rows should stay connected after short body content', () => { + const optionWithBottomFrozenRows = { + ...option, + frozenRowCount: 5, + rightFrozenColCount: 0, + bottomFrozenRowCount: 2, + container: createDiv(), + records: records.slice(0, 10) + }; + optionWithBottomFrozenRows.container.style.position = 'relative'; + optionWithBottomFrozenRows.container.style.width = '1000px'; + optionWithBottomFrozenRows.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithBottomFrozenRows); + const { scenegraph } = frozenTable; + + expect(scenegraph.bottomFrozenGroup.attribute.y).toBe( + scenegraph.bodyGroup.attribute.y + scenegraph.bodyGroup.attribute.height + ); + expect(scenegraph.rightBottomCornerGroup.attribute.visible).toBe(false); + + frozenTable.release(); + }); + + test('listTable bottom left corner should be hidden without frozen columns', () => { + const optionWithOnlyBottomFrozenRows = { + ...option, + frozenColCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 2, + container: createDiv(), + records: records.slice(0, 10) + }; + optionWithOnlyBottomFrozenRows.container.style.position = 'relative'; + optionWithOnlyBottomFrozenRows.container.style.width = '1000px'; + optionWithOnlyBottomFrozenRows.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithOnlyBottomFrozenRows); + + expect(frozenTable.scenegraph.leftBottomCornerGroup.attribute.visible).toBe(false); + expect(frozenTable.scenegraph.leftBottomCornerGroup.attribute.width).toBe(0); + expect(frozenTable.scenegraph.rightBottomCornerGroup.attribute.visible).toBe(false); + + frozenTable.release(); + }); + + test('listTable bottom right corner should stay connected after short content', () => { + const shortColumns = columns.slice(0, 5).map(column => ({ + ...column, + width: 100 + })); + const optionWithBottomRightFrozen = { + ...option, + columns: shortColumns, + defaultColWidth: 100, + frozenColCount: 2, + frozenRowCount: 5, + rightFrozenColCount: 2, + bottomFrozenRowCount: 2, + containerFit: true, + container: createDiv(), + records: records.slice(0, 10) + }; + optionWithBottomRightFrozen.container.style.position = 'relative'; + optionWithBottomRightFrozen.container.style.width = '1000px'; + optionWithBottomRightFrozen.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithBottomRightFrozen); + const { scenegraph } = frozenTable; + + expect(scenegraph.rightFrozenGroup.attribute.x).toBe( + scenegraph.bodyGroup.attribute.x + scenegraph.bodyGroup.attribute.width + ); + expect(scenegraph.rightBottomCornerGroup.attribute.x).toBe(scenegraph.rightFrozenGroup.attribute.x); + expect(scenegraph.rightBottomCornerGroup.attribute.y).toBe( + scenegraph.bodyGroup.attribute.y + scenegraph.bodyGroup.attribute.height + ); + + frozenTable.release(); + }); + + test('listTable right frozen header should stay on the right side without records', () => { + const optionWithRightFrozenHeader = { + ...option, + frozenColCount: 2, + rightFrozenColCount: 2, + bottomFrozenRowCount: 0, + container: createDiv(), + records: [] + }; + optionWithRightFrozenHeader.container.style.position = 'relative'; + optionWithRightFrozenHeader.container.style.width = '1000px'; + optionWithRightFrozenHeader.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithRightFrozenHeader); + const { scenegraph } = frozenTable; + const rightFrozenColsWidth = frozenTable.getRightFrozenColsWidth(); + + expect(scenegraph.rightTopCornerGroup.attribute.visible).toBe(true); + expect(scenegraph.rightFrozenGroup.attribute.x).toBe(scenegraph.tableGroup.attribute.width - rightFrozenColsWidth); + expect(scenegraph.rightTopCornerGroup.attribute.x).toBe(scenegraph.rightFrozenGroup.attribute.x); + + frozenTable.release(); + }); + + test('listTable bottom corner rows should stay below header when body is empty', () => { + const twoColumns = columns.slice(0, 2).map(column => ({ + ...column, + width: 150 + })); + const optionWithOnlyCornerRows = { + ...option, + columns: twoColumns, + frozenColCount: 1, + rightFrozenColCount: 1, + bottomFrozenRowCount: 1, + container: createDiv(), + records: records.slice(0, 1) + }; + optionWithOnlyCornerRows.container.style.position = 'relative'; + optionWithOnlyCornerRows.container.style.width = '1000px'; + optionWithOnlyCornerRows.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithOnlyCornerRows); + const { scenegraph } = frozenTable; + const headerBottom = Math.max( + scenegraph.cornerHeaderGroup.attribute.y + scenegraph.cornerHeaderGroup.attribute.height, + scenegraph.rightTopCornerGroup.attribute.y + scenegraph.rightTopCornerGroup.attribute.height + ); + + expect(scenegraph.bodyGroup.attribute.height).toBe(0); + expect(scenegraph.leftBottomCornerGroup.attribute.y).toBe(headerBottom); + expect(scenegraph.rightBottomCornerGroup.attribute.y).toBe(headerBottom); + + frozenTable.release(); + }); + + test('listTable bottom corner rows should stay below right frozen body when only right frozen column exists', () => { + const optionWithOnlyRightFrozenColumn = { + ...option, + columns: columns.slice(0, 1), + frozenColCount: 0, + rightFrozenColCount: 1, + bottomFrozenRowCount: 3, + container: createDiv(), + records: records.slice(0, 4) + }; + optionWithOnlyRightFrozenColumn.container.style.position = 'relative'; + optionWithOnlyRightFrozenColumn.container.style.width = '1000px'; + optionWithOnlyRightFrozenColumn.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithOnlyRightFrozenColumn); + const { scenegraph } = frozenTable; + const rightFrozenBottom = scenegraph.rightFrozenGroup.attribute.y + scenegraph.rightFrozenGroup.attribute.height; + + expect(scenegraph.bodyGroup.attribute.height).toBe(0); + expect(scenegraph.rightFrozenGroup.attribute.height).toBeGreaterThan(0); + expect(scenegraph.rightBottomCornerGroup.attribute.y).toBe(rightFrozenBottom); + + frozenTable.release(); + }); + test('listTable should support decreasing rightFrozenColCount by setter with row series number', () => { const optionWithRightFrozen = { ...option, @@ -165,5 +327,62 @@ describe('listTable init test', () => { rightFrozenTable.rightFrozenColCount = 1; }).not.toThrow(); expect(rightFrozenTable.rightFrozenColCount).toBe(1); + expect(rightFrozenTable.scenegraph.rightBottomCornerGroup.attribute.visible).toBe(false); + expect(rightFrozenTable.scenegraph.rightBottomCornerGroup.attribute.width).toBe(0); + }); + + test('listTable bottom corner groups should be reset after clearing bottom frozen rows', () => { + const optionWithBottomFrozenRows = { + ...option, + frozenColCount: 2, + rightFrozenColCount: 2, + bottomFrozenRowCount: 2, + container: createDiv(), + records: records.slice(0, 10) + }; + optionWithBottomFrozenRows.container.style.position = 'relative'; + optionWithBottomFrozenRows.container.style.width = '1000px'; + optionWithBottomFrozenRows.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithBottomFrozenRows); + + expect(frozenTable.scenegraph.leftBottomCornerGroup.attribute.visible).toBe(true); + expect(frozenTable.scenegraph.rightBottomCornerGroup.attribute.visible).toBe(true); + + frozenTable.bottomFrozenRowCount = 0; + + expect(frozenTable.scenegraph.leftBottomCornerGroup.attribute.visible).toBe(false); + expect(frozenTable.scenegraph.leftBottomCornerGroup.attribute.height).toBe(0); + expect(frozenTable.scenegraph.rightBottomCornerGroup.attribute.visible).toBe(false); + expect(frozenTable.scenegraph.rightBottomCornerGroup.attribute.width).toBe(0); + expect(frozenTable.scenegraph.rightBottomCornerGroup.attribute.height).toBe(0); + + frozenTable.release(); + }); + + test('listTable right top corner group should be reset after clearing frozen rows', () => { + const optionWithRightFrozenRows = { + ...option, + frozenRowCount: 5, + bottomFrozenRowCount: 0, + rightFrozenColCount: 2, + container: createDiv(), + records + }; + optionWithRightFrozenRows.container.style.position = 'relative'; + optionWithRightFrozenRows.container.style.width = '1000px'; + optionWithRightFrozenRows.container.style.height = '800px'; + + const frozenTable = new ListTable(optionWithRightFrozenRows); + + expect(frozenTable.scenegraph.rightTopCornerGroup.attribute.visible).toBe(true); + + frozenTable.frozenRowCount = 0; + + expect(frozenTable.scenegraph.rightTopCornerGroup.attribute.visible).toBe(false); + expect(frozenTable.scenegraph.rightTopCornerGroup.attribute.width).toBe(0); + expect(frozenTable.scenegraph.rightTopCornerGroup.attribute.height).toBe(0); + + frozenTable.release(); }); }); diff --git a/packages/vtable/__tests__/options/listTable-celltype-function.test.ts b/packages/vtable/__tests__/options/listTable-celltype-function.test.ts new file mode 100644 index 0000000000..a20a51888d --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-celltype-function.test.ts @@ -0,0 +1,40 @@ +import { ListTable } from '../../src'; +import { createDiv, removeDom } from '../dom'; + +(global as any).__VERSION__ = 'none'; + +describe('ListTable cellType function', () => { + let containerDom: HTMLElement; + + beforeEach(() => { + containerDom = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '300px'; + containerDom.style.height = '200px'; + }); + + afterEach(() => { + removeDom(containerDom); + }); + + test('falls back to text when cellType function returns undefined', () => { + const table = new ListTable(containerDom, { + records: [{ name: 'A' }], + columns: [ + { + field: 'name', + title: 'Name', + cellType: ((): any => undefined) as any + } + ] + }); + + const bodyRow = table.columnHeaderLevelCount; + + expect(table.getCellType(0, bodyRow)).toBe('text'); + expect(table.getBodyColumnType(0, bodyRow)).toBe('text'); + expect(table.getCellValue(0, bodyRow)).toBe('A'); + + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/options/listTable-row-series-number-aggregation.test.ts b/packages/vtable/__tests__/options/listTable-row-series-number-aggregation.test.ts new file mode 100644 index 0000000000..188afc9223 --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-row-series-number-aggregation.test.ts @@ -0,0 +1,39 @@ +// @ts-nocheck +import { ListTable, TYPES } from '../../src'; +import { createDiv } from '../dom'; + +global.__VERSION__ = 'none'; + +describe('ListTable row series number with aggregation', () => { + const container = createDiv(); + container.style.width = '600px'; + container.style.height = '400px'; + + const table = new ListTable(container, { + records: [{ value: 1 }, { value: 2 }], + columns: [ + { + field: 'value', + title: 'Value', + aggregation: { + aggregationType: TYPES.AggregationType.SUM + } + } + ], + rowSeriesNumber: { + title: 'No.' + } + }); + + afterAll(() => { + table.release(); + }); + + test('does not display a series number in the aggregation row', () => { + const aggregationRow = table.rowCount - 1; + + expect(table.internalProps.layoutMap.isAggregation(0, aggregationRow)).toBe(true); + expect(table.getCellValue(0, aggregationRow)).toBe(''); + expect(table.getCellOriginValue(0, aggregationRow)).toBe(''); + }); +}); diff --git a/packages/vtable/examples/debug/issue-4761-update-records-edit-render.ts b/packages/vtable/examples/debug/issue-4761-update-records-edit-render.ts new file mode 100644 index 0000000000..372b176081 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4761-update-records-edit-render.ts @@ -0,0 +1,75 @@ +import * as VTable from '../../src'; +import { InputEditor } from '@visactor/vtable-editors'; + +const CONTAINER_ID = 'vTable'; +const inputEditor = new InputEditor({}); +VTable.register.editor('input', inputEditor); + +const records = [ + { name: 'John', age: 20 }, + { name: 'Jane', age: 21 } +]; + +const createStatusBar = () => { + const container = document.getElementById(CONTAINER_ID)!; + const status = document.createElement('div'); + status.id = 'issue4761Status'; + status.style.cssText = 'height: 32px; line-height: 32px; font-size: 13px; color: #333;'; + status.textContent = 'Click "Check updateRecords render" to verify issue #4761.'; + + const button = document.createElement('button'); + button.textContent = 'Check updateRecords render'; + button.style.cssText = 'margin: 0 0 8px 8px;'; + button.onclick = () => checkUpdateRecordsRender(); + + container.parentElement?.insertBefore(status, container); + status.appendChild(button); +}; + +const getRenderedCellText = (tableInstance: VTable.ListTable, col: number, row: number) => { + const cellGroup = tableInstance.scenegraph.getCell(col, row); + const texts: string[] = []; + cellGroup?.forEachChildren((child: any) => { + const text = child?.attribute?.text; + if (typeof text === 'string') { + texts.push(text); + } else if (Array.isArray(text)) { + texts.push(text.join('')); + } + }); + return texts.join(''); +}; + +const checkUpdateRecordsRender = async () => { + const tableInstance = (window as any).tableInstance as VTable.ListTable; + const status = document.getElementById('issue4761Status')!; + records[0].name = 'aaa'; + tableInstance.updateRecords(records); + + await new Promise(resolve => requestAnimationFrame(resolve)); + + const renderedText = getRenderedCellText(tableInstance, 0, tableInstance.columnHeaderLevelCount); + const dataValue = tableInstance.getCellValue(0, tableInstance.columnHeaderLevelCount); + const pass = renderedText.includes('aaa') && dataValue === 'aaa'; + status.textContent = `${pass ? 'PASS' : 'FAIL'} | rendered=${renderedText}, data=${dataValue}`; + return status.textContent; +}; + +export function createTable() { + const option: VTable.ListTableConstructorOptions = { + records, + columns: [ + { field: 'name', title: 'First Name', width: 180, editor: 'input' }, + { field: 'age', title: 'Age', width: 120, editor: 'input' } + ], + editCellTrigger: 'doubleclick', + editor: 'input', + widthMode: 'standard', + defaultRowHeight: 36 + }; + + createStatusBar(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, option); + (window as any).tableInstance = tableInstance; + (window as any).issue4761Run = checkUpdateRecordsRender; +} diff --git a/packages/vtable/examples/debug/issue-4798-sort-icon-visible-time.ts b/packages/vtable/examples/debug/issue-4798-sort-icon-visible-time.ts new file mode 100644 index 0000000000..ae2ceb79ac --- /dev/null +++ b/packages/vtable/examples/debug/issue-4798-sort-icon-visible-time.ts @@ -0,0 +1,110 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const sortIconBase = { + type: 'text' as const, + width: 16, + height: 16, + funcType: VTable.TYPES.IconFuncTypeEnum.sort, + positionType: VTable.TYPES.IconPosition.absoluteRight, + marginRight: 8, + cursor: 'pointer' +}; + +function registerSortIcons() { + VTable.register.icon('sort_normal', { + ...sortIconBase, + name: 'sort_normal', + content: '-', + visibleTime: 'mouseenter_cell', + style: { + fill: '#999', + fontWeight: 'bold' + } + }); + VTable.register.icon('sort_upward', { + ...sortIconBase, + name: 'sort_upward', + content: '^', + visibleTime: 'always', + style: { + fill: '#1677ff', + fontWeight: 'bold' + } + }); + VTable.register.icon('sort_downward', { + ...sortIconBase, + name: 'sort_downward', + content: 'v', + visibleTime: 'always', + style: { + fill: '#f5222d', + fontWeight: 'bold' + } + }); +} + +function getSortIconState(tableInstance: VTable.ListTable, col: number) { + let state: any = null; + tableInstance.scenegraph.getCell(col, 0).forEachChildren((mark: any) => { + if (mark.attribute?.funcType === VTable.TYPES.IconFuncTypeEnum.sort) { + state = { + name: mark.name, + text: mark.attribute.text, + fill: mark.attribute.fill, + visibleTime: mark.attribute.visibleTime, + opacity: mark.attribute.opacity + }; + } + }); + return state; +} + +export function createTable() { + registerSortIcons(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '600px'; + container.style.height = '360px'; + + const tableInstance = new VTable.ListTable({ + container, + records: [ + { id: 1, name: 'Alice', score: 91 }, + { id: 2, name: 'Bob', score: 85 }, + { id: 3, name: 'Carol', score: 96 } + ], + columns: [ + { field: 'id', title: 'ID', width: 120, sort: true }, + { field: 'name', title: 'Name', width: 200, sort: true }, + { field: 'score', title: 'Score', width: 120 } + ] + }); + + window.tableInstance = tableInstance; + (window as any).issue4798GetSortIconState = (col = 0) => getSortIconState(tableInstance, col); + (window as any).issue4798Run = () => { + tableInstance.updateSortState({ field: 'id', order: 'asc' }); + const firstAsc = getSortIconState(tableInstance, 0); + tableInstance.updateSortState({ field: 'name', order: 'asc' }); + const firstNormalAfterSecondSort = getSortIconState(tableInstance, 0); + const secondAsc = getSortIconState(tableInstance, 1); + return { + firstAsc, + firstNormalAfterSecondSort, + secondAsc, + fixed: + firstNormalAfterSecondSort?.name === 'sort_normal' && + firstNormalAfterSecondSort?.text === '-' && + firstNormalAfterSecondSort?.fill === '#999' && + firstNormalAfterSecondSort?.visibleTime === 'mouseenter_cell' && + firstNormalAfterSecondSort?.opacity === 0 && + secondAsc?.name === 'sort_upward' && + secondAsc?.text === '^' && + secondAsc?.fill === '#1677ff' && + secondAsc?.visibleTime === 'always' && + secondAsc?.opacity === 1 + }; + }; +} diff --git a/packages/vtable/examples/debug/issue-4810-edit-cell-double-click-blank.ts b/packages/vtable/examples/debug/issue-4810-edit-cell-double-click-blank.ts new file mode 100644 index 0000000000..0ca7934ad5 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4810-edit-cell-double-click-blank.ts @@ -0,0 +1,73 @@ +import * as VTable from '../../src'; +import { InputEditor } from '@visactor/vtable-editors'; + +const CONTAINER_ID = 'vTable'; +const inputEditor = new InputEditor({}); +VTable.register.editor('issue4810-input', inputEditor); + +const createStatusBar = () => { + const container = document.getElementById(CONTAINER_ID)!; + const status = document.createElement('div'); + status.id = 'issue4810Status'; + status.style.cssText = 'height: 32px; line-height: 32px; font-size: 13px; color: #333;'; + status.textContent = 'Click "Check double edit" to verify issue #4810.'; + + const button = document.createElement('button'); + button.textContent = 'Check double edit'; + button.style.cssText = 'margin: 0 0 8px 8px;'; + button.onclick = () => checkDoubleEdit(); + + container.parentElement?.insertBefore(status, container); + status.appendChild(button); +}; + +const checkDoubleEdit = () => { + const tableInstance = (window as any).tableInstance as VTable.ListTable; + const status = document.getElementById('issue4810Status')!; + + try { + tableInstance.startEditCell(0, tableInstance.columnHeaderLevelCount); + inputEditor.getInputElement()?.remove(); + (tableInstance.editorManager as any).editingEditor = null; + tableInstance.startEditCell(1, tableInstance.columnHeaderLevelCount); + } catch (err) { + status.textContent = `FAIL | ${(err as Error).message}`; + return status.textContent; + } + + const inputElement = inputEditor.getInputElement(); + const tableElement = tableInstance.getElement(); + const pass = + !!inputElement && + tableElement.contains(inputElement) && + inputElement.style.opacity === '1' && + inputElement.style.pointerEvents === 'auto' && + inputElement.style.left !== ''; + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | mounted=${ + !!inputElement && tableElement.contains(inputElement) + }, left=${inputElement?.style.left}, top=${inputElement?.style.top}`; + return status.textContent; +}; + +export function createTable() { + const option: VTable.ListTableConstructorOptions = { + records: [ + { name: 'Alice', age: 20 }, + { name: 'Bob', age: 21 } + ], + columns: [ + { field: 'name', title: 'Name', width: 180, editor: 'issue4810-input' }, + { field: 'age', title: 'Age', width: 120, editor: 'issue4810-input' } + ], + editCellTrigger: 'doubleclick', + editor: 'issue4810-input', + widthMode: 'standard', + defaultRowHeight: 36 + }; + + createStatusBar(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, option); + (window as any).tableInstance = tableInstance; + (window as any).issue4810Run = checkDoubleEdit; +} diff --git a/packages/vtable/examples/debug/issue-4816-functional-icons-theme.ts b/packages/vtable/examples/debug/issue-4816-functional-icons-theme.ts new file mode 100644 index 0000000000..64c49a8eea --- /dev/null +++ b/packages/vtable/examples/debug/issue-4816-functional-icons-theme.ts @@ -0,0 +1,133 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const columns: VTable.ColumnsDefine = [ + { + field: 'name', + title: 'Name', + width: 240, + sort: true, + tree: true + } +]; + +const records = [ + { + name: 'Parent', + children: [{ name: 'Child' }] + } +]; + +const initialIconColors = { + sort_color: '#111111', + collapse_color: '#222222', + dragReorder_color: '#333333' +}; + +const updatedIconColors = { + sort_color: '#123456', + collapse_color: '#234567', + dragReorder_color: '#345678' +}; + +const removeToolbar = () => { + document.getElementById('issue4816Toolbar')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const status = document.getElementById('issue4816Status'); + if (!status) { + return; + } + status.textContent = message; + status.style.color = pass ? '#237804' : '#cf1322'; +}; + +const getIconSvgState = (tableInstance: VTable.ListTable) => { + const internalProps = (tableInstance as any).internalProps; + return { + sort: internalProps.headerHelper.normalIcon.svg, + collapse: internalProps.bodyHelper.collapseIcon.svg, + drag: internalProps.rowSeriesNumberHelper.dragReorderIconName.svg + }; +}; + +const includesColors = (iconState: ReturnType, colors: typeof updatedIconColors) => + iconState.sort.includes(colors.sort_color) && + iconState.collapse.includes(colors.collapse_color) && + iconState.drag.includes(colors.dragReorder_color); + +export function createTable() { + removeToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '640px'; + container.style.height = '360px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue4816Toolbar'; + toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;height:48px;font-size:12px;'; + toolbar.innerHTML = ` + + + 预期:updateOption 后 sort/collapse/drag 图标 SVG 都使用新颜色。 + + `; + container.before(toolbar); + + const createOption = (functionalIconsStyle: typeof initialIconColors): VTable.ListTableConstructorOptions => ({ + columns, + records, + rowSeriesNumber: { dragOrder: true }, + theme: { + functionalIconsStyle + } + }); + + const tableInstance = new VTable.ListTable(container, createOption(initialIconColors)); + + const checkInitial = () => { + const iconState = getIconSvgState(tableInstance); + const pass = includesColors(iconState, initialIconColors); + const sortMatched = iconState.sort.includes(initialIconColors.sort_color); + const collapseMatched = iconState.collapse.includes(initialIconColors.collapse_color); + const dragMatched = iconState.drag.includes(initialIconColors.dragReorder_color); + + setStatus( + `${pass ? 'PASS' : 'FAIL'} | initial sort=${sortMatched}, collapse=${collapseMatched}, drag=${dragMatched}`, + pass + ); + }; + + const updateAndCheck = () => { + tableInstance.updateOption(createOption(updatedIconColors)); + const iconState = getIconSvgState(tableInstance); + const pass = includesColors(iconState, updatedIconColors); + const sortMatched = iconState.sort.includes(updatedIconColors.sort_color); + const collapseMatched = iconState.collapse.includes(updatedIconColors.collapse_color); + const dragMatched = iconState.drag.includes(updatedIconColors.dragReorder_color); + + setStatus( + `${pass ? 'PASS' : 'FAIL'} | updated sort=${sortMatched}, collapse=${collapseMatched}, drag=${dragMatched}`, + pass + ); + }; + + document.getElementById('issue4816CheckInitial')?.addEventListener('click', checkInitial); + document.getElementById('issue4816Update')?.addEventListener('click', updateAndCheck); + + checkInitial(); + + (window as any).tableInstance = tableInstance; + (window as any).issue4816Run = () => { + updateAndCheck(); + return document.getElementById('issue4816Status')?.textContent; + }; + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + removeToolbar(); + release(); + }; +} diff --git a/packages/vtable/examples/debug/issue-4904-frozen-row-gap.ts b/packages/vtable/examples/debug/issue-4904-frozen-row-gap.ts new file mode 100644 index 0000000000..22884137d5 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4904-frozen-row-gap.ts @@ -0,0 +1,90 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const generatePersons = (count: number) => { + return Array.from(new Array(count)).map((_, i) => ({ + id: i + 1, + email1: `${i + 1}@xxx.com`, + name: `小明${i + 1}`, + lastName: '王', + date1: '2022年9月1日', + tel: '000-0000-0000', + sex: i % 2 === 0 ? 'boy' : 'girl', + work: i % 2 === 0 ? 'back-end engineer' : 'front-end engineer', + city: 'beijing' + })); +}; + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '800px'; + container.style.height = '800px'; + + const records = generatePersons(10); + const columns: VTable.ColumnsDefine = [ + { + field: 'id', + title: 'ID', + width: 120, + sort: true + }, + { + field: 'email1', + title: 'email', + width: 200, + sort: true + }, + { + title: 'full name', + columns: [ + { + field: 'name', + title: 'First Name', + width: 200 + }, + { + field: 'lastName', + title: 'Last Name', + width: 200 + } + ] + }, + { + field: 'date1', + title: 'birthday', + width: 200 + }, + { + field: 'sex', + title: 'sex', + width: 100 + }, + { + field: 'tel', + title: 'telephone', + width: 150 + }, + { + field: 'work', + title: 'job', + width: 200 + }, + { + field: 'city', + title: 'city', + width: 150 + } + ]; + const option: VTable.ListTableConstructorOptions = { + container, + records, + columns, + frozenRowCount: 5, + bottomFrozenRowCount: 2, + allowFrozenColCount: 2 + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/debug/issue-5115-auto-height-zero-row.ts b/packages/vtable/examples/debug/issue-5115-auto-height-zero-row.ts new file mode 100644 index 0000000000..ca1cde9a6c --- /dev/null +++ b/packages/vtable/examples/debug/issue-5115-auto-height-zero-row.ts @@ -0,0 +1,127 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const filteredChildren = Array.from({ length: 300 }, (_, index) => ({ + name: `filtered child ${index + 1}`, + status: 'visible before filter', + desc: 'This row is hidden after filter interaction', + _show: true +})); +const visibleChildren = Array.from({ length: 40 }, (_, index) => ({ + name: `visible child ${index + 1}`, + status: 'visible after filter', + desc: `Visible tree row ${index + 1} should fill the viewport after filtered zero-height rows`, + _show: true +})); +const records = [ + { + name: 'expanded group filtered by interaction', + status: 'group', + desc: 'Children below become _show=false after simulated filter interaction', + _show: true, + hierarchyState: 'expand', + children: filteredChildren + }, + { + name: 'expanded group with visible children', + status: 'group', + desc: 'These rows should be pulled into first screen after zero-height rows', + _show: true, + hierarchyState: 'expand', + children: visibleChildren + } +]; + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + document.getElementById('issue5115Toolbar')?.remove(); + container.style.width = '720px'; + container.style.height = '420px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5115Toolbar'; + toolbar.style.cssText = 'height: 48px; font-size: 12px; display: flex; gap: 12px; align-items: center;'; + toolbar.innerHTML = ` + + + + `; + container.before(toolbar); + + const option: VTable.ListTableConstructorOptions = { + container, + records, + columns: [ + { field: 'name', title: 'Name', tree: true, width: 260 }, + { field: 'status', title: 'Status', width: 180 }, + { field: 'desc', title: 'Description', width: 260 } + ], + widthMode: 'standard', + heightMode: 'autoHeight', + defaultRowHeight: 40, + hierarchyIndent: 20, + hierarchyExpandLevel: 2, + customComputeRowHeight: ({ row, table }) => { + const record = table.getCellOriginRecord(0, row); + return record && record._show === false ? 0 : 'auto'; + } + }; + const tableInstance = new VTable.ListTable(option); + + const check = () => { + const proxy = tableInstance.scenegraph.proxy; + const bodyStart = tableInstance.frozenRowCount; + const bodyHeight = + tableInstance.tableNoFrameHeight - + tableInstance.getFrozenRowsHeight() - + tableInstance.getBottomFrozenRowsHeight(); + const renderedHeight = tableInstance.getRowsHeight(bodyStart, proxy.rowEnd); + const firstFilteredRowHeight = tableInstance.getRowHeight(tableInstance.columnHeaderLevelCount + 1); + const proxyRowsSynced = + proxy.totalRow >= proxy.rowEnd && proxy.totalActualBodyRowCount >= proxy.rowEnd - proxy.rowStart + 1; + const pass = firstFilteredRowHeight === 0 && renderedHeight >= bodyHeight && proxyRowsSynced; + const state = document.getElementById('issue5115State')!; + state.textContent = + `${pass ? 'PASS' : 'FAIL'} | firstFilteredRowHeight=${firstFilteredRowHeight} renderedHeight=${renderedHeight} ` + + `bodyHeight=${bodyHeight} rowEnd=${proxy.rowEnd} totalRow=${proxy.totalRow}`; + return { + pass, + firstFilteredRowHeight, + renderedHeight, + bodyHeight, + rowEnd: proxy.rowEnd, + totalRow: proxy.totalRow, + totalActualBodyRowCount: proxy.totalActualBodyRowCount, + proxyRowsSynced + }; + }; + + const filterRows = () => { + filteredChildren.forEach(record => { + record._show = false; + record.status = 'hidden by _show=false'; + }); + tableInstance.updateOption(option, { clearRowHeightCache: true, clearColWidthCache: false }); + }; + + const toggleCheck = async () => { + tableInstance.toggleHierarchyState(0, 1, false); + await new Promise(resolve => setTimeout(resolve, 60)); + tableInstance.toggleHierarchyState(0, 1, false); + await new Promise(resolve => setTimeout(resolve, 120)); + return check(); + }; + + document.getElementById('issue5115Check')!.addEventListener('click', check); + document.getElementById('issue5115ToggleCheck')!.addEventListener('click', toggleCheck); + + window.tableInstance = tableInstance; + (window as any).issue5115Check = check; + (window as any).issue5115ToggleCheck = toggleCheck; + + setTimeout(() => { + filterRows(); + setTimeout(check, 0); + }, 0); +} diff --git a/packages/vtable/examples/debug/issue-5117-auto-height-real-height.ts b/packages/vtable/examples/debug/issue-5117-auto-height-real-height.ts new file mode 100644 index 0000000000..c9384317c9 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5117-auto-height-real-height.ts @@ -0,0 +1,70 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const records = Array.from({ length: 60 }, (_, index) => ({ + id: index + 1, + name: `row-${index + 1}`, + desc: + index % 3 === 0 + ? 'long text long text long text long text long text long text long text long text long text' + : 'short' +})); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + document.getElementById('issue5117Toolbar')?.remove(); + container.style.width = '760px'; + container.style.height = '420px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5117Toolbar'; + toolbar.style.cssText = 'height: 48px; font-size: 12px; display: flex; gap: 12px; align-items: center;'; + toolbar.innerHTML = ` + + + `; + container.before(toolbar); + + const realHeights: Array = []; + const minRowHeight = 64; + const tableInstance = new VTable.ListTable({ + container, + records, + columns: [ + { field: 'id', title: 'ID', width: 80 }, + { field: 'name', title: 'Name', width: 120 }, + { + field: 'desc', + title: 'Description', + width: 280, + style: { + autoWrapText: true + } + } + ], + widthMode: 'standard', + heightMode: 'autoHeight', + customComputeRowHeight: ({ row, realHeight }) => { + realHeights[row] = realHeight; + return Math.max(realHeight ?? 0, minRowHeight); + } + }); + + const check = () => { + const bodyRow = tableInstance.columnHeaderLevelCount; + const realHeight = realHeights[bodyRow]; + const rowHeight = tableInstance.getRowHeight(bodyRow); + const pass = typeof realHeight === 'number' && rowHeight === Math.max(realHeight, minRowHeight); + const state = document.getElementById('issue5117State')!; + state.textContent = `${pass ? 'PASS' : 'FAIL'} | realHeight=${realHeight} rowHeight=${rowHeight}`; + return { pass, realHeight, rowHeight }; + }; + + document.getElementById('issue5117Check')!.addEventListener('click', check); + + window.tableInstance = tableInstance; + (window as any).issue5117Check = check; + + setTimeout(check, 0); +} diff --git a/packages/vtable/examples/debug/issue-5213-row-series-number-aggregation.ts b/packages/vtable/examples/debug/issue-5213-row-series-number-aggregation.ts new file mode 100644 index 0000000000..42efd2690d --- /dev/null +++ b/packages/vtable/examples/debug/issue-5213-row-series-number-aggregation.ts @@ -0,0 +1,84 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const removeDemoToolbar = () => { + document.getElementById('issue5213Toolbar')?.remove(); +}; + +const setStatus = (message: string, pass: boolean) => { + const statusNode = document.getElementById('issue5213Status'); + if (!statusNode) { + return; + } + statusNode.textContent = message; + statusNode.style.color = pass ? '#237804' : '#cf1322'; +}; + +export function createTable() { + removeDemoToolbar(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '640px'; + container.style.height = '360px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue5213Toolbar'; + toolbar.style.cssText = [ + 'display: flex', + 'gap: 8px', + 'align-items: center', + 'height: 40px', + 'font-size: 12px' + ].join(';'); + toolbar.innerHTML = ` + + 预期:聚合行序号列为空;旧逻辑会显示行号。 + + `; + container.before(toolbar); + + const tableInstance = new VTable.ListTable(container, { + records: [{ value: 1 }, { value: 2 }], + columns: [ + { + field: 'value', + title: 'Value', + width: 160, + aggregation: { + aggregationType: VTable.TYPES.AggregationType.SUM + } + } + ], + rowSeriesNumber: { + title: 'No.' + }, + defaultRowHeight: 36 + }); + + const checkAggregationSeriesNumber = () => { + const aggregationRow = tableInstance.rowCount - 1; + const displayValue = tableInstance.getCellValue(0, aggregationRow); + const originValue = tableInstance.getCellOriginValue(0, aggregationRow); + const isAggregation = tableInstance.internalProps.layoutMap.isAggregation(0, aggregationRow); + const pass = isAggregation && displayValue === '' && originValue === ''; + + setStatus( + `${pass ? 'PASS' : 'FAIL'} | aggregationRow=${aggregationRow}, display=${String(displayValue)}, origin=${String( + originValue + )}`, + pass + ); + }; + + document.getElementById('issue5213Check')?.addEventListener('click', checkAggregationSeriesNumber); + requestAnimationFrame(checkAggregationSeriesNumber); + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + removeDemoToolbar(); + release(); + }; + + (window as any).tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/debug/issue-celltype-function-undefined.ts b/packages/vtable/examples/debug/issue-celltype-function-undefined.ts new file mode 100644 index 0000000000..37475b9f16 --- /dev/null +++ b/packages/vtable/examples/debug/issue-celltype-function-undefined.ts @@ -0,0 +1,34 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + const status = document.createElement('div'); + status.id = 'issueCellTypeUndefinedStatus'; + status.style.cssText = 'height: 32px; line-height: 32px; font-size: 13px; color: #333;'; + container.parentElement?.insertBefore(status, container); + + const option: VTable.ListTableConstructorOptions = { + records: [{ name: 'A' }], + columns: [ + { + field: 'name', + title: 'Name', + cellType: () => undefined + } + ] + }; + + try { + const tableInstance = new VTable.ListTable(container, option); + (window as any).tableInstance = tableInstance; + const cellType = tableInstance.getCellType(0, tableInstance.columnHeaderLevelCount); + status.textContent = `PASS | cellType=${cellType}, value=${tableInstance.getCellValue( + 0, + tableInstance.columnHeaderLevelCount + )}`; + } catch (err) { + status.textContent = `FAIL | ${(err as Error).message}`; + } +} diff --git a/packages/vtable/examples/list/issue-5211-tree-checkbox-record-index.ts b/packages/vtable/examples/list/issue-5211-tree-checkbox-record-index.ts new file mode 100644 index 0000000000..5c8de24968 --- /dev/null +++ b/packages/vtable/examples/list/issue-5211-tree-checkbox-record-index.ts @@ -0,0 +1,79 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const records = [ + { + task: { text: 'Project A', checked: true }, + owner: 'Alice', + status: 'collapsed parent', + hierarchyState: VTable.TYPES.HierarchyState.collapse, + children: [ + { + task: { text: 'Hidden task A-1', checked: true }, + owner: 'Bob', + status: 'hidden child' + }, + { + task: { text: 'Hidden task A-2', checked: true }, + owner: 'Cindy', + status: 'hidden child' + } + ] + }, + { + task: { text: 'Project B', checked: true }, + owner: 'David', + status: 'root' + } + ]; + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records, + columns: [ + { + field: 'task', + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox', + width: 260 + }, + { field: 'owner', title: 'Owner', width: 120 }, + { field: 'status', title: 'Status', width: 180 } + ], + defaultRowHeight: 38, + hierarchyIndent: 20, + enableCheckboxCascade: false, + enableHeaderCheckboxCascade: false, + theme: VTable.themes.BRIGHT + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; + + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'position:absolute;top:8px;left:8px;z-index:10;display:flex;gap:8px;'; + + const setHiddenChildButton = document.createElement('button'); + setHiddenChildButton.textContent = 'Uncheck hidden child [0,0]'; + setHiddenChildButton.onclick = () => { + tableInstance.setCellCheckboxStateByRecordIndex([0, 0], 'task', false); + console.log('checkbox state after set hidden child:', tableInstance.getCheckboxState('task')); + }; + + const clearAllButton = document.createElement('button'); + clearAllButton.textContent = 'Clear task checkbox field'; + clearAllButton.onclick = () => { + tableInstance.clearAllCheckboxState('task'); + console.log('checkbox state after clear:', tableInstance.getCheckboxState('task')); + }; + + toolbar.appendChild(setHiddenChildButton); + toolbar.appendChild(clearAllButton); + document.body.appendChild(toolbar); + + return tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5f040cb70a..6fcdf4ccf7 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -34,6 +34,14 @@ export const menus = [ path: 'debug', name: 'issue-5114' }, + { + path: 'debug', + name: 'issue-5115-auto-height-zero-row' + }, + { + path: 'debug', + name: 'issue-5117-auto-height-real-height' + }, { path: 'debug', name: 'issue-5146' @@ -50,6 +58,34 @@ export const menus = [ path: 'debug', name: 'issue-5187-max-frozen-width' }, + { + path: 'debug', + name: 'issue-5213-row-series-number-aggregation' + }, + { + path: 'debug', + name: 'issue-4761-update-records-edit-render' + }, + { + path: 'debug', + name: 'issue-4810-edit-cell-double-click-blank' + }, + { + path: 'debug', + name: 'issue-4816-functional-icons-theme' + }, + { + path: 'debug', + name: 'issue-4904-frozen-row-gap' + }, + { + path: 'debug', + name: 'issue-4798-sort-icon-visible-time' + }, + { + path: 'debug', + name: 'issue-celltype-function-undefined' + }, { path: 'debug', name: 'header-frame-border-null-color' @@ -107,6 +143,10 @@ export const menus = [ path: 'list', name: 'list-checkbox-tree' }, + { + path: 'list', + name: 'issue-5211-tree-checkbox-record-index' + }, { path: 'list', name: 'list-tree-20000' diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 59b0bd3de2..8a2db09c06 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable", - "version": "1.26.5", + "version": "1.26.6", "description": "canvas table width high performance", "keywords": [ "grid", @@ -52,7 +52,7 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable-editors": "workspace:1.26.5", + "@visactor/vtable-editors": "workspace:1.26.6", "@visactor/vrender": "1.1.4", "@visactor/vrender-core": "1.1.4", "@visactor/vrender-kits": "1.1.4", diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index c11e7b0610..4c56dd313c 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -39,7 +39,12 @@ import type { IEditor } from '@visactor/vtable-editors'; import type { ColumnData, ColumnDefine, HeaderData } from './ts-types/list-table/layout-map/api'; import { getCellRadioState, setCellRadioState } from './state/radio/radio'; import { cloneDeepSpec } from '@visactor/vutils-extension'; -import { getGroupCheckboxState, setCellCheckboxState } from './state/checkbox/checkbox'; +import { + clearCheckboxState, + getGroupCheckboxState, + setCellCheckboxState, + setCheckboxStateByRecordIndex +} from './state/checkbox/checkbox'; import type { IEmptyTipComponent } from './components/empty-tip/empty-tip'; import { Factory } from './core/factory'; import { getGroupByDataConfig } from './core/group-helper'; @@ -590,6 +595,9 @@ export class ListTable extends BaseTable implements ListTableAPI { const { title } = table.internalProps.layoutMap.getSeriesNumberHeader(col, row); return title; } + if (table.internalProps.layoutMap.isAggregation(col, row)) { + return ''; + } let value; if ((this.internalProps as ListTableProtected).groupBy) { const record = table.getCellRawRecord(col, row); @@ -640,6 +648,9 @@ export class ListTable extends BaseTable implements ListTableAPI { const { title } = table.internalProps.layoutMap.getSeriesNumberHeader(col, row); return title; } + if (table.internalProps.layoutMap.isAggregation(col, row)) { + return ''; + } const { format } = table.internalProps.layoutMap.getSeriesNumberBody(col, row); return typeof format === 'function' ? format(col, row, this) : row - this.columnHeaderLevelCount; } else if (table.internalProps.layoutMap.isHeader(col, row)) { @@ -772,7 +783,7 @@ export class ListTable extends BaseTable implements ListTableAPI { ) { const internalProps = this.internalProps; - this.pluginManager.removeOrAddPlugins(options.plugins); + this.pluginManager.removeOrAddPlugins(options.plugins, options); super.updateOption(options, updateConfig); internalProps.frozenColDragHeaderMode = options.dragOrder?.frozenColDragHeaderMode ?? options.frozenColDragHeaderMode; @@ -1562,6 +1573,19 @@ export class ListTable extends BaseTable implements ListTableAPI { setCellCheckboxState(col: number, row: number, checked: boolean | 'indeterminate') { setCellCheckboxState(col, row, checked, this); } + setCellCheckboxStateByRecordIndex( + recordIndex: number | number[], + field: FieldDef, + checked: boolean | 'indeterminate' + ) { + setCheckboxStateByRecordIndex(recordIndex, field, checked, this); + } + clearCheckboxState(field: FieldDef) { + clearCheckboxState(field, this); + } + clearAllCheckboxState(field: FieldDef) { + this.clearCheckboxState(field); + } setCellRadioState(col: number, row: number, index?: number) { setCellRadioState(col, row, index, this); } @@ -2416,14 +2440,14 @@ export class ListTable extends BaseTable implements ListTableAPI { * 基本表格中显示在body中的索引,即要修改的是body部分的第几行数据; * 如果是树形结构的话 recordIndexs 为数组,数组中每个元素为data的原始数据索引; */ - updateRecords(records: any[], recordIndexs: (number | number[])[], triggerEvent = true) { - listTableUpdateRecords(records, recordIndexs, this); + updateRecords(records: any[], recordIndexs?: (number | number[])[], triggerEvent = true) { + const updateRecordIndexs = recordIndexs ?? records?.map((_, index) => index) ?? []; + listTableUpdateRecords(records, updateRecordIndexs, this); - // 触发更新数据记录事件 - 假设操作成功 if (triggerEvent) { this.fireListeners(TABLE_EVENT_TYPE.UPDATE_RECORD, { records, - recordIndexs, + recordIndexs: updateRecordIndexs, updateCount: records.length }); } diff --git a/packages/vtable/src/body-helper/body-helper.ts b/packages/vtable/src/body-helper/body-helper.ts index 9047a93878..9a5ab4e6b9 100644 --- a/packages/vtable/src/body-helper/body-helper.ts +++ b/packages/vtable/src/body-helper/body-helper.ts @@ -19,6 +19,10 @@ export class BodyHelper { _table: BaseTableAPI; constructor(_table: BaseTableAPI) { this._table = _table; + this.updateIcons(); + } + + updateIcons() { const regedIcons = registerIcons.get(); //展开折叠按钮 this.expandIcon = regedIcons[InternalIconName.expandIconName] as SvgIcon; diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 7230e26571..31c4ed898c 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -168,6 +168,10 @@ const { isTouchEvent } = event; const rangeReg = /^\$(\d+)\$(\d+)$/; importStyle(); +function normalizeCellType(cellType: ColumnTypeOption | undefined | null): ColumnTypeOption { + return isValid(cellType) ? cellType : 'text'; +} + export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps: IBaseTableProtected; showFrozenIcon = true; @@ -503,7 +507,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps.focusedTable = false; internalProps.theme = themes.of(options.theme ?? themes.DEFAULT); //原来在listTable文件中 internalProps.theme.isPivot = this.isPivotTable(); - setIconColor(internalProps.theme.functionalIconsStyle); + this._updateFunctionalIcons(); if (container) { // 先清空 if (clearDOM) { @@ -2938,7 +2942,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps.theme = themes.of(options.theme ?? themes.DEFAULT); internalProps.theme.isPivot = this.isPivotTable(); - setIconColor(internalProps.theme.functionalIconsStyle); + this._updateFunctionalIcons(); this.scenegraph.updateStageBackground(); // this._updateSize(); //设置是否自动撑开的配置 @@ -3783,6 +3787,12 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { /** * 获取当前使用的主题 */ + private _updateFunctionalIcons() { + setIconColor(this.internalProps.theme.functionalIconsStyle); + this.internalProps.headerHelper?.updateIcons(); + this.internalProps.bodyHelper?.updateIcons(); + this.internalProps.rowSeriesNumberHelper?.updateIcons(); + } get theme(): TableTheme { return this.internalProps.theme; } @@ -3790,7 +3800,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { this.internalProps.theme = themes.of(theme ?? themes.DEFAULT); this.internalProps.theme.isPivot = this.isPivotTable(); this.options.theme = theme; - setIconColor(this.internalProps.theme.functionalIconsStyle); + this._updateFunctionalIcons(); } /** * 设置主题 @@ -3799,7 +3809,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { const oldHoverState = { col: this.stateManager.hover.cellPos.col, row: this.stateManager.hover.cellPos.row }; this.internalProps.theme = themes.of(theme ?? themes.DEFAULT); this.internalProps.theme.isPivot = this.isPivotTable(); - setIconColor(this.internalProps.theme.functionalIconsStyle); + this._updateFunctionalIcons(); this.options.theme = theme; this.scenegraph.updateComponent(); this.scenegraph.updateStageBackground(); @@ -3832,7 +3842,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { getBodyColumnType(col: number, row: number): ColumnTypeOption { const cellType = this.internalProps.layoutMap.getBody(col, row)?.cellType ?? 'text'; - return getProp('cellType', { cellType }, col, row, this); + return normalizeCellType(getProp('cellType', { cellType }, col, row, this)); } getCellType(col: number, row: number): ColumnTypeOption { @@ -3842,13 +3852,13 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { col, row ).cellType; - return seriesHeaderCellType === 'radio' ? 'text' : seriesHeaderCellType; + return normalizeCellType(seriesHeaderCellType === 'radio' ? 'text' : seriesHeaderCellType); } else if (this.isHeader(col, row)) { cellType = (this.internalProps.layoutMap.getHeader(col, row) as HeaderData).headerType; } else { cellType = this.internalProps.layoutMap.getBody(col, row).cellType; } - return getProp('cellType', { cellType }, col, row, this); + return normalizeCellType(getProp('cellType', { cellType }, col, row, this)); } /** diff --git a/packages/vtable/src/core/row-series-number-helper.ts b/packages/vtable/src/core/row-series-number-helper.ts index da83ab292b..3975a5d3e6 100644 --- a/packages/vtable/src/core/row-series-number-helper.ts +++ b/packages/vtable/src/core/row-series-number-helper.ts @@ -17,6 +17,10 @@ export class RowSeriesNumberHelper { _table: BaseTableAPI; constructor(_table: BaseTableAPI) { this._table = _table; + this.updateIcons(); + } + + updateIcons() { const regedIcons = registerIcons.get(); this.dragReorderIconName = regedIcons[InternalIconName.dragReorderIconName] as SvgIcon; diff --git a/packages/vtable/src/data/CachedDataSource.ts b/packages/vtable/src/data/CachedDataSource.ts index 03e0552055..2dec3e5885 100644 --- a/packages/vtable/src/data/CachedDataSource.ts +++ b/packages/vtable/src/data/CachedDataSource.ts @@ -274,6 +274,9 @@ export class CachedDataSource extends DataSource { continue; } const originRecordIndex = this.getOriginRecordIndexForGroup(recordIndex); + if (!isValid(originRecordIndex)) { + continue; + } this.beforeChangedRecordsMap.delete(originRecordIndex.toString()); this.dataSourceObj.records.splice(originRecordIndex, 1); @@ -292,6 +295,9 @@ export class CachedDataSource extends DataSource { continue; } const originRecordIndex = this.getOriginRecordIndexForGroup(recordIndex); + if (!isValid(originRecordIndex)) { + continue; + } this.beforeChangedRecordsMap.delete(originRecordIndex.toString()); this.dataSourceObj.records[originRecordIndex] = records[index]; } diff --git a/packages/vtable/src/header-helper/header-helper.ts b/packages/vtable/src/header-helper/header-helper.ts index a448fe0033..4c883780f4 100644 --- a/packages/vtable/src/header-helper/header-helper.ts +++ b/packages/vtable/src/header-helper/header-helper.ts @@ -38,6 +38,10 @@ export class HeaderHelper { _table: BaseTableAPI; constructor(_table: BaseTableAPI) { this._table = _table; + this.updateIcons(); + } + + updateIcons() { const regedIcons = registerIcons.get(); //pin默认值 this.freezeIcon = regedIcons[InternalIconName.freezeIconName] as SvgIcon; diff --git a/packages/vtable/src/plugins/interface.ts b/packages/vtable/src/plugins/interface.ts index 8119b3dfb7..2f7adb9998 100644 --- a/packages/vtable/src/plugins/interface.ts +++ b/packages/vtable/src/plugins/interface.ts @@ -1,5 +1,5 @@ import type { TableEvents } from '../core/TABLE_EVENT_TYPE'; -import type { BaseTableAPI } from '../ts-types/base-table'; +import type { BaseTableAPI, BaseTableConstructorOptions } from '../ts-types/base-table'; // 插件生命周期接口 export interface IVTablePlugin { @@ -16,7 +16,9 @@ export interface IVTablePlugin { runTime: TableEvents[keyof TableEvents][]; // // 插件依赖 // dependencies?: string[]; - // 初始化方法,在VTable实例创建后、首次渲染前调用 + // 初始化方法,在 VTable 实例创建后、首次渲染前调用 + init?: (table: BaseTableAPI, options: BaseTableConstructorOptions) => void; + // 运行方法,在订阅的表格事件触发时调用 run: (...args: any[]) => void; // 更新方法,当表格数据或配置更新时调用 update?: () => void; diff --git a/packages/vtable/src/plugins/plugin-manager.ts b/packages/vtable/src/plugins/plugin-manager.ts index e984eac6df..e6d197ab85 100644 --- a/packages/vtable/src/plugins/plugin-manager.ts +++ b/packages/vtable/src/plugins/plugin-manager.ts @@ -10,8 +10,9 @@ export class PluginManager { constructor(table: BaseTableAPI, options: BaseTableConstructorOptions) { this.table = table; - options.plugins?.map(plugin => { + options.plugins?.forEach(plugin => { this.register(plugin); + plugin.init?.(this.table, options); this._bindTableEventForPlugin(plugin); }); } @@ -44,14 +45,15 @@ export class PluginManager { } // 移除或添加插件 - removeOrAddPlugins(plugins?: IVTablePlugin[]): void { + removeOrAddPlugins(plugins?: IVTablePlugin[], options: BaseTableConstructorOptions = this.table.options): void { // 先找到plugins中没有,但this.plugins中有,也就是已经被移除的插件 const removedPlugins = Array.from(this.plugins.values()).filter(plugin => !plugins?.some(p => p.id === plugin.id)); removedPlugins.forEach(plugin => { this.pluginEventMap.get(plugin.id)?.forEach(id => { this.table.off(id); }); - this.release(); + this.pluginEventMap.delete(plugin.id); + plugin.release?.(this.table); this.plugins.delete(plugin.id); }); @@ -59,6 +61,7 @@ export class PluginManager { const addedPlugins = plugins?.filter(plugin => !this.plugins.has(plugin.id)); addedPlugins?.forEach(plugin => { this.register(plugin); + plugin.init?.(this.table, options); this._bindTableEventForPlugin(plugin); }); } diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 4fcf6d1baa..01b31ddeb1 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -6,6 +6,30 @@ import { computeRowsHeight } from '../../layout/compute-row-height'; import { createColGroup } from '../column'; import type { SceneProxy } from './proxy'; +function fillVisibleBodyRows(proxy: SceneProxy, distRow: number): number { + const { table } = proxy; + const bodyBottomRow = table.rowCount - 1 - table.bottomFrozenRowCount; + const visibleBodyHeight = table.tableNoFrameHeight - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + let targetRow = distRow; + + while (targetRow < bodyBottomRow && table.getRowsHeight(table.frozenRowCount, targetRow) < visibleBodyHeight) { + const nextRow = targetRow + 1; + computeRowsHeight(table, nextRow, nextRow, false); + targetRow = nextRow; + } + + return targetRow; +} + +function syncVisibleBodyRows(proxy: SceneProxy, targetRow: number) { + if (targetRow <= proxy.totalRow) { + return; + } + + proxy.totalRow = targetRow; + proxy.totalActualBodyRowCount = Math.max(proxy.totalActualBodyRowCount, targetRow - proxy.bodyTopRow + 1); +} + export function createGroupForFirstScreen( cornerHeaderGroup: Group, colHeaderGroup: Group, @@ -50,6 +74,7 @@ export function createGroupForFirstScreen( } else { distRow = Math.min(proxy.firstScreenRowLimit - 1, table.rowCount - 1); } + let bodyDistRow = Math.min(proxy.bodyBottomRow, distRow - table.bottomFrozenRowCount); if (table.internalProps._widthResizedColMap.size === 0) { // compute colums width in first screen computeColsWidth(table, 0, distColForCompute ?? distCol); @@ -64,6 +89,10 @@ export function createGroupForFirstScreen( ? table.rowCount - 1 : distRowForCompute ?? distRow ); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 + if (table.heightMode === 'autoHeight') { + bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); + syncVisibleBodyRows(proxy, bodyDistRow); + } } if (distCol < table.colCount - table.rightFrozenColCount) { @@ -128,7 +157,7 @@ export function createGroupForFirstScreen( table.leftRowSeriesNumberCount - 1, // colEnd table.frozenRowCount, // rowStart // Math.min(proxy.firstScreenRowLimit, table.rowCount - 1 - table.bottomFrozenRowCount), // rowEnd - distRow - table.bottomFrozenRowCount, + bodyDistRow, 'rowHeader', // isHeader table ); @@ -142,7 +171,7 @@ export function createGroupForFirstScreen( Math.min(table.frozenColCount - 1, table.rowHeaderLevelCount + table.leftRowSeriesNumberCount - 1), // colEnd table.frozenRowCount, // rowStart // Math.min(proxy.firstScreenRowLimit, table.rowCount - 1 - table.bottomFrozenRowCount), // rowEnd - distRow - table.bottomFrozenRowCount, + bodyDistRow, 'rowHeader', // isHeader table ); @@ -156,7 +185,7 @@ export function createGroupForFirstScreen( table.frozenColCount - 1, // colEnd table.frozenRowCount, // rowStart // Math.min(proxy.firstScreenRowLimit, table.rowCount - 1 - table.bottomFrozenRowCount), // rowEnd - distRow - table.bottomFrozenRowCount, + bodyDistRow, 'body', table ); @@ -275,7 +304,7 @@ export function createGroupForFirstScreen( table.colCount - 1, // colEnd table.frozenRowCount, // rowStart // Math.min(proxy.firstScreenRowLimit, table.rowCount - 1 - table.bottomFrozenRowCount), // rowEnd - distRow - table.bottomFrozenRowCount, + bodyDistRow, table.isPivotChart() ? 'rowHeader' : 'body', // isHeader table ); @@ -307,7 +336,7 @@ export function createGroupForFirstScreen( distCol - table.rightFrozenColCount, table.frozenRowCount, // rowStart // Math.min(proxy.firstScreenRowLimit, table.rowCount - 1 - table.bottomFrozenRowCount), // rowEnd - distRow - table.bottomFrozenRowCount, + bodyDistRow, 'body', // isHeader table ); diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index 067ce3f2fb..dade83a49b 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -352,6 +352,16 @@ export function computeRowsHeight( } export function computeRowHeight(row: number, startCol: number, endCol: number, table: BaseTableAPI): number { + return computeRowHeightInternal(row, startCol, endCol, table, true); +} + +function computeRowHeightInternal( + row: number, + startCol: number, + endCol: number, + table: BaseTableAPI, + enableCustomCompute: boolean +): number { const isAllRowsAuto = table.isAutoRowHeight(row) || (table.heightMode === 'adaptive' && table.options.autoHeightInAdaptiveMode !== false); if (!isAllRowsAuto && table.getDefaultRowHeight(row) !== 'auto') { @@ -359,16 +369,32 @@ export function computeRowHeight(row: number, startCol: number, endCol: number, } let maxHeight; - if (table.options.customComputeRowHeight) { - const customRowHeight = table.options.customComputeRowHeight({ + if (enableCustomCompute && table.options.customComputeRowHeight) { + let realHeight: number; + let hasRealHeight = false; + const getRealHeight = () => { + if (!hasRealHeight) { + realHeight = computeRowHeightInternal(row, startCol, endCol, table, false); + hasRealHeight = true; + } + return realHeight; + }; + const computeArgs = { row, table + } as Parameters>[0]; + Object.defineProperty(computeArgs, 'realHeight', { + get: getRealHeight, + enumerable: false, + configurable: true }); + const customRowHeight = table.options.customComputeRowHeight(computeArgs); if (typeof customRowHeight === 'number') { return customRowHeight; - } else if (customRowHeight !== 'auto') { - return table.getDefaultRowHeight(row) as number; + } else if (customRowHeight === 'auto') { + return getRealHeight(); } + return table.getDefaultRowHeight(row) as number; } if (table.internalProps.rowHeightConfig) { const rowHeightConfig = table.internalProps.rowHeightConfig.find((item: { key: number }) => item.key === row); diff --git a/packages/vtable/src/scenegraph/layout/update-row.ts b/packages/vtable/src/scenegraph/layout/update-row.ts index a2175ff157..610bbcefa5 100644 --- a/packages/vtable/src/scenegraph/layout/update-row.ts +++ b/packages/vtable/src/scenegraph/layout/update-row.ts @@ -7,6 +7,7 @@ import type { Scenegraph } from '../scenegraph'; import { getCellMergeInfo } from '../utils/get-cell-merge'; import { deduplication } from '../../tools/util'; import { checkHaveTextStick, resetTextStick } from '../stick-text'; +import { computeRowsHeight } from './compute-row-height'; /** * add and remove rows in scenegraph @@ -78,6 +79,11 @@ export function updateRow( updateAfter = updateAfter ?? needUpdateAfter; rowHeightsMap.insert(row); }); + const filledVisibleRowStart = fillVisibleBodyRows(scene); + if (isNumber(filledVisibleRowStart)) { + updateAfter = updateAfter ?? filledVisibleRowStart; + rowUpdatePos = isValid(rowUpdatePos) ? Math.min(rowUpdatePos, filledVisibleRowStart) : filledVisibleRowStart; + } // reset attribute y and row number in CellGroup // const newTotalHeight = resetRowNumberAndY(scene); @@ -277,6 +283,40 @@ function addRow(row: number, scene: Scenegraph, skipUpdateProxy?: boolean) { // scene.proxy.rowEnd++; // scene.proxy.currentRow++; } + +function fillVisibleBodyRows(scene: Scenegraph): number | undefined { + const { table, proxy } = scene; + if (table.heightMode !== 'autoHeight') { + return undefined; + } + const bodyBottomRow = table.rowCount - 1 - table.bottomFrozenRowCount; + const visibleBodyHeight = table.tableNoFrameHeight - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + let targetRow = Math.min(proxy.rowEnd, bodyBottomRow); + + computeRowsHeight(table, proxy.rowStart, targetRow, false); + while (targetRow < bodyBottomRow && table.getRowsHeight(table.frozenRowCount, targetRow) < visibleBodyHeight) { + const nextRow = targetRow + 1; + computeRowsHeight(table, nextRow, nextRow, false); + targetRow = nextRow; + } + + if (targetRow <= proxy.rowEnd) { + return undefined; + } + + const startRow = proxy.rowEnd + 1; + for (let row = startRow; row <= targetRow; row++) { + addRowCellGroup(row, scene); + } + proxy.rowEnd = targetRow; + proxy.currentRow = Math.max(proxy.currentRow, targetRow); + proxy.totalRow = Math.max(proxy.totalRow, targetRow); + proxy.totalActualBodyRowCount = Math.max(proxy.totalActualBodyRowCount, targetRow - proxy.rowStart + 1); + proxy.rowUpdatePos = Math.min(proxy.rowUpdatePos, startRow); + + return startRow; +} + function resetRowNumber(scene: Scenegraph) { scene.bodyGroup.forEachChildren((colGroup: Group) => { let rowIndex = scene.bodyRowStart; diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index 0330409fa0..f3dc9a32a8 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -1435,36 +1435,78 @@ export class Scenegraph { } } - if (this.table.bottomFrozenRowCount > 0) { - this.bottomFrozenGroup.setAttribute( - 'y', - this.tableGroup.attribute.height - this.table.getBottomFrozenRowsHeight() + const hasFrozenCols = this.table.frozenColCount > 0; + const hasRightFrozenCols = this.table.rightFrozenColCount > 0; + const hasFrozenRows = this.table.frozenRowCount > 0; + const hasBottomFrozenRows = this.table.bottomFrozenRowCount > 0; + + if (hasBottomFrozenRows) { + const bottomFrozenRowsHeight = this.table.getBottomFrozenRowsHeight(); + const topFrozenBottom = Math.max( + this.colHeaderGroup.attribute.y + this.colHeaderGroup.attribute.height, + this.cornerHeaderGroup.attribute.y + this.cornerHeaderGroup.attribute.height, + this.rightTopCornerGroup.attribute.y + this.rightTopCornerGroup.attribute.height + ); + const middleContentBottom = Math.max( + this.rowHeaderGroup.attribute.y + this.rowHeaderGroup.attribute.height, + this.bodyGroup.attribute.y + this.bodyGroup.attribute.height, + this.rightFrozenGroup.attribute.y + this.rightFrozenGroup.attribute.height, + topFrozenBottom ); + const bottomFrozenY = Math.min(this.tableGroup.attribute.height - bottomFrozenRowsHeight, middleContentBottom); + this.bottomFrozenGroup.setAttribute('y', bottomFrozenY); + this.leftBottomCornerGroup.setAttributes({ + visible: hasFrozenCols, + y: bottomFrozenY, + height: bottomFrozenRowsHeight, + width: hasFrozenCols ? this.table.getFrozenColsWidth() : 0 + }); + this.rightBottomCornerGroup.setAttributes({ + visible: hasRightFrozenCols, + x: 0, + y: bottomFrozenY, + width: 0, + height: bottomFrozenRowsHeight + }); + } else { this.leftBottomCornerGroup.setAttributes({ - visible: true, - y: this.tableGroup.attribute.height - this.table.getBottomFrozenRowsHeight(), - height: this.table.getBottomFrozenRowsHeight(), - width: this.table.getFrozenColsWidth() + visible: false, + width: 0, + height: 0 }); this.rightBottomCornerGroup.setAttributes({ - visible: true, - y: this.tableGroup.attribute.height - this.table.getBottomFrozenRowsHeight(), - height: this.table.getBottomFrozenRowsHeight() + visible: false, + width: 0, + height: 0 }); } - if (this.table.rightFrozenColCount > 0) { - this.rightFrozenGroup.setAttribute('x', this.tableGroup.attribute.width - this.table.getRightFrozenColsWidth()); + if (hasRightFrozenCols) { + const rightFrozenColsWidth = this.table.getRightFrozenColsWidth(); + const middleContentRight = Math.max( + this.colHeaderGroup.attribute.x + this.colHeaderGroup.attribute.width, + this.bodyGroup.attribute.x + this.bodyGroup.attribute.width, + this.bottomFrozenGroup.attribute.x + this.bottomFrozenGroup.attribute.width + ); + const rightFrozenX = Math.min(this.tableGroup.attribute.width - rightFrozenColsWidth, middleContentRight); + this.rightFrozenGroup.setAttribute('x', rightFrozenX); this.rightTopCornerGroup.setAttributes({ - visible: true, - x: this.tableGroup.attribute.width - this.table.getRightFrozenColsWidth(), - width: this.table.getRightFrozenColsWidth(), - height: this.table.getFrozenRowsHeight() + visible: hasFrozenRows, + x: rightFrozenX, + width: hasFrozenRows ? rightFrozenColsWidth : 0, + height: hasFrozenRows ? this.table.getFrozenRowsHeight() : 0 }); this.rightBottomCornerGroup.setAttributes({ - visible: true, - x: this.tableGroup.attribute.width - this.table.getRightFrozenColsWidth(), - width: this.table.getRightFrozenColsWidth() + visible: hasBottomFrozenRows, + x: rightFrozenX, + width: hasBottomFrozenRows ? rightFrozenColsWidth : 0, + height: hasBottomFrozenRows ? this.table.getBottomFrozenRowsHeight() : 0 + }); + } else { + this.rightTopCornerGroup.setAttributes({ + visible: false, + width: 0, + height: 0 }); } diff --git a/packages/vtable/src/scenegraph/utils/text-icon-layout.ts b/packages/vtable/src/scenegraph/utils/text-icon-layout.ts index 285c09f804..8920f0b7d8 100644 --- a/packages/vtable/src/scenegraph/utils/text-icon-layout.ts +++ b/packages/vtable/src/scenegraph/utils/text-icon-layout.ts @@ -583,9 +583,21 @@ export function dealWithIcon( iconAttribute.shape = icon.shape; } + if (icon.type === 'text') { + iconAttribute.text = icon.content; + merge(iconAttribute, icon.style); + } + + if (isNil(iconAttribute.opacity)) { + iconAttribute.opacity = + iconAttribute.visibleTime === 'mouseenter_cell' || iconAttribute.visibleTime === 'click_cell' ? 0 : 1; + } + if (mark) { mark.setAttributes(iconAttribute); - mark.loadImage(iconAttribute.image); + if (iconAttribute.image) { + mark.loadImage(iconAttribute.image); + } mark.tooltip = icon.tooltip; mark.name = icon.name; return mark; @@ -594,8 +606,6 @@ export function dealWithIcon( let iconMark: Icon | TextIcon; if (icon.type === 'text') { - iconAttribute.text = icon.content; - merge(iconAttribute, icon.style); iconMark = new TextIcon(iconAttribute); iconMark.tooltip = icon.tooltip; iconMark.name = icon.name; diff --git a/packages/vtable/src/state/checkbox/checkbox.ts b/packages/vtable/src/state/checkbox/checkbox.ts index 9ac8d7eef0..5b4da0ea5e 100644 --- a/packages/vtable/src/state/checkbox/checkbox.ts +++ b/packages/vtable/src/state/checkbox/checkbox.ts @@ -1,11 +1,14 @@ import { isArray, isFunction, isNumber, isObject, isValid } from '@visactor/vutils'; import type { StateManager } from '../state'; -import type { CheckboxColumnDefine, ListTableAPI } from '../../ts-types'; +import type { CheckboxColumnDefine, FieldDef, ListTableAPI } from '../../ts-types'; import { getOrApply } from '../../tools/helper'; import type { BaseTableAPI } from '../../ts-types/base-table'; import type { CachedDataSource } from '../../data'; import type { CheckBox } from '@src/vrender'; +type CheckboxStateValue = boolean | 'indeterminate'; +type CheckboxRecordValue = CheckboxStateValue | { checked?: CheckboxStateValue }; + export function setCheckedState( col: number, row: number, @@ -304,6 +307,34 @@ export function setCellCheckboxState( } } +export function setCheckboxStateByRecordIndex( + recordIndex: number | number[], + field: FieldDef, + checked: boolean | 'indeterminate', + table: BaseTableAPI +) { + setRecordCheckboxState(recordIndex, field, checked, table.stateManager.checkedState); + + if (table.internalProps.enableCheckboxCascade && checked !== 'indeterminate') { + updateChildrenCheckboxStateByRecordIndex(recordIndex, field, checked, table); + updateParentCheckboxStateByRecordIndex(recordIndex, field, table); + } + + updateVisibleCheckboxCellByRecordIndex(recordIndex, field, checked, table); + updateHeaderCheckboxStateByField(field, table); + renderCheckboxStateUpdate(table); +} + +export function clearCheckboxState(field: FieldDef, table: BaseTableAPI) { + if (!isValid(field)) { + return; + } + clearRecordCheckboxState((table as any).records, field, table); + updateVisibleCheckboxCellsByField(field, false, table); + updateHeaderCheckboxStateByField(field, table); + renderCheckboxStateUpdate(table); +} + export function setCellCheckboxStateByAttribute( col: number, row: number, @@ -404,6 +435,234 @@ export function getGroupCheckboxState(table: BaseTableAPI) { return result; } +function normalizeRecordIndex(recordIndex: number | number[]): number | number[] { + if (isArray(recordIndex) && recordIndex.length === 1) { + return recordIndex[0]; + } + return recordIndex; +} + +function setRecordCheckboxState( + recordIndex: number | number[], + field: FieldDef, + checked: boolean | 'indeterminate', + checkedState: Map +) { + const dataIndex = normalizeRecordIndex(recordIndex).toString(); + if (checkedState.get(dataIndex)) { + checkedState.get(dataIndex)[field as string | number] = checked; + } else { + checkedState.set(dataIndex, { + [field as string | number]: checked + }); + } +} + +function clearRecordCheckboxState(records: any[], field: FieldDef, table: BaseTableAPI, parentIndex: number[] = []) { + if (!isArray(records)) { + return; + } + + records.forEach((record, index) => { + const recordIndex = parentIndex.length ? parentIndex.concat(index) : [index]; + const currentState = getRecordCheckboxState(recordIndex, record, field, table); + if (currentState !== false) { + setRecordCheckboxState(recordIndex, field, false, table.stateManager.checkedState); + } + + if (isArray(record?.children)) { + clearRecordCheckboxState(record.children, field, table, recordIndex); + } + }); +} + +function updateVisibleCheckboxCellByRecordIndex( + recordIndex: number | number[], + field: FieldDef, + checked: boolean | 'indeterminate', + table: BaseTableAPI +) { + const bodyRowIndex = (table as ListTableAPI).getBodyRowIndexByRecordIndex?.(recordIndex); + if (!isNumber(bodyRowIndex) || bodyRowIndex < 0) { + return; + } + + const row = bodyRowIndex + table.columnHeaderLevelCount; + const cols = getCheckboxColsByField(field, row, table); + cols.forEach(col => { + setCellCheckboxStateByAttribute(col, row, checked, table); + }); +} + +function updateVisibleCheckboxCellsByField(field: FieldDef, checked: boolean | 'indeterminate', table: BaseTableAPI) { + const bodyStartRow = table.columnHeaderLevelCount; + const cols = getCheckboxColsByField(field, bodyStartRow, table); + for (let row = table.columnHeaderLevelCount; row < table.rowCount; row++) { + cols.forEach(col => { + setCellCheckboxStateByAttribute(col, row, checked, table); + }); + } +} + +function renderCheckboxStateUpdate(table: BaseTableAPI) { + const renderAsync = (table as any).renderAsync; + if (isFunction(renderAsync)) { + renderAsync.call(table); + } else { + table.render(); + } +} + +function updateHeaderCheckboxStateByField(field: FieldDef, table: BaseTableAPI) { + const fieldKey = field as string | number; + let hasChecked = false; + let hasUnchecked = false; + let hasRecord = false; + + traverseRecords((table as any).records, (record, recordIndex) => { + const state = getRecordCheckboxState(recordIndex, record, field, table); + hasRecord = true; + if (state === true) { + hasChecked = true; + } else if (state === 'indeterminate') { + hasChecked = true; + hasUnchecked = true; + } else { + hasUnchecked = true; + } + }); + + const checked = hasRecord && hasChecked && !hasUnchecked ? true : hasChecked ? 'indeterminate' : false; + table.stateManager.headerCheckedState[fieldKey] = checked; + updateVisibleHeaderCheckboxCellByField(field, checked, table); +} + +function updateVisibleHeaderCheckboxCellByField( + field: FieldDef, + checked: boolean | 'indeterminate', + table: BaseTableAPI +) { + for (let row = 0; row < table.columnHeaderLevelCount; row++) { + for (let col = 0; col < table.colCount; col++) { + if (table.getHeaderField(col, row) === field && table.getCellType(col, row) === 'checkbox') { + table.scenegraph.updateHeaderCheckboxCellState(col, row, checked); + } + } + } +} + +function getCheckboxColsByField(field: FieldDef, row: number, table: BaseTableAPI): number[] { + const cols: number[] = []; + for (let col = 0; col < table.colCount; col++) { + if (table.isHeader(col, row)) { + continue; + } + const define = table.getBodyColumnDefine(col, row); + if (define?.field === field && table.getCellType(col, row) === 'checkbox') { + cols.push(col); + } + } + return cols; +} + +function updateChildrenCheckboxStateByRecordIndex( + recordIndex: number | number[], + field: FieldDef, + checked: boolean, + table: BaseTableAPI +) { + const record = getRecordByRecordIndex((table as any).records, recordIndex); + if (!record?.children?.length) { + return; + } + + record.children.forEach((child: any, childIndex: number) => { + const childRecordIndex = normalizeRecordIndexToArray(recordIndex).concat(childIndex); + setRecordCheckboxState(childRecordIndex, field, checked, table.stateManager.checkedState); + updateVisibleCheckboxCellByRecordIndex(childRecordIndex, field, checked, table); + updateChildrenCheckboxStateByRecordIndex(childRecordIndex, field, checked, table); + }); +} + +function updateParentCheckboxStateByRecordIndex(recordIndex: number | number[], field: FieldDef, table: BaseTableAPI) { + const recordIndexPath = normalizeRecordIndexToArray(recordIndex); + for (let level = recordIndexPath.length - 1; level > 0; level--) { + const parentIndex = recordIndexPath.slice(0, level); + const parentRecord = getRecordByRecordIndex((table as any).records, parentIndex); + if (!parentRecord?.children?.length) { + continue; + } + + const childStates: CheckboxStateValue[] = parentRecord.children.map((child: any, childIndex: number) => { + const childRecordIndex = parentIndex.concat(childIndex); + const childState = getRecordCheckboxState(childRecordIndex, child, field, table); + setRecordCheckboxState(childRecordIndex, field, childState, table.stateManager.checkedState); + return childState; + }); + const allChecked = childStates.every(state => state === true); + const allUnchecked = childStates.every(state => state !== true && state !== 'indeterminate'); + const parentState = allChecked ? true : allUnchecked ? false : 'indeterminate'; + setRecordCheckboxState(parentIndex, field, parentState, table.stateManager.checkedState); + updateVisibleCheckboxCellByRecordIndex(parentIndex, field, parentState, table); + } +} + +function getRecordCheckboxState( + recordIndex: number | number[], + record: any, + field: FieldDef, + table: BaseTableAPI +): CheckboxStateValue { + const fieldKey = field as string | number; + const dataIndex = normalizeRecordIndex(recordIndex).toString(); + const cachedState = table.stateManager.checkedState.get(dataIndex)?.[fieldKey]; + if (isValid(cachedState)) { + return cachedState; + } + + const value = record?.[fieldKey] as CheckboxRecordValue | undefined; + if (isObject(value) && isValid((value as { checked?: CheckboxStateValue }).checked)) { + return (value as { checked?: CheckboxStateValue }).checked; + } + if (typeof value === 'boolean') { + return value; + } + return false; +} + +function getRecordByRecordIndex(records: any[], recordIndex: number | number[]): any { + const recordIndexPath = normalizeRecordIndexToArray(recordIndex); + let currentRecords = records; + let record; + for (let i = 0; i < recordIndexPath.length; i++) { + record = currentRecords?.[recordIndexPath[i]]; + currentRecords = record?.children; + } + return record; +} + +function normalizeRecordIndexToArray(recordIndex: number | number[]): number[] { + return isArray(recordIndex) ? recordIndex : [recordIndex]; +} + +function traverseRecords( + records: any[], + handler: (record: any, recordIndex: number | number[]) => void, + parentIndex: number[] = [] +) { + if (!isArray(records)) { + return; + } + + records.forEach((record, index) => { + const recordIndex = parentIndex.length ? parentIndex.concat(index) : index; + handler(record, recordIndex); + if (isArray(record?.children)) { + traverseRecords(record.children, handler, isArray(recordIndex) ? recordIndex : [recordIndex]); + } + }); +} + function initRecordCheckState(records: any[], state: StateManager) { const table = state.table; const start = table.isPivotTable() diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index 5433438e5c..f898856f68 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -694,7 +694,11 @@ export interface BaseTableConstructorOptions { renderOption?: any; formatCopyValue?: (value: string) => string; - customComputeRowHeight?: (computeArgs: { row: number; table: BaseTableAPI }) => number | 'auto' | undefined; + customComputeRowHeight?: (computeArgs: { + row: number; + table: BaseTableAPI; + realHeight?: number; + }) => number | 'auto' | undefined; /** 当表格出现抖动情况,请排查是否上层dom容器的宽高是小数引起的。如果不能保证是整数,请配置这个配置项为true */ tableSizeAntiJitter?: boolean; diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 25021a6e07..00f45593ec 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -458,6 +458,25 @@ export interface ListTableAPI extends BaseTableAPI { reapplySort?: boolean; clearRowHeightCache?: boolean; }) => void; + /** 获取某个字段下 checkbox 全部数据的选中状态,顺序对应原始 records。 */ + getCheckboxState: (field?: string | number) => any[]; + /** 获取某个单元格 checkbox 的状态。 */ + getCellCheckboxState: (col: number, row: number) => boolean | 'indeterminate' | undefined; + /** 设置某个可见单元格 checkbox 的状态。 */ + setCellCheckboxState: (col: number, row: number, checked: boolean | 'indeterminate') => void; + /** + * 根据源数据 records 的 index + field 设置 checkbox 状态。 + * recordIndex 为源数据中的索引:普通表格为 number;树形表格为 number[](children 路径)。 + */ + setCellCheckboxStateByRecordIndex: ( + recordIndex: number | number[], + field: FieldDef, + checked: boolean | 'indeterminate' + ) => void; + /** 清除指定 field 的全部 checkbox 选中状态。 */ + clearCheckboxState: (field: FieldDef) => void; + /** clearCheckboxState 的别名,兼容 issue 中提出的 API 命名。 */ + clearAllCheckboxState: (field: FieldDef) => void; getFieldData: (field: FieldDef | FieldFormat | undefined, col: number, row: number) => FieldData; //#region 编辑器相关demo /** 获取单元格配置的编辑器 */ @@ -478,7 +497,7 @@ export interface ListTableAPI extends BaseTableAPI { addRecord: (record: any, recordIndex?: number | number[], triggerEvent?: boolean) => void; addRecords: (records: any[], recordIndex?: number | number[], triggerEvent?: boolean) => void; deleteRecords: (recordIndexs: number[] | number[][], triggerEvent?: boolean) => void; - updateRecords: (records: any[], recordIndexs: (number | number[])[], triggerEvent?: boolean) => void; + updateRecords: (records: any[], recordIndexs?: (number | number[])[], triggerEvent?: boolean) => void; updateFilterRules: (filterRules: FilterRules, options: { clearRowHeightCache?: boolean }) => void; getAggregateValuesByField: (field: string | number) => { col: number; diff --git a/packages/vue-vtable/demo/src/App.vue b/packages/vue-vtable/demo/src/App.vue index a54581281b..3722650d69 100644 --- a/packages/vue-vtable/demo/src/App.vue +++ b/packages/vue-vtable/demo/src/App.vue @@ -19,6 +19,9 @@ import ListTableEditorRender from './table/gramatical/composition/ListTable-edit import ListTableDes from './table/gramatical/composition/ListTable-destruction.vue'; import ListTableCustom from './table/gramatical/composition/ListTable-custom.vue'; import ListTableCustomHover from './table/gramatical/composition/ListTable-custom-hover.vue'; +import Issue5150CustomLayoutSort from './table/gramatical/composition/Issue5150CustomLayoutSort.vue'; +import Issue5157CustomLayoutScrollbar from './table/gramatical/composition/Issue5157CustomLayoutScrollbar.vue'; +import Issue4884SlotEditorBackground from './table/gramatical/composition/Issue4884SlotEditorBackground.vue'; import ListTableVFor from './table/gramatical/options/ListTable-v-for.vue'; import PivotTable from './table/gramatical/options/PivotTable.vue'; @@ -50,7 +53,9 @@ import singleRadio from './table/single/single-radio.vue'; - + + + diff --git a/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4884SlotEditorBackground.vue b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4884SlotEditorBackground.vue new file mode 100644 index 0000000000..c7a95003a7 --- /dev/null +++ b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4884SlotEditorBackground.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5150CustomLayoutSort.vue b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5150CustomLayoutSort.vue new file mode 100644 index 0000000000..4f9d35e6ef --- /dev/null +++ b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5150CustomLayoutSort.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5157CustomLayoutScrollbar.vue b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5157CustomLayoutScrollbar.vue new file mode 100644 index 0000000000..7da2522d7b --- /dev/null +++ b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue5157CustomLayoutScrollbar.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/packages/vue-vtable/package.json b/packages/vue-vtable/package.json index a7c2312c8c..17bb510e44 100644 --- a/packages/vue-vtable/package.json +++ b/packages/vue-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vue-vtable", - "version": "1.26.5", + "version": "1.26.6", "description": "The vue version of VTable", "keywords": [ "vue", @@ -59,7 +59,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", + "@visactor/vtable": "workspace:1.26.6", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts index 23656f05f9..f44dc8b665 100644 --- a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts +++ b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts @@ -158,10 +158,6 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl wrapContainer.setAttribute('data-vue-renderId', dataRenderId); // 先隐藏 wrapContainer.style.display = 'none'; - if (!reuse) { - // 仅在非复用时需要重新渲染 - render(element, wrapContainer); - } targetMap = { wrapContainer, nativeContainer, @@ -181,6 +177,7 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl targetMap.renderId = this.renderId; targetMap.graphic = graphic; targetMap.lastAccessed = Date.now(); + render(element, targetMap.wrapContainer); this.updateAccessQueue(id); this.updateStyleOfWrapContainer(graphic, stage, targetMap.wrapContainer, targetMap.nativeContainer); } @@ -492,6 +489,16 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl const { vue: options, width, height, visible, display, ...rest } = attribute || {}; const { x: left, y: top } = this.calculatePosition(graphic, options.anchorType); const { left: offsetX, top: offsetTop } = this.calculateOffset(stage, nativeContainer, left, top); + const { safeWidth, safeHeight } = this.getScrollbarSafeSize( + graphic, + stage, + nativeContainer, + offsetX, + offsetTop, + width, + height, + options + ); const { id } = this.getGraphicOptions(graphic) || {}; const record = id ? this.htmlMap[id] : null; @@ -502,22 +509,20 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl // 位置变化检查 const positionChanged = !record.lastPosition || record.lastPosition.x !== offsetX || record.lastPosition.y !== offsetTop; - if (!positionChanged) { - // 位置没有变化,无需更新样式 - return; - } // 默认自定义区域内也可带动表格画布滚动 const { pointerEvents } = options; const calculateStyle = this.parseDefaultStyleFromGraphic(graphic); + const clipStyle = this.getScrollbarClipStyle(width, height, safeWidth, safeHeight); // 单元格样式 const style = this.convertCellStyle(graphic); Object.assign(calculateStyle, { - width: `${width}px`, - height: `${height}px`, + width: `${safeWidth}px`, + height: `${safeHeight}px`, overflow: 'hidden', ...(style || {}), ...(rest || {}), + ...clipStyle, transform: `translate(${offsetX}px, ${offsetTop}px)`, boxSizing: 'border-box', display: visible !== false ? display || 'block' : 'none', @@ -549,6 +554,152 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl record.lastStyle = calculateStyle; } + record.lastPosition = positionChanged ? { x: offsetX, y: offsetTop } : record.lastPosition; + } + + private getScrollbarSafeSize( + graphic: IGraphic, + stage: IStage, + nativeContainer: HTMLElement, + offsetX: number, + offsetTop: number, + width: number, + height: number, + options: any + ) { + const pointerEvents = options?.pointerEvents === true ? 'all' : options?.pointerEvents || 'none'; + const table = getTargetGroup(graphic)?.stage?.table; + const scrollStyle = table?.theme?.scrollStyle; + const barToSide = scrollStyle?.barToSide ?? false; + let safeWidth = width; + let safeHeight = height; + + if (pointerEvents !== 'none' && table && !barToSide) { + const verticalVisible = scrollStyle?.verticalVisible ?? scrollStyle?.visible; + const horizontalVisible = scrollStyle?.horizontalVisible ?? scrollStyle?.visible; + const sizeTolerance = this.getScrollSizeTolerance(table); + const hasVerticalScrollBar = + verticalVisible !== 'none' && table.getAllRowsHeight() > table.tableNoFrameHeight + sizeTolerance; + const hasHorizontalScrollBar = + horizontalVisible !== 'none' && this.getBodyHorizontalScrollRange(table) > sizeTolerance; + const scrollBarSize = scrollStyle?.width ?? 7; + const groupX = table.scenegraph?.tableGroup?.attribute?.x ?? 0; + const groupY = table.scenegraph?.tableGroup?.attribute?.y ?? 0; + const hoverOn = scrollStyle?.hoverOn; + const domRight = offsetX + width; + const domBottom = offsetTop + height; + const ignoreFrozenCols = scrollStyle?.ignoreFrozenCols ?? false; + + if (hasVerticalScrollBar) { + const verticalLeft = + Math.min(table.tableNoFrameWidth, table.getAllColsWidth()) - (hoverOn ? scrollBarSize : -groupX); + const verticalTop = table.getFrozenRowsHeight() + (!hoverOn ? groupY : 0); + const verticalBottom = + verticalTop + table.tableNoFrameHeight - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + const { left: verticalDomLeft, top: verticalDomTop } = this.calculateOffset( + stage, + nativeContainer, + verticalLeft, + verticalTop + ); + const verticalDomBottom = verticalDomTop + verticalBottom - verticalTop; + + if ( + offsetTop < verticalDomBottom && + domBottom > verticalDomTop && + offsetX < verticalDomLeft + scrollBarSize && + domRight > verticalDomLeft + ) { + safeWidth = Math.max(0, Math.min(width, verticalDomLeft - offsetX)); + } + } + + const clipHorizontalScrollbar = (horizontalLeft: number, horizontalWidth: number) => { + const horizontalTop = + Math.min(table.tableNoFrameHeight, table.getAllRowsHeight()) - (hoverOn ? scrollBarSize : -groupY); + const horizontalRight = horizontalLeft + horizontalWidth; + const { left: horizontalDomLeft, top: horizontalDomTop } = this.calculateOffset( + stage, + nativeContainer, + horizontalLeft, + horizontalTop + ); + const horizontalDomRight = horizontalDomLeft + horizontalRight - horizontalLeft; + + if ( + offsetX < horizontalDomRight && + domRight > horizontalDomLeft && + offsetTop < horizontalDomTop + scrollBarSize && + domBottom > horizontalDomTop + ) { + safeHeight = Math.max(0, Math.min(height, horizontalDomTop - offsetTop)); + } + }; + + if (hasHorizontalScrollBar) { + const horizontalLeft = ignoreFrozenCols + ? !hoverOn + ? groupX + : 0 + : table.getFrozenColsWidth() + (!hoverOn ? groupX : 0); + const horizontalWidth = ignoreFrozenCols + ? table.tableNoFrameWidth + : table.tableNoFrameWidth - table.getFrozenColsWidth() - table.getRightFrozenColsWidth(); + clipHorizontalScrollbar(horizontalLeft, horizontalWidth); + } + + if ( + horizontalVisible !== 'none' && + !ignoreFrozenCols && + table.options?.scrollFrozenCols && + table.getFrozenColsOffset?.() > 0 + ) { + clipHorizontalScrollbar(!hoverOn ? groupX : 0, table.getFrozenColsWidth()); + } + + if ( + horizontalVisible !== 'none' && + !ignoreFrozenCols && + table.options?.scrollRightFrozenCols && + table.getRightFrozenColsOffset?.() > 0 + ) { + clipHorizontalScrollbar( + table.tableNoFrameWidth - table.getRightFrozenColsWidth() + (!hoverOn ? groupX : 0), + table.getRightFrozenColsWidth() + ); + } + } + + return { safeWidth, safeHeight }; + } + + private getScrollbarClipStyle(width: number, height: number, safeWidth: number, safeHeight: number) { + if (safeWidth >= width && safeHeight >= height) { + return { clipPath: 'none' }; + } + + const right = safeWidth < width ? `calc(100% - ${safeWidth}px)` : '0px'; + const bottom = safeHeight < height ? `calc(100% - ${safeHeight}px)` : '0px'; + + return { + clipPath: `inset(0 ${right} ${bottom} 0)` + }; + } + + private getBodyHorizontalScrollRange(table: any) { + const totalWidth = table.getAllColsWidth(); + const frozenColsWidth = table.getFrozenColsWidth(); + const rightFrozenColsWidth = table.getRightFrozenColsWidth(); + const frozenColsContentWidth = table.getFrozenColsContentWidth?.() ?? frozenColsWidth; + const rightFrozenColsContentWidth = table.getRightFrozenColsContentWidth?.() ?? rightFrozenColsWidth; + const bodyViewportWidth = table.tableNoFrameWidth - frozenColsWidth - rightFrozenColsWidth; + const bodyContentWidth = totalWidth - frozenColsContentWidth - rightFrozenColsContentWidth; + + return Math.max(0, bodyContentWidth - bodyViewportWidth); + } + + private getScrollSizeTolerance(table: any) { + return table.options?.customConfig?._disableColumnAndRowSizeRound ? 1 : 0; } /** diff --git a/packages/vue-vtable/src/edit/editor.ts b/packages/vue-vtable/src/edit/editor.ts index ce41464259..256ec9805e 100644 --- a/packages/vue-vtable/src/edit/editor.ts +++ b/packages/vue-vtable/src/edit/editor.ts @@ -182,8 +182,6 @@ export class DynamicRenderEditor { wrapContainer.style.position = 'absolute'; wrapContainer.style.width = '100%'; wrapContainer.style.boxSizing = 'border-box'; - const { bgColor } = table.getCellStyle(col, row) || {}; - wrapContainer.style.backgroundColor = bgColor || '#FFFFFF'; this.wrapContainer = wrapContainer; this.tableContainer = container; this.tableContainer.appendChild(wrapContainer); diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index e55721c4a7..60f812acce 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.5", - "@visactor/vtable-gantt": "workspace:1.26.5", - "@visactor/vtable-editors": "workspace:1.26.5", - "@visactor/vtable-plugins": "workspace:1.26.5", - "@visactor/vtable-sheet": "workspace:1.26.5" + "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable-gantt": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-plugins": "workspace:1.26.6", + "@visactor/vtable-sheet": "workspace:1.26.6" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4", diff --git a/tools/bundler/src/tasks/umd.ts b/tools/bundler/src/tasks/umd.ts index a45f6e4213..b4a4f04c8b 100644 --- a/tools/bundler/src/tasks/umd.ts +++ b/tools/bundler/src/tasks/umd.ts @@ -34,6 +34,7 @@ export async function buildUmd(config: Config, projectRoot: string, rawPackageJs file: minify ? `${dest}/${config.umdOutputFilename || packageNameToPath(rawPackageJson.name)}.min.js` : `${dest}/${config.umdOutputFilename || packageNameToPath(rawPackageJson.name)}.js`, + inlineDynamicImports: true, exports: 'named', globals: { react: 'React', ...config.globals } }