diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e266a022cb..d37d1fc8c5 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.7 + specifier: workspace:1.26.8 version: link:../packages/openinula-vtable '@visactor/react-vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/react-vtable '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-editors '@visactor/vtable-export': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-export '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-gantt '@visactor/vtable-search': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-search '@visactor/vtable-sheet': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-sheet '@visactor/vue-vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 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.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -278,7 +278,7 @@ importers: ../../packages/react-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 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.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -640,7 +640,7 @@ importers: ../../packages/vtable-calendar: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -863,7 +863,7 @@ importers: specifier: 2.0.7 version: 2.0.7 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1023,10 +1023,10 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -1259,13 +1259,13 @@ importers: specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-gantt '@vitejs/plugin-react': specifier: 3.1.0 @@ -1364,7 +1364,7 @@ importers: ../../packages/vtable-search: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1518,13 +1518,13 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vtable-plugins': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-plugins '@visactor/vutils': specifier: ~1.0.17 @@ -1663,7 +1663,7 @@ importers: ../../packages/vue-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1844,19 +1844,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-gantt '@visactor/vtable-plugins': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-plugins '@visactor/vtable-sheet': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 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 2fa348fd6c..a482f07a95 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.7","mainProject":"@visactor/vtable","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.8","mainProject":"@visactor/vtable","nextBump":"patch"}] diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 8a3c7c18e3..831366d188 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -1,3 +1,23 @@ +# v1.26.8 + +2026-09-11 + +**🆕 New Features** + +- **@visactor/vtable**: add themes.setDefaultTheme for application-wide default styling + +**🐛 Bug Fixes** + +- **@visactor/vtable**: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) +- **@visactor/vtable**: update nested fields by path +- **@visactor/vtable**: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) +- **@visactor/vtable**: preserve pivot table column widths after updateOption (#5298) +- **@visactor/vtable-sheet**: keep test-only plugin sources out of the package compile + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 + +[more detail about v1.26.8](https://github.com/VisActor/VTable/releases/tag/v1.26.8) + # v1.26.7 2026-08-20 @@ -5,17 +25,17 @@ **🆕 New Features** - **@visactor/vtable**: support tooltip appear delay -- **@visactor/vtable**: add audio cell type support (GitHub #5271) +- **@visactor/vtable**: add audio cell type support [#5271](https://github.com/VisActor/VTable/issues/5271) **🐛 Bug Fixes** -- **@visactor/vtable**: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752) -- **@visactor/vtable**: preserve tree checkbox state after row drag reorder (GitHub #3975) -- **@visactor/vtable**: add context menu click event typing (GitHub #4655) -- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- **@visactor/vtable**: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows [#3752](https://github.com/VisActor/VTable/issues/3752) +- **@visactor/vtable**: preserve tree checkbox state after row drag reorder [#3975](https://github.com/VisActor/VTable/issues/3975) +- **@visactor/vtable**: add context menu click event typing [#4655](https://github.com/VisActor/VTable/issues/4655) +- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation [#4964](https://github.com/VisActor/VTable/issues/4964) - **@visactor/vtable**: render vue components in top frozen rows -- **@visactor/vtable**: align frame border with short frozen rows (GitHub #5277) -- **@visactor/vtable**: keep headers after setRecords at scroll edge (GitHub #5278) +- **@visactor/vtable**: align frame border with short frozen rows [#5277](https://github.com/VisActor/VTable/issues/5277) +- **@visactor/vtable**: keep headers after setRecords at scroll edge [#5278](https://github.com/VisActor/VTable/issues/5278) **Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 7c8059ff82..1c36af6110 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -1,3 +1,23 @@ +# v1.26.8 + +2026-09-11 + +**🆕 新增功能** + +- **@visactor/vtable**: 新增 `themes.setDefaultTheme`,支持配置应用级默认主题样式 + +**🐛 问题修复** + +- **@visactor/vtable**: 阻止空图片、音频和视频单元格打开媒体预览(GitHub #5290) +- **@visactor/vtable**: 支持按路径更新嵌套字段 +- **@visactor/vtable**: 复制和剪切时优先使用事件的剪贴板数据;异步 Clipboard API 不可用时为粘贴提供回退;粘贴成功前保留剪切源单元格,并规范化粘贴的 HTML 单元格内容(GitHub #5274) +- **@visactor/vtable**: 调用 `updateOption` 后保留透视表列宽(#5298) +- **@visactor/vtable-sheet**: 修复编译配置,避免将仅用于测试的插件源码纳入包编译 + +**完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 + +[更多详情请查看 v1.26.8](https://github.com/VisActor/VTable/releases/tag/v1.26.8) + # v1.26.7 2026-08-20 @@ -5,17 +25,17 @@ **🆕 新增功能** - **@visactor/vtable**: 支持配置 Tooltip 展示延迟 -- **@visactor/vtable**: 新增音频单元格类型支持(GitHub #5271) +- **@visactor/vtable**: 新增音频单元格类型支持 [#5271](https://github.com/VisActor/VTable/issues/5271) **🐛 问题修复** -- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白(GitHub #3752) -- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态(GitHub #3975) -- **@visactor/vtable**: 新增右键菜单点击事件类型定义(GitHub #4655) -- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录(GitHub #4964) +- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白 [#3752](https://github.com/VisActor/VTable/issues/3752) +- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态 [#3975](https://github.com/VisActor/VTable/issues/3975) +- **@visactor/vtable**: 新增右键菜单点击事件类型定义 [#4655](https://github.com/VisActor/VTable/issues/4655) +- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录 [#4964](https://github.com/VisActor/VTable/issues/4964) - **@visactor/vtable**: 支持在顶部冻结行中渲染 Vue 组件 -- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐(GitHub #5277) -- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示(GitHub #5278) +- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐 [#5277](https://github.com/VisActor/VTable/issues/5277) +- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示 [#5278](https://github.com/VisActor/VTable/issues/5278) **完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 diff --git a/docs/assets/demo-react/en/functional-components/arco-select-editor.md b/docs/assets/demo-react/en/functional-components/arco-select-editor.md index a6bb930ee6..1b533b0de9 100644 --- a/docs/assets/demo-react/en/functional-components/arco-select-editor.md +++ b/docs/assets/demo-react/en/functional-components/arco-select-editor.md @@ -20,6 +20,8 @@ https://arco.design/react/components/select ```javascript livedemo template=vtable-react // import * as ReactVTable from '@visactor/react-vtable'; +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { constructor() { this.root = null; @@ -48,6 +50,7 @@ class ArcoListEditor {
{ this.currentValue = value; @@ -86,19 +89,23 @@ class ArcoListEditor { } isEditorElement(target) { - // cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域 + // The popup is appended to body and is outside the editor container. + // Clicking the popup should still be treated as clicking inside the editor. return this.element.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || target.classList.contains('arco-select-vtable')) + ) { return true; } - // 如果到达了DOM树的顶部,则停止搜索 + // Stop searching when reaching the top of the DOM tree. target = target.parentNode; } - // 如果遍历结束也没有找到符合条件的父元素,则返回false + // Return false if no matching parent element is found. return false; } } diff --git a/docs/assets/demo-react/zh/functional-components/arco-select-editor.md b/docs/assets/demo-react/zh/functional-components/arco-select-editor.md index c4edc624b4..9b9d4abfb4 100644 --- a/docs/assets/demo-react/zh/functional-components/arco-select-editor.md +++ b/docs/assets/demo-react/zh/functional-components/arco-select-editor.md @@ -20,6 +20,8 @@ https://arco.design/react/components/select ```javascript livedemo template=vtable-react // import * as ReactVTable from '@visactor/react-vtable'; +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { constructor() { this.root = null; @@ -48,6 +50,7 @@ class ArcoListEditor {
{ this.currentValue = value; @@ -86,13 +89,16 @@ class ArcoListEditor { } isEditorElement(target) { - // cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域 + // popup append 在 body 尾部,不在编辑器容器内;点击 popup 时也应该被认为仍在编辑器区域。 return this.element.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || target.classList.contains('arco-select-vtable')) + ) { return true; } // 如果到达了DOM树的顶部,则停止搜索 diff --git a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md index 6d830f64f7..f58df2d8d5 100644 --- a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md @@ -19,6 +19,8 @@ https://arco.design/vue/components/select ## Code Demonstration ```javascript livedemo template=vtable-vue +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { root = null; element = null; @@ -64,6 +66,9 @@ class ArcoListEditor { style: { height: '32px' }, placeholder: 'Select city', modelValue: this.currentValue, + triggerProps: { + contentClass: selectEditorPopupClassName + }, 'onUpdate:modelValue': value => { this.currentValue = value; self.setValue(value); @@ -110,12 +115,18 @@ class ArcoListEditor { } isEditorElement(target) { + // The popup is appended to body and is outside the editor container. + // Clicking the popup should still be treated as clicking inside the editor. return this.element?.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || + target.classList.contains('arco-select-vtable')) + ) { return true; } target = target.parentNode; diff --git a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md index c4e66c1264..e1c0a2f5cf 100644 --- a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md @@ -19,6 +19,8 @@ https://arco.design/vue/components/select ## 代码演示 ```javascript livedemo template=vtable-vue +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { root = null; element = null; @@ -64,6 +66,9 @@ class ArcoListEditor { style: { height: '32px' }, placeholder: 'Select city', modelValue: this.currentValue, + triggerProps: { + contentClass: selectEditorPopupClassName + }, 'onUpdate:modelValue': value => { this.currentValue = value; self.setValue(value); @@ -110,12 +115,17 @@ class ArcoListEditor { } isEditorElement(target) { + // popup append 在 body 尾部,不在编辑器容器内;点击 popup 时也应该被认为仍在编辑器区域。 return this.element?.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || + target.classList.contains('arco-select-vtable')) + ) { return true; } target = target.parentNode; diff --git a/docs/assets/demo/en/animation/scroll-animation.md b/docs/assets/demo/en/animation/scroll-animation.md index cfd08870c8..90c4a5ae16 100644 --- a/docs/assets/demo/en/animation/scroll-animation.md +++ b/docs/assets/demo/en/animation/scroll-animation.md @@ -14,7 +14,7 @@ Scroll to the specified area of ​​the table through animation. - `animationOption` scroll animation configuration - `duration` animation duration, unit ms - - `easing` animation easing function, defalut `linear`, support `linear`, `quadIn`, `quadOut`, `quadInOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `backIn`, `backOut`, `backInOut`, `circIn`, `circOut`, `circInOut`, `bounceOut`, `bounceIn`, `bounceInOut`, `elasticIn`, `elasticOut`, `elasticInOut`, `sineIn`, `sineOut`, `sineInOut`, `expoIn`, `expoOut`, `expoInOut` + - `easing` animation easing function, default `linear`, support `linear`, `quadIn`, `quadOut`, `quadInOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `backIn`, `backOut`, `backInOut`, `circIn`, `circOut`, `circInOut`, `bounceOut`, `bounceIn`, `bounceInOut`, `elasticIn`, `elasticOut`, `elasticInOut`, `sineIn`, `sineOut`, `sineInOut`, `expoIn`, `expoOut`, `expoInOut` - `scrollToCell({col, row}, animationOption)` scroll to specified cell - `scrollToRow(row, animationOption)` scroll to specified row - `scrollToCol(col, animationOption)` scroll to specified column diff --git a/docs/assets/demo/en/business/personnel-info.md b/docs/assets/demo/en/business/personnel-info.md new file mode 100644 index 0000000000..c77fdebbeb --- /dev/null +++ b/docs/assets/demo/en/business/personnel-info.md @@ -0,0 +1,172 @@ +--- +category: examples +group: Business +title: Personnel Information Sheet +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif +link: custom_define/custom_icon +--- + +# Personnel Information Sheet + +Display personnel information with phone numbers masked by default (showing only first 3 and last 4 digits). Click the eye icon to toggle displaying the full phone number. + +## Key Configuration + +- `fieldFormat` formats phone numbers: shows `138****5678` by default +- `VTable.register.icon` registers eye-open / eye-close state icons with `visibleTime: 'always'` to always show the toggle button +- `click_cell` listens for icon clicks, toggles the phone visibility for that row, clears the old tooltip, and recreates cells to keep the phone number and icon state in sync + +## Code Demo + +```javascript livedemo template=vtable +const eyeOpenSvg = ` + + + +`; + +const eyeCloseSvg = ` + + + + +`; + +// Register "phone visible" state icon (eye open) +VTable.register.icon('eye-open', { + type: 'svg', + name: 'eye-open', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: 'Click to hide number', + placement: VTable.TYPES.Placement.top + }, + svg: eyeOpenSvg +}); + +// Register "phone hidden" state icon (eye closed) +VTable.register.icon('eye-close', { + type: 'svg', + name: 'eye-close', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: 'Click to show number', + placement: VTable.TYPES.Placement.top + }, + svg: eyeCloseSvg +}); + +// Mask phone number: keep first 3 and last 4 digits, replace the rest with **** +function maskPhone(phone) { + if (!phone || phone.length < 7) return phone; + return phone.slice(0, 3) + '****' + phone.slice(-4); +} + +// Sample personnel data +const records = [ + { id: '001', name: 'Zhang Wei', department: 'R&D', position: 'Senior Engineer', phone: '13812345678', email: 'zhangwei@company.com', location: 'Beijing' }, + { id: '002', name: 'Li Na', department: 'Product', position: 'Product Manager', phone: '13987654321', email: 'lina@company.com', location: 'Shanghai' }, + { id: '003', name: 'Wang Fang', department: 'Design', position: 'UI Designer', phone: '15012349876', email: 'wangfang@company.com', location: 'Shenzhen' }, + { id: '004', name: 'Zhao Lei', department: 'Operations', position: 'Operations Specialist', phone: '18611112222', email: 'zhaolei@company.com', location: 'Guangzhou' }, + { id: '005', name: 'Chen Jing', department: 'HR', position: 'HR Manager', phone: '13711113333', email: 'chenjing@company.com', location: 'Hangzhou' }, + { id: '006', name: 'Liu Yang', department: 'R&D', position: 'Frontend Engineer', phone: '15888889999', email: 'liuyang@company.com', location: 'Beijing' }, + { id: '007', name: 'Zhou Chao', department: 'Sales', position: 'Sales Director', phone: '13666667777', email: 'zhouchao@company.com', location: 'Chengdu' }, + { id: '008', name: 'Wu Min', department: 'Finance', position: 'Finance Supervisor', phone: '18900001111', email: 'wumin@company.com', location: 'Wuhan' }, + { id: '009', name: 'Zheng Hao', department: 'R&D', position: 'Backend Engineer', phone: '13544445555', email: 'zhenghao@company.com', location: "Xi'an" }, + { id: '010', name: 'Sun Li', department: 'Marketing', position: 'Marketing Specialist', phone: '17722223333', email: 'sunli@company.com', location: 'Nanjing' } +]; + +// Track per-row phone number visibility +const phoneVisible = {}; + +let tableInstance; + +const columns = [ + { + field: 'id', + title: 'ID', + width: 70, + style: { textAlign: 'center', color: '#666' } + }, + { + field: 'name', + title: 'Name', + width: 120 + }, + { + field: 'department', + title: 'Department', + width: 120 + }, + { + field: 'position', + title: 'Position', + width: 160 + }, + { + field: 'phone', + title: 'Phone Number', + width: 190, + // Show masked or full number based on per-row visibility state + fieldFormat(record) { + const rowId = record.id; + return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); + }, + // Show current state: eye-close when masked, eye-open when the full number is visible + icon(args) { + const record = args.table.getCellOriginRecord(args.col, args.row); + const rowId = record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + } + }, + { + field: 'email', + title: 'Email', + width: 210 + }, + { + field: 'location', + title: 'City', + width: 100, + style: { textAlign: 'center' } + } +]; + +const option = { + records, + columns, + widthMode: 'standard', + frozenColCount: 1, + theme: VTable.themes.ARCO +}; + +tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window['tableInstance'] = tableInstance; + +// Listen for icon clicks to toggle phone number visibility per row +tableInstance.on('click_cell', args => { + const { col, row, targetIcon } = args; + if (!targetIcon) return; + if (targetIcon.name === 'eye-open' || targetIcon.name === 'eye-close') { + const record = tableInstance.getCellOriginRecord(col, row); + if (!record) return; + const rowId = record.id; + // Toggle visibility for this row + phoneVisible[rowId] = !phoneVisible[rowId]; + // Clear the current tooltip so stale icon text does not remain after toggling + tableInstance.internalProps.tooltipHandler?._unbindFromCell?.(); + // Recreate cells to refresh both the phone number and icon state + tableInstance.renderWithRecreateCells(); + } +}); +``` diff --git a/docs/assets/demo/en/custom-render/complex-corner.md b/docs/assets/demo/en/custom-render/complex-corner.md index e3fd9a5d76..18fcf213f2 100644 --- a/docs/assets/demo/en/custom-render/complex-corner.md +++ b/docs/assets/demo/en/custom-render/complex-corner.md @@ -71,9 +71,18 @@ const option = { }, corner: { titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, customLayout: args => { const { table, row, col, rect } = args; const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 80; const container = createGroup({ height, width @@ -81,12 +90,12 @@ const option = { // 定义文本内容的数组 const texts = [ - { text: 'province', fontSize: 18, x: 10, y: rect.height - 25 }, - { text: 'city', fontSize: 18, x: 105, y: rect.height - 25 }, - { text: 'data', fontSize: 18, x: rect.width - 50, y: rect.height - 35 }, - { text: 'subCategory', fontSize: 18, x: rect.width - 100, y: rect.height - 85 }, - { text: 'category', fontSize: 18, x: rect.width - 80, y: 18 }, - { text: 'indicator', fontSize: 16, x: 176, y: rect.height - 20 } + { text: 'province', fontSize: 18, x: provinceCenterX - 36, y: height - 25 }, + { text: 'city', fontSize: 18, x: cityCenterX - 14, y: height - 25 }, + { text: 'data', fontSize: 18, x: endCategoryX + 30, y: height - 35 }, + { text: 'subCategory', fontSize: 18, x: endCategoryX - 20, y: height - 85 }, + { text: 'category', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: 'indicator', fontSize: 16, x: indicatorX, y: height - 20 } ]; // 循环添加文本 @@ -105,17 +114,16 @@ const option = { // 定义线段的点 const linePoints = [ - { x: rect.left, y: rect.top }, { x: 0, y: 0 }, - { x: rect.width - 40, y: rect.height }, + { x: width - 40, y: height }, { x: 0, y: 0 }, - { x: 173, y: rect.height }, + { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, - { x: 84, y: rect.height }, + { x: provinceWidth, y: height }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 90 }, + { x: width, y: categoryHeight }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 38 }, + { x: width, y: height - 38 }, { x: 0, y: 0 } ]; @@ -398,6 +406,10 @@ const option = { rightFrozenColCount: 1, dragOrder:{ dragHeaderMode: 'all' + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' } }; diff --git a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md index a0fdb79b40..86cb588a8a 100644 --- a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # Pivot Analysis - Subtotal Total -To summarize table data in pivot analysis, configure totals in dataConfig to set the total subtotal of the row and column dimensions.This example summary is always displayed to the bottom, and can also be displayed to the top through total.showGrandTotalsOnTop. [Configuration reference](../../option/PivotTable#dataConfig.totals) +Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Row grand totals appear at the bottom by default and can be moved to the top with `showGrandTotalsOnTop`; column grand totals appear on the right and can be moved to the left with `showGrandTotalsOnLeft`. [Configuration reference](../../option/PivotTable#dataConfig.totals) ## Key Configurations @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/demo/en/gantt/gantt-zoom.md b/docs/assets/demo/en/gantt/gantt-zoom.md index 0f60a23f20..2966597c57 100644 --- a/docs/assets/demo/en/gantt/gantt-zoom.md +++ b/docs/assets/demo/en/gantt/gantt-zoom.md @@ -19,6 +19,7 @@ The Gantt chart smart zoom feature provides multi-level timeline display schemes - API get current zoom state: Use methods provided by `getCurrentZoomState` - API set current zoom state: Use methods provided by `setZoomPosition` - API zooming: Use zoom methods provided by `zoomScaleManager` +- `markLine.style.lineWidth`: Can be configured as a function to calculate the mark line width from the current zoomed timeline column width ## Demo @@ -729,6 +730,27 @@ const option = { lineColor: '#f1f3f4' } }, + markLine: [ + { + date: '2024-07-17', + content: 'Dynamic width', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: 'Fixed width', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], headerRowHeight: 50, rowHeight: 40, overscrollBehavior: 'none' diff --git a/docs/assets/demo/menu.json b/docs/assets/demo/menu.json index 9097516a00..b9fdf73b22 100644 --- a/docs/assets/demo/menu.json +++ b/docs/assets/demo/menu.json @@ -1854,8 +1854,25 @@ "zh": "产品对比表", "en": "Product Compare Table" } + }, + { + "path": "personnel-info", + "title": { + "zh": "人员信息表", + "en": "Personnel Information Sheet" + }, + "meta": { + "title": "Personnel Information Sheet", + "keywords": "personnel, phone mask, privacy, icon, click", + "category": "demo", + "group": "Business", + "cover": "https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif", + "link": "'../../guide/custom_define/custom_icon'", + "option": "" + } } + ] } ] -} \ No newline at end of file +} diff --git a/docs/assets/demo/zh/business/personnel-info.md b/docs/assets/demo/zh/business/personnel-info.md new file mode 100644 index 0000000000..83a5689b09 --- /dev/null +++ b/docs/assets/demo/zh/business/personnel-info.md @@ -0,0 +1,172 @@ +--- +category: examples +group: Business +title: 人员信息表 +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif +link: custom_define/custom_icon +--- + +# 人员信息表 + +展示人员信息,电话号码默认脱敏显示(部分隐藏),点击眼睛图标可切换显示完整号码。 + +## 关键配置 + +- `fieldFormat` 格式化电话号码,默认只显示前三位和后四位,中间用 `****` 替换 +- `VTable.register.icon` 注册显示/隐藏状态图标,通过 `visibleTime: 'always'` 常驻显示 +- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态,清理旧 tooltip 并重建单元格,确保号码和图标状态同步刷新 + +## 代码演示 + +```javascript livedemo template=vtable +const eyeOpenSvg = ` + + + +`; + +const eyeCloseSvg = ` + + + + +`; + +// 注册"号码已显示"状态图标(眼睛张开) +VTable.register.icon('eye-open', { + type: 'svg', + name: 'eye-open', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: '点击隐藏号码', + placement: VTable.TYPES.Placement.top + }, + svg: eyeOpenSvg +}); + +// 注册"号码已隐藏"状态图标(眼睛闭合) +VTable.register.icon('eye-close', { + type: 'svg', + name: 'eye-close', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: '点击显示号码', + placement: VTable.TYPES.Placement.top + }, + svg: eyeCloseSvg +}); + +// 脱敏电话号码:保留前3位和后4位,中间替换为 **** +function maskPhone(phone) { + if (!phone || phone.length < 7) return phone; + return phone.slice(0, 3) + '****' + phone.slice(-4); +} + +// 模拟人员数据 +const records = [ + { id: '001', name: '张伟', department: '研发部', position: '高级工程师', phone: '13812345678', email: 'zhangwei@company.com', location: '北京' }, + { id: '002', name: '李娜', department: '产品部', position: '产品经理', phone: '13987654321', email: 'lina@company.com', location: '上海' }, + { id: '003', name: '王芳', department: '设计部', position: 'UI设计师', phone: '15012349876', email: 'wangfang@company.com', location: '深圳' }, + { id: '004', name: '赵磊', department: '运营部', position: '运营专员', phone: '18611112222', email: 'zhaolei@company.com', location: '广州' }, + { id: '005', name: '陈静', department: '人力资源部', position: 'HR经理', phone: '13711113333', email: 'chenjing@company.com', location: '杭州' }, + { id: '006', name: '刘洋', department: '研发部', position: '前端工程师', phone: '15888889999', email: 'liuyang@company.com', location: '北京' }, + { id: '007', name: '周超', department: '销售部', position: '销售总监', phone: '13666667777', email: 'zhouchao@company.com', location: '成都' }, + { id: '008', name: '吴敏', department: '财务部', position: '财务主管', phone: '18900001111', email: 'wumin@company.com', location: '武汉' }, + { id: '009', name: '郑浩', department: '研发部', position: '后端工程师', phone: '13544445555', email: 'zhenghao@company.com', location: '西安' }, + { id: '010', name: '孙丽', department: '市场部', position: '市场专员', phone: '17722223333', email: 'sunli@company.com', location: '南京' } +]; + +// 记录每行的电话号码展开状态 +const phoneVisible = {}; + +let tableInstance; + +const columns = [ + { + field: 'id', + title: '工号', + width: 70, + style: { textAlign: 'center', color: '#666' } + }, + { + field: 'name', + title: '姓名', + width: 90 + }, + { + field: 'department', + title: '部门', + width: 120 + }, + { + field: 'position', + title: '职位', + width: 130 + }, + { + field: 'phone', + title: '电话号码', + width: 180, + // 根据展开状态决定显示脱敏号码还是完整号码 + fieldFormat(record) { + const rowId = record.id; + return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); + }, + // 根据号码显示状态动态切换图标:隐藏状态显示闭眼,展开状态显示开眼 + icon(args) { + const record = args.table.getCellOriginRecord(args.col, args.row); + const rowId = record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + } + }, + { + field: 'email', + title: '邮箱', + width: 200 + }, + { + field: 'location', + title: '所在城市', + width: 100, + style: { textAlign: 'center' } + } +]; + +const option = { + records, + columns, + widthMode: 'standard', + frozenColCount: 1, + theme: VTable.themes.ARCO +}; + +tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window['tableInstance'] = tableInstance; + +// 监听图标点击事件,切换电话号码的显示状态 +tableInstance.on('click_cell', args => { + const { col, row, targetIcon } = args; + if (!targetIcon) return; + if (targetIcon.name === 'eye-open' || targetIcon.name === 'eye-close') { + const record = tableInstance.getCellOriginRecord(col, row); + if (!record) return; + const rowId = record.id; + // 切换该行的显示状态 + phoneVisible[rowId] = !phoneVisible[rowId]; + // 清理当前 tooltip,避免点击后仍残留旧图标的提示文案 + tableInstance.internalProps.tooltipHandler?._unbindFromCell?.(); + // 重建单元格以同步刷新号码和图标状态 + tableInstance.renderWithRecreateCells(); + } +}); +``` diff --git a/docs/assets/demo/zh/custom-render/complex-corner.md b/docs/assets/demo/zh/custom-render/complex-corner.md index 7c60a70928..c14b8794f7 100644 --- a/docs/assets/demo/zh/custom-render/complex-corner.md +++ b/docs/assets/demo/zh/custom-render/complex-corner.md @@ -71,9 +71,18 @@ const option = { }, corner: { titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, customLayout: args => { const { table, row, col, rect } = args; const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 50; const container = createGroup({ height, width @@ -81,12 +90,12 @@ const option = { // 定义文本内容的数组 const texts = [ - { text: '省份', fontSize: 18, x: 30, y: rect.height - 25 }, - { text: '城市', fontSize: 18, x: 105, y: rect.height - 25 }, - { text: '数据', fontSize: 18, x: rect.width - 50, y: rect.height - 35 }, - { text: '子类别', fontSize: 18, x: rect.width - 50, y: rect.height - 85 }, - { text: '类别', fontSize: 18, x: rect.width - 50, y: 18 }, - { text: '指标', fontSize: 16, x: 176, y: rect.height - 20 } + { text: '省份', fontSize: 18, x: provinceCenterX - 18, y: height - 25 }, + { text: '城市', fontSize: 18, x: cityCenterX - 18, y: height - 25 }, + { text: '数据', fontSize: 18, x: endCategoryX, y: height - 35 }, + { text: '子类别', fontSize: 18, x: endCategoryX, y: height - 85 }, + { text: '类别', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: '指标', fontSize: 16, x: indicatorX, y: height - 20 } ]; // 循环添加文本 @@ -105,17 +114,16 @@ const option = { // 定义线段的点 const linePoints = [ - { x: rect.left, y: rect.top }, { x: 0, y: 0 }, - { x: rect.width - 40, y: rect.height }, + { x: width - 40, y: height }, { x: 0, y: 0 }, - { x: 173, y: rect.height }, + { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, - { x: 84, y: rect.height }, + { x: provinceWidth, y: height }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 90 }, + { x: width, y: categoryHeight }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 38 }, + { x: width, y: height - 38 }, { x: 0, y: 0 } ]; @@ -398,6 +406,10 @@ const option = { rightFrozenColCount: 1, dragOrder: { dragHeaderMode: 'all' + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' } }; diff --git a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md index 36cb888639..8c3c1b88b4 100644 --- a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # 透视分析——小计总计 -透视分析表格数据汇总,dataConfig 中配置 totals 来设置行列维度的小计总计。该示例汇总总显示到底部,也可以通过 total.showGrandTotalsOnTop 来显示到顶部。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。行总计默认显示在底部,可通过 `showGrandTotalsOnTop` 调整到顶部;列总计默认显示在右侧,可通过 `showGrandTotalsOnLeft` 调整到左侧。[配置参考](../../option/PivotTable#dataConfig.totals) ## 关键配置 @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/demo/zh/gantt/gantt-zoom.md b/docs/assets/demo/zh/gantt/gantt-zoom.md index 1bc57c9eba..36ee410448 100644 --- a/docs/assets/demo/zh/gantt/gantt-zoom.md +++ b/docs/assets/demo/zh/gantt/gantt-zoom.md @@ -19,6 +19,7 @@ option: Gantt#zoomScale - API 获得当前缩放状态:使用 `getCurrentZoomState` 提供的方法 - API 设置当前缩放状态:使用 `setZoomPosition` 提供的方法 - API 缩放:使用 `zoomScaleManager` 提供的缩放方法 +- `markLine.style.lineWidth`: 可配置函数,根据当前缩放后的时间轴列宽动态计算标记线宽度 ## 代码演示 @@ -700,6 +701,27 @@ const option = { lineColor: '#f1f3f4' } }, + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: '固定宽度', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], headerRowHeight: 50, rowHeight: 40, overscrollBehavior: 'none' diff --git a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md index 682cfd9b1e..ca00ea1703 100644 --- a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['province'], // Group the grand total by province and append an overall subtotal grandTotalLabel: 'row total', subTotalLabel: 'Subtotal', showGrandTotalsOnTop: true, //totals show on top @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, with the row dimensions `['region', 'province']` shown above, setting `grandTotalDimensions: ['province']` displays a total for each province under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. + Online demo:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### Custom summary data diff --git a/docs/assets/guide/en/edit/edit_cell.md b/docs/assets/guide/en/edit/edit_cell.md index a8f2e0d7e6..3b0bbc049e 100644 --- a/docs/assets/guide/en/edit/edit_cell.md +++ b/docs/assets/guide/en/edit/edit_cell.md @@ -184,6 +184,35 @@ VTable.register.editor('custom-date', custom_date_editor); In the above example, we created a custom editor named `DateEditor` and implemented the methods required by the `IEditor` interface. Then, we register the custom editor into the VTable through the `VTable.register.editor` method for use in the table. +### Handling popup DOM from third-party components + +When a custom editor uses third-party components such as Select, Cascader, DatePicker, or Tooltip, the popup DOM may not be mounted inside the editor container. Many component libraries append popups to `body` through portal/teleport. In this case, when the user clicks a popup option, the click target is outside the editor container from VTable's perspective, and the editor may exit too early. + +It is recommended to configure a stable class on the popup root element and let `isEditorElement` check both the editor container and the popup DOM: + +```ts +const editorPopupClassName = 'vtable-editor-popup'; + +isEditorElement(target: HTMLElement) { + return this.element.contains(target) || this.isClickPopUp(target); +} + +isClickPopUp(target: HTMLElement) { + let current: HTMLElement | null = target; + while (current) { + if (current.classList?.contains(editorPopupClassName)) { + return true; + } + current = current.parentNode as HTMLElement | null; + } + return false; +} +``` + +Different component libraries expose different props for popup class names. For example, React Arco Select can use `dropdownMenuClassName`, Vue Arco Select can use `triggerProps.contentClass`, and Ant Design Select can use `classNames.popup.root` or `popupClassName` in older versions. See the React and Vue Arco Select custom editor demos for complete examples. + +If the third-party component has a close animation after selection, or if its internal implementation affects the order of `onChange` and blur events, update the editor's current value in the component change callback before calling `endEdit`. This prevents `getValue` from reading the old value. + `IEditor` [definition](https://github.com/VisActor/VTable/blob/main/packages/vtable-editors/src/types.ts): ```ts diff --git a/docs/assets/guide/en/gantt/gantt_markline.md b/docs/assets/guide/en/gantt/gantt_markline.md index 6324a1d1c1..f8aa562ae9 100644 --- a/docs/assets/guide/en/gantt/gantt_markline.md +++ b/docs/assets/guide/en/gantt/gantt_markline.md @@ -15,12 +15,12 @@ The configuration items of IMarkLine are as follows: ```javascript export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** The position of the mark line under the date column. The default is 'left'. */ position?: 'left' | 'right' | 'middle' | 'date'; - /** Automatically scroll the date range to include this mark line. */ + /** Automatically scroll the date range to include this mark line. */ scrollToMarkLine?: boolean; - content?: string; // markLine中内容 + content?: string; // markLine content /** The style of the content in the markLine. */ contentStyle?: { color?: string; @@ -31,6 +31,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ## Mark Line Style @@ -43,17 +57,54 @@ const ganttOptions = { { date: '2024-01-01', style: { - color: 'red', + lineColor: 'red', + lineWidth: 1, }, }, { date: '2024-01-02', style: { - color: 'blue', + lineColor: 'blue', + lineWidth: 2, }, }, ], }; +``` + +When the mark line width needs to change with timeline zooming, configure `style.lineWidth` as a function. The function is re-executed whenever the mark line refreshes, and its argument includes the current timeline column width, cell width, and milliseconds-per-pixel ratio: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + content: 'Dynamic width', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } + ] +}; +``` + +You can also configure the whole `style` as a function: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + style: ({ millisecondsPerPixel }) => ({ + lineWidth: millisecondsPerPixel > 86400000 ? 1 : 3, + lineColor: 'red' + }) + } + ] +}; +``` ## Mark Line Position @@ -110,4 +161,4 @@ const ganttOptions = { ], }; ``` -Normally, if the date '2024-01-01' is outside the display range of the Gantt chart, when the `scrollToMarkLine: true` configuration is set, the Gantt chart will automatically scroll to the mark line position. \ No newline at end of file +Normally, if the date '2024-01-01' is outside the display range of the Gantt chart, when the `scrollToMarkLine: true` configuration is set, the Gantt chart will automatically scroll to the mark line position. diff --git a/docs/assets/guide/en/theme_and_style/theme.md b/docs/assets/guide/en/theme_and_style/theme.md index 93f24c29fd..c94eb48dbf 100644 --- a/docs/assets/guide/en/theme_and_style/theme.md +++ b/docs/assets/guide/en/theme_and_style/theme.md @@ -1198,3 +1198,34 @@ const tableInstance = new VTable.PivotTable(option); ``` In short, in VTable, by flexibly using Theme configuration items, we can easily create an exclusive data table style that meets our needs. Please refer to this tutorial to make reasonable configurations based on actual scenarios, and give full play to VTable's powerful Theme customization capabilities. + +## Set the default theme + +Use `VTable.themes.setDefaultTheme(theme)` during application or component-library initialization to configure the theme used when a table has no explicit `theme`. This applies to ListTable, PivotTable and PivotChart. + +```javascript +const originalDefault = VTable.themes.DEFAULT; +VTable.themes.setDefaultTheme( + originalDefault.extends({ + defaultStyle: { fontFamily: 'Arial, sans-serif' }, + bodyStyle: { color: '#243447' }, + headerStyle: { bgColor: '#f2f4f7' } + }) +); + +// Uses the configured default without specifying option.theme. +const table = new VTable.ListTable({ container, columns, records }); + +// Extends the configured default using the existing theme API. +const customTheme = VTable.themes.DEFAULT.extends({ headerStyle: { color: '#345678' } }); + +// Restore the original default for subsequent operations. +VTable.themes.setDefaultTheme(originalDefault); +``` + +The argument is an `ITableThemeDefine` object or a `TableTheme`. It **replaces** the previous default; use `themes.DEFAULT.extends(...)` explicitly to merge with the current default. The function returns `void`. + +- `themes.DEFAULT`, the internal default fallback and the built-in `DEFAULT` name resolve to the new theme. User-registered names retain their existing lookup priority, including a user registration named `DEFAULT`. +- Existing instances and themes created earlier with `extends()` retain their theme objects and are not automatically redrawn. A later `updateOption()` without `theme` uses the current default. Use `table.updateTheme(VTable.themes.DEFAULT)` to explicitly apply it to an existing instance. +- Explicit instance themes, other built-in themes, theme registration and `updateTheme()` keep their existing behavior. Standalone partial themes do not inherit the configured default; use `extends()` when inheritance is intended. +- The setting is shared by callers using the same VTable runtime. Configure it before creating tables. It is not an instance-scoped setting or a mechanism for automatically switching all existing tables. diff --git a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md index 4d41ba4c14..e15a652dfc 100644 --- a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['province'], // 总计区域继续按 province 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如上述行维度为 `['region', 'province']` 时,将行总计配置为 `grandTotalDimensions: ['province']`,会在“行总计”下按省份分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 + 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### 自定义汇总数据 diff --git a/docs/assets/guide/zh/edit/edit_cell.md b/docs/assets/guide/zh/edit/edit_cell.md index 131974bee5..9b55dcc85b 100644 --- a/docs/assets/guide/zh/edit/edit_cell.md +++ b/docs/assets/guide/zh/edit/edit_cell.md @@ -189,6 +189,35 @@ VTable.register.editor('custom-date', custom_date_editor); 在上面的示例中,我们创建了一个名为`DateEditor`的自定义编辑器,并实现了`IEditor`接口所要求的方法。然后,我们通过`VTable.register.editor`方法将自定义编辑器注册到 VTable 中,以便在表格中使用。 +### 处理第三方组件的弹层 DOM + +当自定义编辑器使用 Select、Cascader、DatePicker、Tooltip 等第三方组件时,弹层 DOM 可能不会挂载在编辑器容器内部,而是通过 portal/teleport 追加到 `body`。这种情况下,用户点击弹层选项时,VTable 看到的点击目标不在编辑器容器内,可能会触发编辑器提前结束。 + +建议为第三方组件的弹层根节点配置一个稳定的 class,并在 `isEditorElement` 中同时判断编辑器容器和弹层 DOM: + +```ts +const editorPopupClassName = 'vtable-editor-popup'; + +isEditorElement(target: HTMLElement) { + return this.element.contains(target) || this.isClickPopUp(target); +} + +isClickPopUp(target: HTMLElement) { + let current: HTMLElement | null = target; + while (current) { + if (current.classList?.contains(editorPopupClassName)) { + return true; + } + current = current.parentNode as HTMLElement | null; + } + return false; +} +``` + +不同组件库暴露的弹层 class 配置项不同,例如 React Arco Select 可以通过 `dropdownMenuClassName` 配置,Vue Arco Select 可以通过 `triggerProps.contentClass` 配置,Ant Design Select 可以通过 `classNames.popup.root` 或旧版本的 `popupClassName` 配置。完整示例可参考 React 和 Vue 的 Arco Select 自定义编辑器 demo。 + +如果第三方组件在选中后存在关闭动画,或者 `onChange` 与失焦事件的触发顺序受组件内部实现影响,需要先在组件的变更回调中更新编辑器当前值,再按需调用 `endEdit` 结束编辑,避免 `getValue` 读取到旧值。 + `IEditor` 接口[定义](https://github.com/VisActor/VTable/blob/main/packages/vtable-editors/src/types.ts): ```ts diff --git a/docs/assets/guide/zh/gantt/gantt_markline.md b/docs/assets/guide/zh/gantt/gantt_markline.md index 33076d685f..3bc5bcc38d 100644 --- a/docs/assets/guide/zh/gantt/gantt_markline.md +++ b/docs/assets/guide/zh/gantt/gantt_markline.md @@ -15,10 +15,10 @@ IMarkLine 配置项如下: ```javascript export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; - /** 自动将日期范围内 包括改标记线 */ + /** 自动将日期范围内包括该标记线 */ scrollToMarkLine?: boolean; content?: string; // markLine中内容 /** markLine中内容的样式 */ @@ -31,6 +31,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ## 标记线样式 @@ -43,17 +57,54 @@ const ganttOptions = { { date: '2024-01-01', style: { - color: 'red', + lineColor: 'red', + lineWidth: 1, }, }, { date: '2024-01-02', style: { - color: 'blue', + lineColor: 'blue', + lineWidth: 2, }, }, ], }; +``` + +当标记线宽度需要跟随时间轴缩放变化时,可以将 `style.lineWidth` 配置为函数。函数会在标记线刷新时重新执行,参数中包含当前时间轴列宽、单元格宽度和毫秒/像素比例等信息: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } + ] +}; +``` + +也可以将整个 `style` 配置为函数: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + style: ({ millisecondsPerPixel }) => ({ + lineWidth: millisecondsPerPixel > 86400000 ? 1 : 3, + lineColor: 'red' + }) + } + ] +}; +``` ## 标记线位置 @@ -110,4 +161,4 @@ const ganttOptions = { ], }; ``` -正常如果日期 '2024-01-01'在甘特图的显示范围之外,当配置`scrollToMarkLine: true`时,甘特图会自动滚动到标记线位置。 \ No newline at end of file +正常如果日期 '2024-01-01'在甘特图的显示范围之外,当配置`scrollToMarkLine: true`时,甘特图会自动滚动到标记线位置。 diff --git a/docs/assets/guide/zh/theme_and_style/theme.md b/docs/assets/guide/zh/theme_and_style/theme.md index 3fa9bf4614..b3e70bedb1 100644 --- a/docs/assets/guide/zh/theme_and_style/theme.md +++ b/docs/assets/guide/zh/theme_and_style/theme.md @@ -1197,3 +1197,34 @@ const tableInstance = new VTable.PivotTable(document.getElementById(CONTAINER_ID ``` 总之,在 VTable 中,通过灵活运用主题配置项,我们可以轻松打造出专属的、符合需求的数据表格样式。请参照本教程,结合实际场景进行合理配置,发挥 VTable 强大的主题定制能力。 + +## 设置默认主题 + +应用或组件库可以在初始化时调用 `VTable.themes.setDefaultTheme(theme)`,配置表格未显式指定 `theme` 时使用的默认主题。该设置适用于 ListTable、PivotTable 和 PivotChart。 + +```javascript +const originalDefault = VTable.themes.DEFAULT; +VTable.themes.setDefaultTheme( + originalDefault.extends({ + defaultStyle: { fontFamily: 'Arial, sans-serif' }, + bodyStyle: { color: '#243447' }, + headerStyle: { bgColor: '#f2f4f7' } + }) +); + +// 无需传入 option.theme,即可使用配置后的默认主题。 +const table = new VTable.ListTable({ container, columns, records }); + +// 通过原有主题 API 继承配置后的默认主题。 +const customTheme = VTable.themes.DEFAULT.extends({ headerStyle: { color: '#345678' } }); + +// 恢复原始默认主题,供后续操作使用。 +VTable.themes.setDefaultTheme(originalDefault); +``` + +参数为 `ITableThemeDefine` 对象或 `TableTheme`,会**替换**上一次设置的默认主题;如需与当前默认主题合并,请显式使用 `themes.DEFAULT.extends(...)`。函数返回 `void`。 + +- `themes.DEFAULT`、内部默认回退和内置名称 `DEFAULT` 使用新的主题。用户注册的主题名称仍遵循原有查询优先级,包括用户注册的同名 `DEFAULT`。 +- 已有实例和此前通过 `extends()` 创建的主题保留其主题对象,不会自动重绘。之后调用未指定 `theme` 的 `updateOption()` 时使用当前默认主题;如需显式应用到已有实例,可调用 `table.updateTheme(VTable.themes.DEFAULT)`。 +- 显式指定的实例主题、其他内置主题、主题注册和 `updateTheme()` 保持原有行为。独立的局部主题不会继承配置后的默认值;需要继承时请使用 `extends()`。 +- 此设置由使用同一份 VTable 运行时的调用方共享,建议在创建表格前完成配置。它不是实例级设置,也不会自动切换所有已有表格的主题。 diff --git a/docs/assets/option/en/common/gantt/mark-line.md b/docs/assets/option/en/common/gantt/mark-line.md index 94f36bb184..2adb936518 100644 --- a/docs/assets/option/en/common/gantt/mark-line.md +++ b/docs/assets/option/en/common/gantt/mark-line.md @@ -5,7 +5,7 @@ IMarkLine specific definition: ``` export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** The position where the mark line is displayed under the date column. Default is 'left' */ position?: 'left' | 'right' | 'middle' | 'date'; /** Automatically include the mark line within the date range */ @@ -21,6 +21,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ${prefix} date(string) @@ -29,7 +43,7 @@ Specify date Required -${prefix} style(ILineStyle) +${prefix} style(IMarkLineStyle | (args: IMarkLineStyleArgumentType) => IMarkLineStyle) Mark line style @@ -37,6 +51,39 @@ Optional {{ use: common-gantt-line-style }} +`style` can be configured as a function. The function is re-executed whenever the mark line refreshes, such as after timeline zooming, so the style can be recalculated from the latest timeline size. + +`style.lineWidth` also supports a function value, which can be used to dynamically calculate the mark line width based on the current zoom state. + +Function arguments: + +``` +export type IMarkLineStyleArgumentType = { + date: Date; // The mark line date + dateIndex: number; // The date cell index where the mark line is located + dateX: number; // The mark line x position in the timeline coordinate system + cellStartX: number; // The start x position of the date cell containing the mark line + cellWidth: number; // The current width of the date cell containing the mark line + timelineColWidth: number; // The current timeline column width after zoom or scale changes + millisecondsPerPixel: number; // Current milliseconds represented by one pixel +}; +``` + +Example: + +```javascript +markLine: [ + { + date: '2024-07-17', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } +] +``` + ${prefix} position('left' | 'right' | 'middle' | 'date') The position where the mark line is displayed under the date column. Default is 'left' diff --git a/docs/assets/option/en/table/pivotChart.md b/docs/assets/option/en/table/pivotChart.md index 785e1cc019..e4988ef5a1 100644 --- a/docs/assets/option/en/table/pivotChart.md +++ b/docs/assets/option/en/table/pivotChart.md @@ -49,6 +49,10 @@ The currently supported data formats are, taking the sales of large supermarkets ] ``` +## dataConfig(IPivotTableDataConfig) + +PivotChart supports the PivotTable data analysis configuration. When totals are configured, use `totals.row.grandTotalDimensions` or `totals.column.grandTotalDimensions` to create grouped grand totals by retained lower-level dimensions. + ## columnTree(Array) List header tree, type:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`. Among them, IDimensionHeaderNode refers to the dimension value node of the dimension non-indicator, and IIndicatorHeaderNode refers to the indicator name node. @@ -240,4 +244,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/docs/assets/option/en/table/pivotTable.md b/docs/assets/option/en/table/pivotTable.md index 43e8cd36e4..d23c35eac1 100644 --- a/docs/assets/option/en/table/pivotTable.md +++ b/docs/assets/option/en/table/pivotTable.md @@ -225,6 +225,8 @@ export interface Total { showSubTotals: boolean; // Subtotal summary dimension definition subTotalsDimensions?: string[]; + // Lower-level dimensions retained in the grand total area for grouped totals. Only dimensions after the first dimension on the current axis are supported. + grandTotalDimensions?: string[]; //Default 'total' grandTotalLabel?: string; //Default 'Subtotal' diff --git a/docs/assets/option/zh/common/gantt/mark-line.md b/docs/assets/option/zh/common/gantt/mark-line.md index 6d3eccc461..2b6ad2424b 100644 --- a/docs/assets/option/zh/common/gantt/mark-line.md +++ b/docs/assets/option/zh/common/gantt/mark-line.md @@ -5,10 +5,10 @@ IMarkLine 具体定义: ``` export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; - /** 自动将日期范围内 包括改标记线 */ + /** 自动将日期范围内包括该标记线 */ scrollToMarkLine?: boolean; content?: string; // markLine中内容 /** markLine中内容的样式 */ @@ -21,6 +21,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ${prefix} date(string) @@ -29,7 +43,7 @@ ${prefix} date(string) 必填 -${prefix} style(ILineStyle) +${prefix} style(IMarkLineStyle | (args: IMarkLineStyleArgumentType) => IMarkLineStyle) 标记线样式 @@ -37,6 +51,39 @@ ${prefix} style(ILineStyle) {{ use: common-gantt-line-style }} +`style` 支持配置为函数,函数会在标记线刷新时重新执行,例如缩放时间轴后会基于最新时间轴尺寸重新计算样式。 + +`style.lineWidth` 也支持配置为函数,可用于根据当前缩放状态动态计算标记线宽度。 + +函数参数说明: + +``` +export type IMarkLineStyleArgumentType = { + date: Date; // 标记线对应日期 + dateIndex: number; // 标记线所在日期单元格索引 + dateX: number; // 标记线在时间轴坐标系中的 x 坐标 + cellStartX: number; // 标记线所在日期单元格起始 x 坐标 + cellWidth: number; // 标记线所在日期单元格当前宽度 + timelineColWidth: number; // 当前缩放或时间刻度下的时间轴列宽 + millisecondsPerPixel: number; // 当前每像素代表的毫秒数 +}; +``` + +示例: + +```javascript +markLine: [ + { + date: '2024-07-17', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } +] +``` + ${prefix} position('left' | 'right' | 'middle' | 'date') 标记线显示在日期列下的位置 默认为'left' diff --git a/docs/assets/option/zh/table/pivotChart.md b/docs/assets/option/zh/table/pivotChart.md index 76ca917c79..5ee93965d8 100644 --- a/docs/assets/option/zh/table/pivotChart.md +++ b/docs/assets/option/zh/table/pivotChart.md @@ -49,6 +49,10 @@ ] ``` +## dataConfig(IPivotTableDataConfig) + +透视图支持复用透视表的数据分析配置。配置总计时可使用 `totals.row.grandTotalDimensions` 或 `totals.column.grandTotalDimensions`,按保留的下级维度生成分组总计。 + ## columnTree(Array) 列表头树,类型为:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`。其中 IDimensionHeaderNode 指的是维度非指标的维度值节点,IIndicatorHeaderNode 指的是指标名称节点。 @@ -247,4 +251,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/docs/assets/option/zh/table/pivotTable.md b/docs/assets/option/zh/table/pivotTable.md index 34b21b536b..a201cd5f0d 100644 --- a/docs/assets/option/zh/table/pivotTable.md +++ b/docs/assets/option/zh/table/pivotTable.md @@ -229,6 +229,8 @@ export interface Total { showSubTotals: boolean; // 小计汇总维度定义 subTotalsDimensions?: string[]; + // 总计中保留的下级维度,用于在总计区域内继续按维度分组;仅支持当前行/列方向首层之后的维度 + grandTotalDimensions?: string[]; // 默认'总计' grandTotalLabel?: string; // 默认'小计' diff --git a/docs/package.json b/docs/package.json index cbab95dc3f..ded2797e0a 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.7", - "@visactor/vtable-gantt": "workspace:1.26.7", - "@visactor/react-vtable": "workspace:1.26.7", - "@visactor/vue-vtable": "workspace:1.26.7", - "@visactor/openinula-vtable": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-export": "workspace:1.26.7", - "@visactor/vtable-search": "workspace:1.26.7", - "@visactor/vtable-sheet": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", + "@visactor/react-vtable": "workspace:1.26.8", + "@visactor/vue-vtable": "workspace:1.26.8", + "@visactor/openinula-vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-export": "workspace:1.26.8", + "@visactor/vtable-search": "workspace:1.26.8", + "@visactor/vtable-sheet": "workspace:1.26.8", "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 78be8cbcd4..e6e2554334 100644 --- a/packages/openinula-vtable/package.json +++ b/packages/openinula-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/openinula-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The openinula version of VTable", "keywords": [ "openinula", @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 2df5245aa8..f3b9cce987 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The react version of VTable", "keywords": [ "react", @@ -58,7 +58,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@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 9731ded216..7f3f904a47 100644 --- a/packages/react-vtable/src/table-components/custom/custom-layout.tsx +++ b/packages/react-vtable/src/table-components/custom/custom-layout.tsx @@ -106,10 +106,11 @@ export const CustomLayout: React.FC = (props: PropsWithChildr const key = `${col}-${row}`; if (container.current.has(key)) { const currentContainer = container.current.get(key); - module.reconcilor.updateContainer(null, currentContainer, null); - // group = currentContainer.containerInfo; - currentContainer.containerInfo.delete(); - container.current.delete(key); + reconcilorUnmountContainer(module, currentContainer); + if (container.current.get(key) === currentContainer) { + currentContainer.containerInfo.delete(); + container.current.delete(key); + } } }, []); @@ -119,12 +120,20 @@ export const CustomLayout: React.FC = (props: PropsWithChildr container.current.clear(); return; } - container.current.forEach((value, key) => { - const currentContainer = value; - module.reconcilor.updateContainer(null, currentContainer, null); - currentContainer.containerInfo.delete(); + const pendingContainers = Array.from(container.current.entries()); + batchReconcilerUpdates(module, () => { + pendingContainers.forEach(([, currentContainer]) => { + requestReconcilerUnmountContainer(module, currentContainer); + }); + }); + flushReconcilerWork(module); + + pendingContainers.forEach(([key, currentContainer]) => { + if (container.current.get(key) === currentContainer) { + currentContainer.containerInfo.delete(); + container.current.delete(key); + } }); - container.current.clear(); }, []); useLayoutEffect(() => { @@ -219,10 +228,7 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem const updateContainerSync = (reconcilor as any).updateContainerSync; if (typeof updateContainerSync === 'function') { updateContainerSync(element, currentContainer, null); - const flushSyncWork = (reconcilor as any).flushSyncWork; - if (typeof flushSyncWork === 'function') { - flushSyncWork(); - } + flushReconcilerWork(module); return; } reconcilor.updateContainer(element, currentContainer, null); @@ -237,6 +243,60 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem // } } +function reconcilorUnmountContainer(module: ReconcilerModule, currentContainer: any): boolean { + requestReconcilerUnmountContainer(module, currentContainer); + return flushReconcilerWork(module); +} + +function requestReconcilerUnmountContainer(module: ReconcilerModule, currentContainer: any) { + const { reconcilor } = module; + const updateContainerSync = (reconcilor as any).updateContainerSync; + if (typeof updateContainerSync === 'function') { + try { + updateContainerSync(null, currentContainer, null); + return; + } catch { + reconcilor.updateContainer(null, currentContainer, null); + return; + } + } + reconcilor.updateContainer(null, currentContainer, null); +} + +function flushReconcilerWork(module: ReconcilerModule): boolean { + const { reconcilor } = module; + const flushSyncWork = (reconcilor as any).flushSyncWork; + if (typeof flushSyncWork === 'function') { + try { + const result = flushSyncWork(); + if (result === true) { + return false; + } + } catch { + return false; + } + } + + const flushPassiveEffects = (reconcilor as any).flushPassiveEffects; + if (typeof flushPassiveEffects === 'function') { + try { + flushPassiveEffects(); + } catch { + return false; + } + } + return true; +} + +function batchReconcilerUpdates(module: ReconcilerModule, callback: () => void) { + const batchedUpdates = (module.reconcilor as any).batchedUpdates; + if (typeof batchedUpdates === 'function') { + batchedUpdates(callback); + } else { + callback(); + } +} + function getCellRect(col: number, row: number, table: any) { const range = table.getCellRange(col, row); const rect = table.getCellsRect(range.start.col, range.start.row, range.end.col, range.end.row); diff --git a/packages/vtable-calendar/package.json b/packages/vtable-calendar/package.json index 38dc37ccae..e3f49260f9 100644 --- a/packages/vtable-calendar/package.json +++ b/packages/vtable-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-calendar", - "version": "1.26.7", + "version": "1.26.8", "description": "The calendar component of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@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 24c8edb847..5edf8d77eb 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-editors", - "version": "1.26.7", + "version": "1.26.8", "description": "", "sideEffects": false, "main": "cjs/index.js", diff --git a/packages/vtable-export/package.json b/packages/vtable-export/package.json index a431f3c962..7423cb5d94 100644 --- a/packages/vtable-export/package.json +++ b/packages/vtable-export/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-export", - "version": "1.26.7", + "version": "1.26.8", "description": "The export util of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "file-saver": "2.0.5", "@types/file-saver": "2.0.7", diff --git a/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts b/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts index bec18d12d3..062c97803a 100644 --- a/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts +++ b/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts @@ -7,6 +7,23 @@ import { Gantt } from '../src/index'; import { defaultPixelRatio } from '../src/tools/pixel-ratio'; describe('bugserver gantt initialization', () => { + function findFirstMarkLineShape(gantt) { + let result; + function walk(node) { + if (!node || result) { + return; + } + if (node.type === 'line') { + result = node; + return; + } + const children = node.children || node._children; + children?.forEach?.(child => walk(child)); + } + walk(gantt.scenegraph.markLine.markLIneContainer); + return result; + } + test('initializes gantt with mixed date formats and top-level columns', () => { const container = createDiv(); container.style.width = '800px'; @@ -109,6 +126,58 @@ describe('bugserver gantt initialization', () => { container.remove(); }); + test('recomputes markLine function style when refreshed after zoom scale changes', () => { + const container = createDiv(); + container.style.width = '800px'; + container.style.height = '400px'; + const calls = []; + + const gantt = new Gantt(container, { + records: [ + { id: 1, title: 'Task 1', start: '2024-07-05', end: '2024-07-10' }, + { id: 2, title: 'Task 2', start: '2024-07-11', end: '2024-07-15' } + ], + columns: [{ field: 'title', title: 'title', width: 200 }], + taskBar: { + startDateField: 'start', + endDateField: 'end' + }, + timelineHeader: { + colWidth: 30, + scales: [ + { unit: 'week', step: 1, startOfWeek: 'sunday' }, + { unit: 'day', step: 1 } + ] + }, + minDate: '2024-07-01', + maxDate: '2024-07-31', + markLine: [ + { + date: '2024-07-17', + style: { + lineColor: 'blue', + lineWidth: ({ timelineColWidth }) => { + calls.push(timelineColWidth); + return timelineColWidth > 60 ? 6 : 2; + } + } + } + ] + }); + + expect(findFirstMarkLineShape(gantt).attribute.lineWidth).toBe(2); + + gantt.parsedOptions.timelineColWidth = 80; + gantt.scenegraph.markLine.refresh(); + + expect(findFirstMarkLineShape(gantt).attribute.lineWidth).toBe(6); + expect(calls).toContain(30); + expect(calls).toContain(80); + + gantt.release?.(); + container.remove(); + }); + test('initializes the gantt stage with the configured pixel ratio', () => { const container = createDiv(); container.style.width = '1600px'; diff --git a/packages/vtable-gantt/examples/gantt/gantt-zoom.ts b/packages/vtable-gantt/examples/gantt/gantt-zoom.ts index b436c2dce5..b68161803c 100644 --- a/packages/vtable-gantt/examples/gantt/gantt-zoom.ts +++ b/packages/vtable-gantt/examples/gantt/gantt-zoom.ts @@ -1172,26 +1172,27 @@ export function createTable() { }, // minDate: '2024-07-01', // maxDate: '2024-10-15', - // markLine: [ - // { - // date: '2024-07-17', - // style: { - // lineWidth: 1, - // lineColor: 'blue', - // lineDash: [8, 4] - // } - // }, - // { - // date: '2024-08-17', - // position: 'middle', - // // scrollToMarkLine: true, - // style: { - // lineWidth: 2, - // lineColor: 'red', - // lineDash: [8, 4] - // } - // } - // ], + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: '固定宽度', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], rowSeriesNumber: { title: '行号', dragOrder: true, diff --git a/packages/vtable-gantt/package.json b/packages/vtable-gantt/package.json index baca2d03d9..cf57b8dc9a 100644 --- a/packages/vtable-gantt/package.json +++ b/packages/vtable-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-gantt", - "version": "1.26.7", + "version": "1.26.8", "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.7", - "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-gantt/src/gantt-helper.ts b/packages/vtable-gantt/src/gantt-helper.ts index 854ce69322..b7a3dd96f5 100644 --- a/packages/vtable-gantt/src/gantt-helper.ts +++ b/packages/vtable-gantt/src/gantt-helper.ts @@ -72,30 +72,38 @@ export function generateMarkLine(markLine?: boolean | IMarkLine | IMarkLine[]): ]; } else if (Array.isArray(markLine)) { return markLine.map((item, index) => { + const style = item.style; return { ...item, date: item.date, scrollToMarkLine: item.scrollToMarkLine, position: item.position ?? 'left', - style: { - lineColor: item.style?.lineColor || 'red', - lineWidth: item.style?.lineWidth || 1, - lineDash: item.style?.lineDash - } + style: + typeof style === 'function' + ? style + : { + lineColor: style?.lineColor || 'red', + lineWidth: style?.lineWidth ?? 1, + lineDash: style?.lineDash + } }; }); } + const style = (markLine as IMarkLine).style; return [ { ...markLine, date: (markLine as IMarkLine).date, scrollToMarkLine: (markLine as IMarkLine).scrollToMarkLine ?? true, position: (markLine as IMarkLine).position ?? 'left', - style: { - lineColor: (markLine as IMarkLine).style?.lineColor || 'red', - lineWidth: (markLine as IMarkLine).style?.lineWidth || 1, - lineDash: (markLine as IMarkLine).style?.lineDash - } + style: + typeof style === 'function' + ? style + : { + lineColor: style?.lineColor || 'red', + lineWidth: style?.lineWidth ?? 1, + lineDash: style?.lineDash + } } ]; } diff --git a/packages/vtable-gantt/src/scenegraph/mark-line.ts b/packages/vtable-gantt/src/scenegraph/mark-line.ts index 660e7e97e8..e7578a4d56 100644 --- a/packages/vtable-gantt/src/scenegraph/mark-line.ts +++ b/packages/vtable-gantt/src/scenegraph/mark-line.ts @@ -1,9 +1,18 @@ import { createDateAtMidnight } from '../tools/util'; -import { DayTimes } from '../gantt-helper'; -//import type { IMarkLine } from '../ts-types'; +import type { ILineStyle, IMarkLine, IMarkLineStyleArgumentType } from '../ts-types'; import type { Scenegraph } from './scenegraph'; import { Group, createLine, Text } from '@visactor/vtable/es/vrender'; +function resolveMarkLineStyle(line: IMarkLine, args: IMarkLineStyleArgumentType): ILineStyle { + const style = typeof line.style === 'function' ? line.style(args) : line.style; + const lineWidth = typeof style?.lineWidth === 'function' ? style.lineWidth(args) : style?.lineWidth; + return { + lineColor: style?.lineColor || 'red', + lineWidth: lineWidth ?? 1, + lineDash: style?.lineDash + }; +} + export class MarkLine { _scene: Scenegraph; group: Group; @@ -53,7 +62,6 @@ export class MarkLine { const minDate = this._scene._gantt.parsedOptions.minDate; minDate && markLine.forEach(line => { - const style = line.style; const contentStyle = line.contentStyle || {}; const date = this._scene._gantt.parsedOptions.timeScaleIncludeHour ? createDateAtMidnight(line.date) @@ -76,6 +84,15 @@ export class MarkLine { } else if (line.position === 'middle') { dateX = cellStartX + cellWidth / 2; } + const style = resolveMarkLineStyle(line, { + date, + dateIndex: cellIndex, + dateX, + cellStartX, + cellWidth, + timelineColWidth: this._scene._gantt.parsedOptions.timelineColWidth, + millisecondsPerPixel: this._scene._gantt.getCurrentMillisecondsPerPixel() + }); const markLineGroup = new Group({ pickable: false, x: dateX - this.markLineContainerWidth / 2, diff --git a/packages/vtable-gantt/src/ts-types/gantt-engine.ts b/packages/vtable-gantt/src/ts-types/gantt-engine.ts index cb33f7b05d..d4d837b611 100644 --- a/packages/vtable-gantt/src/ts-types/gantt-engine.ts +++ b/packages/vtable-gantt/src/ts-types/gantt-engine.ts @@ -328,6 +328,27 @@ export type ILineStyle = { lineWidth?: number; lineDash?: number[]; }; +/** markLine style function arguments, recalculated whenever markLine is refreshed, such as after zoom changes. */ +export type IMarkLineStyleArgumentType = { + /** The normalized markLine date. */ + date: Date; + /** The date cell index where the markLine is located. */ + dateIndex: number; + /** The markLine x position in the timeline coordinate system. */ + dateX: number; + /** The start x position of the date cell containing the markLine. */ + cellStartX: number; + /** The current width of the date cell containing the markLine. */ + cellWidth: number; + /** The current timeline column width after zoom or scale changes. */ + timelineColWidth: number; + /** Current milliseconds represented by one pixel. */ + millisecondsPerPixel: number; +}; +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; +export type IMarkLineStyleFunction = (args: IMarkLineStyleArgumentType) => IMarkLineStyle; export type IPointStyle = { strokeColor?: string; strokeWidth?: number; @@ -345,7 +366,7 @@ export interface IMarkLine { backgroundColor?: string; cornerRadius?: number | number[]; }; - style?: ILineStyle; + style?: IMarkLineStyle | IMarkLineStyleFunction; /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; /** 自动将日期范围内 包括改标记线 */ diff --git a/packages/vtable-plugins/package.json b/packages/vtable-plugins/package.json index 0486e0cc86..0f1f63cafa 100644 --- a/packages/vtable-plugins/package.json +++ b/packages/vtable-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-plugins", - "version": "1.26.7", + "version": "1.26.8", "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.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-gantt": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", "@visactor/vchart": "2.1.3", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 7d0ff32ce1..98003b3fe8 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-search", - "version": "1.26.7", + "version": "1.26.8", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vtable-sheet/package.json b/packages/vtable-sheet/package.json index f25091d3ef..6be894095c 100644 --- a/packages/vtable-sheet/package.json +++ b/packages/vtable-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-sheet", - "version": "1.26.7", + "version": "1.26.8", "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.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-plugins": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-plugins": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-sheet/src/components/vtable-sheet.ts b/packages/vtable-sheet/src/components/vtable-sheet.ts index 4122db8b26..9a80348b22 100644 --- a/packages/vtable-sheet/src/components/vtable-sheet.ts +++ b/packages/vtable-sheet/src/components/vtable-sheet.ts @@ -13,7 +13,7 @@ import type { IColumnDefine, SheetData } from '../ts-types'; -import type { MultiSheetImportResult } from '@visactor/vtable-plugins/src/excel-import/types'; +import type { MultiSheetImportResult } from '@visactor/vtable-plugins'; import type { TableEventHandlersEventArgumentMap } from '@visactor/vtable/es/ts-types/events'; import SheetTabDragManager from '../managers/tab-drag-manager'; import { FormulaAutocomplete } from '../formula/formula-autocomplete'; diff --git a/packages/vtable-sheet/tsconfig.json b/packages/vtable-sheet/tsconfig.json index 21a3c4de00..db86a17eeb 100644 --- a/packages/vtable-sheet/tsconfig.json +++ b/packages/vtable-sheet/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@internal/ts-config/tsconfig.base.json", "include": ["src"], - "compilerOptions": { + "exclude": ["src/test-shims"], + "compilerOptions": { "types": ["jest", "offscreencanvas", "node"], "lib": ["DOM", "ESNext"], "baseUrl": "./", @@ -32,4 +33,4 @@ "module": "commonjs" } } -} \ No newline at end of file +} diff --git a/packages/vtable/CHANGELOG.json b/packages/vtable/CHANGELOG.json index 34e307d147..68e3291770 100644 --- a/packages/vtable/CHANGELOG.json +++ b/packages/vtable/CHANGELOG.json @@ -1,6 +1,32 @@ { "name": "@visactor/vtable", "entries": [ + { + "version": "1.26.8", + "tag": "@visactor/vtable_v1.26.8", + "date": "Fri, 11 Sep 2026 09:49:37 GMT", + "comments": { + "none": [ + { + "comment": "feat: add themes.setDefaultTheme for application-wide default styling" + } + ], + "patch": [ + { + "comment": "fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290)" + }, + { + "comment": "fix: update nested fields by path" + }, + { + "comment": "fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274)" + }, + { + "comment": "fix: preserve pivot table column widths after updateOption (#5298)" + } + ] + } + }, { "version": "1.26.7", "tag": "@visactor/vtable_v1.26.7", diff --git a/packages/vtable/CHANGELOG.md b/packages/vtable/CHANGELOG.md index 8c454c62c8..4bfb9d6029 100644 --- a/packages/vtable/CHANGELOG.md +++ b/packages/vtable/CHANGELOG.md @@ -1,6 +1,20 @@ # Change Log - @visactor/vtable -This log was last generated on Thu, 20 Aug 2026 08:02:48 GMT and should not be manually modified. +This log was last generated on Fri, 11 Sep 2026 09:49:37 GMT and should not be manually modified. + +## 1.26.8 +Fri, 11 Sep 2026 09:49:37 GMT + +### Patches + +- fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) +- fix: update nested fields by path +- fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) +- fix: preserve pivot table column widths after updateOption (#5298) + +### Updates + +- feat: add themes.setDefaultTheme for application-wide default styling ## 1.26.7 Thu, 20 Aug 2026 08:02:48 GMT diff --git a/packages/vtable/__tests__/adaptive-width-resized-col.test.ts b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts new file mode 100644 index 0000000000..169f960bec --- /dev/null +++ b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts @@ -0,0 +1,56 @@ +import { getAdaptiveWidth } from '../src/scenegraph/layout/compute-col-width'; + +describe('adaptive width with resized columns', () => { + test('keeps manually resized columns fixed while distributing remaining width', () => { + const widths = [120, 100, 100, 100]; + const table: any = { + internalProps: { + _widthResizedColMap: new Set([0]) + }, + options: {}, + widthMode: 'adaptive', + defaultColWidth: 80, + getColWidth: jest.fn((col: number) => widths[col]), + getMaxColWidth: jest.fn(() => Number.MAX_SAFE_INTEGER), + getMinColWidth: jest.fn(() => 10), + getBodyColumnType: jest.fn(() => 'text'), + _adjustColWidth: jest.fn((_col: number, width: number) => width), + _setColWidth: jest.fn((col: number, width: number) => { + widths[col] = width; + }) + }; + + getAdaptiveWidth(800, 0, 4, false, [], table); + + expect(widths[0]).toBe(120); + expect(widths.slice(1)).toEqual([227, 227, 226]); + expect(widths.reduce((sum, width) => sum + width, 0)).toBe(800); + }); + + test('keeps autoFillWidth resized columns in width distribution', () => { + const widths = [85, 100, 180, 100]; + const table: any = { + internalProps: { + _widthResizedColMap: new Set([0]) + }, + options: {}, + widthMode: 'standard', + autoFillWidth: true, + defaultColWidth: 80, + getColWidth: jest.fn((col: number) => widths[col]), + getMaxColWidth: jest.fn(() => Number.MAX_SAFE_INTEGER), + getMinColWidth: jest.fn(() => 10), + getBodyColumnType: jest.fn(() => 'text'), + _adjustColWidth: jest.fn((_col: number, width: number) => width), + _setColWidth: jest.fn((col: number, width: number) => { + widths[col] = width; + }) + }; + + getAdaptiveWidth(800, 0, 4, false, [], table); + + expect(widths[0]).toBe(146); + expect(widths.slice(1)).toEqual([172, 310, 172]); + expect(widths.reduce((sum, width) => sum + width, 0)).toBe(800); + }); +}); diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts new file mode 100644 index 0000000000..0deaeddde3 --- /dev/null +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -0,0 +1,282 @@ +// @ts-nocheck +import { createGroupForFirstScreen } from '../src/scenegraph/group-creater/progress/create-group-for-first-screen'; +import { computeColsWidth } from '../src/scenegraph/layout/compute-col-width'; +import { computeRowsHeight } from '../src/scenegraph/layout/compute-row-height'; + +const { computeRowHeight, computeRowsHeight: computeRowsHeightActual } = jest.requireActual( + '../src/scenegraph/layout/compute-row-height' +); + +jest.mock('../src/scenegraph/layout/compute-col-width', () => ({ + computeColsWidth: jest.fn() +})); +jest.mock('../src/scenegraph/layout/compute-row-height', () => ({ + computeRowsHeight: jest.fn() +})); +jest.mock('../src/scenegraph/group-creater/column', () => ({ + createColGroup: jest.fn() +})); + +describe('createGroupForFirstScreen issue #5298', () => { + const makeGroup = () => ({ + firstChild: null, + lastChild: null, + setAttribute: jest.fn(), + setAttributes: jest.fn() + }); + + const createMockTable = () => ({ + widthMode: 'standard', + heightMode: 'standard', + options: {}, + colCount: 3, + rowCount: 3, + frozenColCount: 1, + frozenRowCount: 1, + rowHeaderLevelCount: 1, + leftRowSeriesNumberCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 0, + isListTable: () => false, + isPivotChart: () => false, + getFrozenColsWidth: () => 0, + getFrozenRowsHeight: () => 0, + scenegraph: { + leftBottomCornerGroup: makeGroup(), + rightTopCornerGroup: makeGroup(), + rightBottomCornerGroup: makeGroup(), + colHeaderGroup: makeGroup(), + rowHeaderGroup: makeGroup(), + bottomFrozenGroup: makeGroup(), + rightFrozenGroup: makeGroup(), + bodyGroup: makeGroup() + }, + internalProps: { + _widthResizedColMap: new Set([0]), + _heightResizedRowMap: new Set() + } + }); + + test('recalculates unresized columns when some columns keep manual widths', () => { + const table = createMockTable(); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 2); + }); + + test('stops scanning resized columns after finding an unresized column', () => { + const table = createMockTable(); + table.colCount = 10000; + table.widthMode = 'adaptive'; + table.internalProps._widthResizedColMap = { + has: jest.fn(col => col === 0) + }; + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(table.internalProps._widthResizedColMap.has).toHaveBeenCalledTimes(2); + expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 9999); + }); + + test('recalculates unresized rows when some rows keep manual heights', () => { + const table = createMockTable(); + table.internalProps._heightResizedRowMap = new Set([0]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).toHaveBeenCalledWith(table, 0, 2); + }); + + test('stops scanning resized rows after finding an unresized row', () => { + const table = createMockTable(); + table.rowCount = 10000; + table.heightMode = 'adaptive'; + table.internalProps._heightResizedRowMap = { + has: jest.fn(row => row === 0) + }; + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(table.internalProps._heightResizedRowMap.has).toHaveBeenCalledTimes(2); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 0, 9999); + }); + + test('syncs autoHeight visible rows even when all first screen rows keep manual heights', () => { + const table = createMockTable(); + table.heightMode = 'autoHeight'; + table.rowCount = 5; + table.tableNoFrameHeight = 100; + table.getBottomFrozenRowsHeight = () => 0; + table.getRowsHeight = jest.fn((start, end) => (end < 4 ? 50 : 120)); + table.internalProps._heightResizedRowMap = new Set([0, 1, 2]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + bodyBottomRow: 4, + bodyTopRow: 1, + totalActualBodyRowCount: 2, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).not.toHaveBeenCalledWith(table, 0, 2); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 3, 3, false); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 4, 4, false); + expect(proxy.totalRow).toBe(4); + expect(proxy.totalActualBodyRowCount).toBe(4); + }); + + test('does not recompute manually resized rows while filling autoHeight visible rows', () => { + const table = createMockTable(); + table.heightMode = 'autoHeight'; + table.rowCount = 5; + table.tableNoFrameHeight = 100; + table.getBottomFrozenRowsHeight = () => 0; + table.getRowsHeight = jest.fn((start, end) => (end < 4 ? 50 : 120)); + table.internalProps._heightResizedRowMap = new Set([0, 1, 2, 3]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + bodyBottomRow: 4, + bodyTopRow: 1, + totalActualBodyRowCount: 2, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).not.toHaveBeenCalledWith(table, 3, 3, false); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 4, 4, false); + }); + + test('keeps manually resized row height when computing row height directly', () => { + const table = { + internalProps: { + _heightResizedRowMap: new Set([1]) + }, + getRowHeight: jest.fn(() => 42), + isAutoRowHeight: jest.fn() + }; + + expect(computeRowHeight(1, 0, 2, table)).toBe(42); + expect(table.isAutoRowHeight).not.toHaveBeenCalled(); + }); + + test('uses an unresized representative row when filling fixed body row heights', () => { + const setRowHeight = jest.fn(); + const table = { + defaultHeaderRowHeight: 40, + defaultHeaderColWidth: 80, + defaultRowHeight: 33, + heightMode: 'standard', + heightAdaptiveMode: 'all', + autoFillHeight: false, + options: { + defaultRowHeight: 'auto' + }, + rowCount: 4, + colCount: 2, + columnHeaderLevelCount: 1, + rowHeaderLevelCount: 0, + bottomFrozenRowCount: 0, + internalProps: { + _heightResizedRowMap: new Set([1]), + layoutMap: { + getBody: jest.fn(() => ({ + cellType: 'text', + define: {} + })) + }, + transpose: false, + autoWrapText: false, + enableLineBreak: false, + useOneRowHeightFillAll: false + }, + isPivotTable: () => false, + isPivotChart: () => false, + isAutoRowHeight: () => false, + getDefaultRowHeight: jest.fn(row => (row === 1 ? 200 : 33)), + getRowHeight: jest.fn(row => (row === 1 ? 200 : 33)), + _getCellStyle: jest.fn(() => ({})), + _setRowHeight: setRowHeight, + _clearRowRangeHeightsMap: jest.fn() + }; + + computeRowsHeightActual(table, 0, 3); + + expect(table.getDefaultRowHeight).toHaveBeenCalledWith(2); + expect(setRowHeight).not.toHaveBeenCalledWith(1, expect.any(Number)); + expect(setRowHeight).toHaveBeenCalledWith(2, 33); + expect(setRowHeight).toHaveBeenCalledWith(3, 33); + }); +}); diff --git a/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts new file mode 100644 index 0000000000..b1782f5d96 --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts @@ -0,0 +1,124 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv } from '../dom'; + +describe('ListTable nested field updates', () => { + test('updates a nested field and reports its old and changed values', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, field, '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + row: 1, + recordIndex: 0, + field, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); + + test('updates nested fields in a batch change and reports the aggregate event', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + + table.changeCellValuesByRecords([{ recordIndex: 0, field, value: '12' }], { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(cellEvents).toHaveLength(1); + expect(cellEvents[0].changedValue).toBe(12); + expect(batchEvents).toHaveLength(1); + expect(batchEvents[0].values).toEqual(cellEvents); + table.release(); + }); + + test('updates an array field path and reports its old and changed values', () => { + const field = ['facts', '2025-02', 'qty']; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, [...field], '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + recordIndex: 0, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); + + test('matches copied array fields when resolving a custom sort function', () => { + const field = ['facts', 'qty']; + const orderFn = jest.fn(); + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity', sort: orderFn }], + records: [{ facts: { qty: 10 } }] + }); + + expect(table._getSortFuncFromHeaderOption(undefined, [...field])).toBe(orderFn); + table.release(); + }); + + test('reports stored values when updates create records', () => { + const field = ['facts', 'qty']; + const records: any[] = []; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + table.dataSource.beforeChangedRecordsMap.set('0', { facts: { qty: 0 } }); + + table.changeCellValueByRecord(0, [...field], '12', { + autoRefresh: false, + noTriggerChangeCellValuesEvent: true + }); + + expect(records[0].facts.qty).toBe(12); + expect(cellEvents[0].changedValue).toBe(12); + + table.dataSource.beforeChangedRecordsMap.set('1', { facts: { qty: 0 } }); + table.changeCellValuesByRecords([{ recordIndex: 1, field: [...field], value: '14' }], { autoRefresh: false }); + + expect(records[1].facts.qty).toBe(14); + expect(cellEvents[1].changedValue).toBe(14); + expect(batchEvents[0].values).toEqual([cellEvents[1]]); + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts new file mode 100644 index 0000000000..beef2f3b89 --- /dev/null +++ b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts @@ -0,0 +1,164 @@ +// @ts-nocheck +import { DataSource, getField, getRecordFieldValue } from '../../src/data/DataSource'; + +describe('DataSource nested field updates', () => { + test('updates a dotted field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('keeps array paths distinct from comma-delimited literal keys', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts,2025-02,qty': 11 }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + const table = { leftRowSeriesNumberCount: 0 } as any; + + dataSource.changeFieldValueByRecordIndex(18, 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBe(11); + expect(getRecordFieldValue(records[0], field)).toBe(18); + expect(getField(records[0], field, 0, 0, table, () => undefined)).toBe(18); + dataSource.release(); + }); + + test('updates a dotted field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('creates missing objects while updating a dotted field path', () => { + const records = [{}]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]).toEqual({ facts: { '2025-02': { qty: 18 } } }); + dataSource.release(); + }); + + test('prefers an existing literal dotted field over path traversal', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts.2025-02.qty': 11 }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]['facts.2025-02.qty']).toBe(18); + expect(records[0].facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('does not mutate inherited objects while creating a nested field path', () => { + const inherited = { facts: { '2025-02': { qty: 10 } } }; + const record = Object.create(inherited); + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(record).toHaveProperty('facts', { '2025-02': { qty: 18 } }); + expect(inherited.facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('recognizes nested fields in hasField', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 'facts.2025-02.qty')).toBe(true); + dataSource.release(); + }); + + test('recognizes numeric fields in array records', () => { + const records = [[10, 20]]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 0)).toBe(true); + expect(dataSource.hasField(0, 1)).toBe(true); + expect(dataSource.hasField(0, 2)).toBe(false); + dataSource.release(); + }); + + test('keeps array-path reads aligned when an intermediate value is falsy', () => { + const table = { leftRowSeriesNumberCount: 0 } as any; + const record = { + nested: { + zero: 0, + disabled: false, + empty: '' + } + }; + + for (const key of ['zero', 'disabled', 'empty']) { + const field = ['nested', key, 'missing']; + expect(getRecordFieldValue(record, field)).toBeUndefined(); + expect(getField(record, field, 0, 0, table, () => undefined)).toBeUndefined(); + } + }); + + test('writes a literal __proto__ field without changing the record prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, '__proto__'); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value).toBe('safe'); + dataSource.release(); + }); + + test('creates sensitive nested path keys without polluting Object.prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, 'constructor.prototype.polluted'); + + expect(Object.prototype.hasOwnProperty.call(record, 'constructor')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(record.constructor, 'prototype')).toBe(true); + expect(record.constructor.prototype.polluted).toBe('safe'); + expect(Object.prototype.polluted).toBeUndefined(); + + dataSource.changeFieldValueByRecordIndex('nested-safe', 0, ['__proto__', 'polluted']); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value.polluted).toBe('nested-safe'); + expect(Object.prototype.polluted).toBeUndefined(); + dataSource.release(); + }); +}); diff --git a/packages/vtable/__tests__/default-theme-table.test.ts b/packages/vtable/__tests__/default-theme-table.test.ts new file mode 100644 index 0000000000..1af75f5c8e --- /dev/null +++ b/packages/vtable/__tests__/default-theme-table.test.ts @@ -0,0 +1,69 @@ +import { ListTable, PivotTable, PivotChart, themes } from '../src'; +import { createDiv, removeDom } from './dom'; + +describe('default theme instance behavior', () => { + const original = themes.DEFAULT; + const tables: (ListTable | PivotTable | PivotChart)[] = []; + const containers: HTMLElement[] = []; + const columns = [{ field: 'name', title: 'Name' }]; + const records = [{ name: 'Example' }]; + + function container() { + const element = createDiv(); + element.style.width = '600px'; + element.style.height = '400px'; + containers.push(element); + return element; + } + + afterEach(() => { + tables.forEach(table => table.release()); + tables.length = 0; + containers.forEach(removeDom); + containers.length = 0; + themes.setDefaultTheme(original); + }); + + test('uses the configured default for all table types and both constructor forms', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + tables.push( + new ListTable(container(), { columns, records }), + new ListTable({ container: container(), columns, records }), + new PivotTable(container(), { records: [] }), + new PivotTable({ container: container(), records: [] }), + new PivotChart(container(), { records: [] }), + new PivotChart({ container: container(), records: [] }) + ); + tables.forEach(table => expect(table.theme.bodyStyle.color).toBe('#123456')); + }); + + test('leaves existing instances unchanged and uses the new default on updateOption', () => { + const table = new ListTable(container(), { columns, records }); + tables.push(table); + const previous = table.theme; + const previousColor = previous.bodyStyle.color; + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + + expect(table.theme).toBe(previous); + expect(table.theme.bodyStyle.color).toBe(previousColor); + table.updateOption({ columns, records }); + expect(table.theme.bodyStyle.color).toBe('#123456'); + }); + + test('preserves explicit themes, replacement updates and property assignment', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + const custom = themes.DARK.extends({ bodyStyle: { color: '#654321' } }); + const table = new ListTable(container(), { columns, records, theme: custom }); + tables.push(table); + + expect(table.theme.bodyStyle.color).toBe('#654321'); + table.updateTheme({ headerStyle: { color: '#abcdef' } }); + expect(table.theme.bodyStyle.color).toBe(themes.of({}).bodyStyle.color); + table.theme = custom; + expect(table.theme.bodyStyle.color).toBe('#654321'); + table.updateTheme(themes.DEFAULT); + expect(table.theme.bodyStyle.color).toBe('#123456'); + table.updateOption({ columns, records, theme: custom }); + expect(table.theme.bodyStyle.color).toBe('#654321'); + }); +}); diff --git a/packages/vtable/__tests__/default-theme.test.ts b/packages/vtable/__tests__/default-theme.test.ts new file mode 100644 index 0000000000..345479546f --- /dev/null +++ b/packages/vtable/__tests__/default-theme.test.ts @@ -0,0 +1,72 @@ +import internalThemes, * as themes from '../src/themes'; +import { theme as registerTheme, clearAll } from '../src/register'; + +describe('setDefaultTheme', () => { + const original = themes.DEFAULT; + + afterEach(() => { + clearAll(); + themes.setDefaultTheme(original); + }); + + test('keeps the public export, internal fallback and name lookup in sync', () => { + const registry = themes.get(); + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + + expect(internalThemes.DEFAULT).toBe(themes.DEFAULT); + expect(registry.DEFAULT).toBe(themes.DEFAULT); + expect(themes.of('default')).toBe(themes.DEFAULT); + expect(themes.DEFAULT.bodyStyle.color).toBe('#123456'); + expect(themes.DEFAULT.extends({ headerStyle: { color: '#654321' } }).bodyStyle.color).toBe('#123456'); + }); + + test('replaces styles without reusing previously resolved caches or accumulating overrides', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + const previous = themes.DEFAULT; + const extended = previous.extends({ headerStyle: { color: '#654321' } }); + expect(previous.bodyStyle.color).toBe('#123456'); + + themes.setDefaultTheme(original.extends({ headerStyle: { color: '#abcdef' } })); + + expect(themes.DEFAULT).not.toBe(previous); + expect(themes.DEFAULT.bodyStyle.color).toBe(original.bodyStyle.color); + expect(themes.DEFAULT.headerStyle.color).toBe('#abcdef'); + expect(previous.bodyStyle.color).toBe('#123456'); + expect(extended.bodyStyle.color).toBe('#123456'); + }); + + test('preserves inherited values and callback styles when installing a TableTheme', () => { + const bgColor = () => '#abcdef'; + const parent = original.extends({ bodyStyle: { color: '#123456', bgColor } }); + const child = parent.extends({ headerStyle: { color: '#654321' } }); + themes.setDefaultTheme(child); + + expect(themes.DEFAULT).not.toBe(child); + expect(themes.DEFAULT.bodyStyle.color).toBe('#123456'); + expect(themes.DEFAULT.bodyStyle.bgColor).toBe(bgColor); + expect(themes.DEFAULT.headerStyle.color).toBe('#654321'); + }); + + test('does not merge the default into standalone or registered themes', () => { + const custom = { headerStyle: { color: '#654321' } }; + const fallbackColor = themes.of(custom).bodyStyle.color; + registerTheme('custom-default-test', custom); + themes.setDefaultTheme({ defaultStyle: { color: '#123456' } }); + + expect(themes.of(custom).bodyStyle.color).toBe(fallbackColor); + expect(themes.of('custom-default-test').bodyStyle.color).toBe(fallbackColor); + expect(themes.of('DARK')).toBe(themes.DARK); + expect(themes.of('missing-default-test')).toBeNull(); + }); + + test('keeps the existing registered-name precedence and clear behavior', () => { + const registered = original.extends({ bodyStyle: { color: '#654321' } }); + registerTheme('DEFAULT', registered); + themes.setDefaultTheme({ defaultStyle: { color: '#123456' } }); + + expect(themes.of('DEFAULT')).toBe(registered); + expect(internalThemes.DEFAULT.bodyStyle.color).toBe('#123456'); + clearAll(); + expect(themes.of('DEFAULT')).toBe(themes.DEFAULT); + }); +}); diff --git a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts index beeac72e7a..2fff7b8ed2 100644 --- a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts +++ b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts @@ -7184,4 +7184,49 @@ describe('get-data-cell-path test', () => { cellLocation: 'body' }); }); + + test('get-data-cell-path returns the grouped grand total path', () => { + const data = { organization: '公司一', type: '银票', balance: 100 }; + const path = getDataCellPath( + { + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [data], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + showGrandTotalsOnTop: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }, + data + ); + + expect(path?.rowHeaderPaths[0]).toMatchObject({ + dimensionKey: 'organization', + value: '合计' + }); + expect(path?.rowHeaderPaths[0].dataValue).toContain('grouped_grand_total'); + }); }); diff --git a/packages/vtable/__tests__/media-click.test.ts b/packages/vtable/__tests__/media-click.test.ts new file mode 100644 index 0000000000..72b7c908dc --- /dev/null +++ b/packages/vtable/__tests__/media-click.test.ts @@ -0,0 +1,39 @@ +import { bindMediaClick } from '../src/event/media-click'; +import { Env, type EnvMode } from '../src/tools/env'; +import type { MousePointerCellEvent } from '../src/ts-types'; +import type { BaseTableAPI } from '../src/ts-types/base-table'; + +describe('bindMediaClick', () => { + let originalMode: EnvMode; + + beforeEach(() => { + originalMode = Env.mode; + Env.mode = 'browser'; + document.body.innerHTML = ''; + }); + + afterEach(() => { + Env.mode = originalMode; + document.body.innerHTML = ''; + }); + + test.each(['image', 'audio', 'video'] as const)('does not open a %s preview for an empty cell', cellType => { + let clickHandler: ((event: MousePointerCellEvent) => void) | undefined; + const table = { + addReleaseObj: jest.fn(), + on: jest.fn((_event: string, handler: (event: MousePointerCellEvent) => void) => { + clickHandler = handler; + }), + getCellType: jest.fn(() => cellType), + isHeader: jest.fn(() => false), + getBodyColumnDefine: jest.fn(() => ({ clickToPreview: true })), + getCellValue: jest.fn(() => null), + getCellOriginValue: jest.fn(() => null) + } as unknown as BaseTableAPI; + + bindMediaClick(table); + clickHandler?.({ col: 0, row: 0, target: { type: 'rect' } } as MousePointerCellEvent); + + expect(document.body.childElementCount).toBe(0); + }); +}); diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index 729075a0b8..f61199a49c 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9249,6 +9249,66 @@ function createTable(containerDom) { const tableInstance = new VTable.PivotChart(option); return tableInstance; } + +describe('pivotChart grouped grand total collected values', () => { + test('collects chart ranges under grouped grand total paths while ignoring external total records', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotChart = new VTable.PivotChart({ + container: containerDom, + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const groupedKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}银票`; + const groupedSubtotalKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${ + pivotChart.dataset.stringJoinChar + }${String.fromCharCode(1)}vtable_grouped_grand_total_subtotal`; + + expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ + min: 400, + max: 400 + }); + expect(pivotChart.dataset.collectedValues.balance[groupedSubtotalKey]).toMatchObject({ + min: 400, + max: 400 + }); + pivotChart.release(); + }); +}); + describe('pivotChart init test', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index ffff4f483a..980936d15c 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -378,3 +378,683 @@ describe('pivotTable-analysis init test', () => { pivotTable.release(); }); }); + +describe('pivotTable grand total grouped by lower-level dimension', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + function findBodyCell(rowValues: string[], colValues: string[] = [], role?: 'normal' | 'grand-total') { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const rowRole = pivotTable.getCellPivotRole(col, row).rowRole; + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + (!role || rowRole === role) + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${rowValues.join('/')}`); + } + + test('creates grouped grand total headers and values', () => { + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '信用证'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1000); + }); + + test('keeps grouped grand total headers after sorting updates', () => { + pivotTable.updateSortRules([ + { + sortField: 'type', + sortType: VTable.TYPES.SortType.DESC + } + ]); + + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '信用证', '商票', '小计']); + }); + + test('recalculates grouped and overall grand totals after editing', () => { + const detailCell = findBodyCell(['公司一', '银票'], [], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], [], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], [], 'grand-total'); + + expect(pivotTable.getCellOriginValue(detailCell.col, detailCell.row)).toBe(150); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable column grand total grouped by lower-level dimension', () => { + test('creates grouped column grand total headers and values', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + + const findBodyCell = (colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const values = paths.colHeaderPaths.filter(path => path.dimensionKey).map(path => path.value as string); + if (values.join('|') === colValues.join('|') && pivotTable.getCellPivotRole(col, row).colRole === role) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${colValues.join('/')}`); + }; + const detailCell = findBodyCell(['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], 'grand-total'); + + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable grouped grand total edge cases', () => { + function createPivotTable( + records: any[], + options: { + rows?: string[]; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; + grandTotalDimensions?: string[]; + showSubTotalsOnTop?: boolean; + showSubTotalsOnTreeNode?: boolean; + aggregationRules?: any[]; + sortRules?: any[]; + filterRules?: any[]; + } = {} + ) { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + return new PivotTable(containerDom, { + rows: options.rows ?? ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + rowHierarchyType: options.rowHierarchyType, + rowExpandLevel: 3, + records, + dataConfig: { + updateAggregationOnEditCell: true, + aggregationRules: options.aggregationRules, + sortRules: options.sortRules, + filterRules: options.filterRules, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: options.grandTotalDimensions ?? ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计', + showSubTotalsOnTop: options.showSubTotalsOnTop, + showSubTotalsOnTreeNode: options.showSubTotalsOnTreeNode + } + } + } + }); + } + + function findBodyCell( + pivotTable: PivotTable, + rowValues: string[], + role?: 'normal' | 'grand-total' + ): { col: number; row: number } { + const candidates: string[] = []; + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const values = pivotTable + .getCellHeaderPaths(col, row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.value as string); + candidates.push(`${values.join('/')} (${pivotTable.getCellPivotRole(col, row).rowRole})`); + if ( + values.join('|') === rowValues.join('|') && + (!role || pivotTable.getCellPivotRole(col, row).rowRole === role) + ) { + return { col, row }; + } + } + } + const availableCells = Array.from(new Set(candidates)).join(', '); + throw new Error(`Unable to find body cell for ${rowValues.join('/')}: ${availableCells}`); + } + + test('keeps records whose dimension values match total labels separate from grouped totals', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '小计', balance: 100 }, + { organization: '公司二', type: '小计', balance: 300 } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + const groupedNode = grandTotalNode.children[0]; + const overallNode = grandTotalNode.children[1]; + + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(100); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, groupedNode.dataValue], [], 'balance').value() + ).toBe(400); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, overallNode.dataValue], [], 'balance').value() + ).toBe(400); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '小计'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(400); + pivotTable.release(); + }); + + test('uses active dimension indexes when an intermediate dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + { + rows: ['organization', 'unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('keeps a configured lower-level total when the first dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + rows: ['unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); + pivotTable.release(); + }); + + test('only uses filtered records to determine active grouped total dimensions', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + filterRules: [{ filterFunc: record => record.organization !== '公司一' }] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + + test('keeps column values matching the grand total label separate from grouped totals', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.role === 'grand-total'); + + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(100); + expect( + pivotTable.dataset + .getAggregator([], [grandTotalNode.dataValue, grandTotalNode.children[0].dataValue], 'balance') + .value() + ).toBe(400); + pivotTable.release(); + }); + + test('does not classify retained-dimension records as totals when totals are hidden', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: false, + grandTotalDimensions: ['type'] + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['银票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + + test('resolves column grouped totals independently for each row', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['region'], + columns: ['organization', 'type'], + indicators: ['balance'], + records: [ + { region: '华北', organization: '合计', type: '银票', balance: 100 }, + { region: '华东', organization: '公司一', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['华东'], ['合计', '银票'], 'balance').value()).toBe(300); + pivotTable.release(); + }); + + test('passes the custom aggregation function to grouped and overall totals', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 }, + { organization: '公司二', type: '商票', balance: 500 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length + } + ] + } + ); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(150); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(275); + + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBeCloseTo(433.33, 2); + pivotTable.release(); + }); + + test('recalculates custom aggregation records after editing a multi-record cell', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ] + } + ); + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(800); + pivotTable.release(); + }); + + test('recalculates custom grouped total intersections after editing', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: ['category', 'channel'], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 100 }, + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 200 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ], + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + }, + column: { + showGrandTotals: true, + grandTotalDimensions: ['channel'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const findCell = (rowValues: string[], colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const roles = pivotTable.getCellPivotRole(col, row); + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + roles.rowRole === role && + roles.colRole === role + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find ${rowValues.join('/')} and ${colValues.join('/')}`); + }; + const detailCell = findCell(['公司一', '银票'], ['票据', '线上'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + const intersection = findCell(['合计', '银票'], ['合计', '线上'], 'grand-total'); + expect(pivotTable.getCellOriginValue(intersection.col, intersection.row)).toBe(500); + pivotTable.release(); + }); + + test('keeps custom overall total records effective with grouped grand totals', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(999); + pivotTable.release(); + }); + + test('uses custom grouped total records for retained dimensions', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(999); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('sorts grouped totals by indicator values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 200 } + ], + { + sortRules: [ + { + sortField: 'type', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '信用证', '银票', '小计']); + pivotTable.release(); + }); + + test('supports multiple retained dimensions and grid-tree lookup', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 300 }, + { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 }, + { category: '票据', balance: 999 } + ], + { + rows: ['organization', 'category', 'type'], + rowHierarchyType: 'grid-tree', + grandTotalDimensions: ['category', 'type'], + showSubTotalsOnTreeNode: true + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); + expect(grandTotalNode.children[0].levelSpan).toBe(1); + expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(999); + expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); + const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); + const groupedPath = pivotTable + .getCellHeaderPaths(groupedCell.col, groupedCell.row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.dataValue ?? path.value) + .join(pivotTable.dataset.stringJoinChar); + expect((pivotTable.internalProps.layoutMap as any).getRowKeysPath(groupedCell.col, groupedCell.row)).toBe( + groupedPath + ); + pivotTable.release(); + }); + + test('sorts non-leaf grouped totals by their own aggregated values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司三', category: '贷款', type: '信用贷', balance: 500 }, + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 600 } + ], + { + rows: ['organization', 'category', 'type'], + grandTotalDimensions: ['category', 'type'], + sortRules: [ + { + sortField: 'category', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + pivotTable.release(); + }); + + test('round-trips grouped total header paths when labels collide with data', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ]); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '银票'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); + pivotTable.release(); + }); + + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { + const pivotTable = createPivotTable( + [ + { organization: 'A', type: 'A', balance: 100 }, + { organization: 'A', type: 'B', balance: 200 }, + { organization: 'B', type: 'A', balance: 300 } + ], + { + rowHierarchyType: 'tree', + showSubTotalsOnTop: true + } + ); + pivotTable.updateSortRules([ + { sortField: 'organization', sortType: VTable.TYPES.SortType.ASC }, + { sortField: 'type', sortType: VTable.TYPES.SortType.DESC } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['小计', 'B', 'A']); + expect(pivotTable.dataset.getAggregator(['合计', 'B'], [], 'balance').value()).toBe(200); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', 'B'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); + pivotTable.release(); + }); +}); diff --git a/packages/vtable/__tests__/pivotTable.test.ts b/packages/vtable/__tests__/pivotTable.test.ts index 9fcf5b10c1..491c9a07c8 100644 --- a/packages/vtable/__tests__/pivotTable.test.ts +++ b/packages/vtable/__tests__/pivotTable.test.ts @@ -675,6 +675,17 @@ describe('pivotTable init test', () => { pivotTable.stateManager.endMoveCol(); expect(pivotTable.getCellValue(3, 1)).toEqual('利润'); }); + test('pivotTable updateOption keeps default header col width aligned with default col width', () => { + pivotTable.updateOption( + { + ...option, + defaultColWidth: 120 + }, + { clearColWidthCache: false } + ); + + expect(pivotTable.internalProps.defaultHeaderColWidth).toBe(120); + }); test('pivotTable updateOption hideIndicatorName&format', () => { indicators[0].format = rec => { return rec?.['利润'] ?? '0' + '元'; diff --git a/packages/vtable/examples/custom/complex-corner.ts b/packages/vtable/examples/custom/complex-corner.ts index 5948f97184..6701fecb0a 100644 --- a/packages/vtable/examples/custom/complex-corner.ts +++ b/packages/vtable/examples/custom/complex-corner.ts @@ -1,397 +1,406 @@ import * as VTable from '../../src'; -import { bindDebugTool } from '../../src/scenegraph/debug-tool'; +import VChart from '@visactor/vchart'; const BUGSERVER_CONTAINER_ID = 'vTable'; -const VCHART_NPM_CDN = 'https://unpkg.com/@visactor/vchart@latest/build/index.min.js'; +VTable.register.chartModule('vchart', VChart); -// your code here -import('https://unpkg.com/@visactor/vchart@latest/build/index.min.js') - .then(async () => { - VTable.register.chartModule('vchart', window.VChart.default); - const dom = document.querySelector('#' + BUGSERVER_CONTAINER_ID); - dom.style.width = '800px'; - dom.style.height = '800px'; - const option = { - autoFillHeight: true, - rows: ['province', 'city'], - columns: ['category', 'sub_category'], - indicators: ['sales', 'number'], - defaultHeaderColWidth: ['auto', 'auto', 120], - defaultHeaderRowHeight: ['auto', 90], - indicatorTitle: '指标名称', - indicatorsAsCol: false, - customMergeCell: (col, row, table) => { - if (col >= 0 && col < 3 && row <= 1) { - return { - text: 'merge text', - range: { - start: { - col: 0, - row: 0 - }, - end: { - col: 2, - row: 1 - } +export function createTable() { + const dom = document.querySelector('#' + BUGSERVER_CONTAINER_ID); + dom.style.width = '800px'; + dom.style.height = '800px'; + const option = { + autoFillHeight: true, + rows: ['province', 'city'], + columns: ['category', 'sub_category'], + indicators: ['sales', 'number'], + defaultHeaderColWidth: ['auto', 'auto', 120], + defaultHeaderRowHeight: ['auto', 90], + indicatorTitle: '指标名称', + indicatorsAsCol: false, + customMergeCell: (col, row, table) => { + if (col >= 0 && col < 3 && row <= 1) { + return { + text: 'merge text', + range: { + start: { + col: 0, + row: 0 }, - style: { - bgColor: '#ECF1F5' + end: { + col: 2, + row: 1 } - }; - } - }, - dataConfig: { - totals: { - row: { - showGrandTotals: true, - showSubTotals: true, - subTotalsDimensions: ['province'], - grandTotalLabel: '行总计', - subTotalLabel: '小计' }, - column: { - showGrandTotals: true, - showSubTotals: true, - subTotalsDimensions: ['category'], - grandTotalLabel: '列总计', - subTotalLabel: '小计' + style: { + bgColor: '#ECF1F5' } + }; + } + }, + dataConfig: { + totals: { + row: { + showGrandTotals: true, + showSubTotals: true, + subTotalsDimensions: ['province'], + grandTotalLabel: '行总计', + subTotalLabel: '小计' + }, + column: { + showGrandTotals: true, + showSubTotals: true, + subTotalsDimensions: ['category'], + grandTotalLabel: '列总计', + subTotalLabel: '小计' } - }, - corner: { - merge: true, - titleOnDimension: 'row', - customLayout: (args: any) => { - const {table, row, col, rect} = args; - const {height, width} = rect ?? table.getCellRect(col, row); - const container = new VTable.CustomLayout.Group({ - height, - width, - }); - - // 定义文本内容的数组 - const texts = [ - {text: '省', fontSize: 18, x: 30, y: rect.height - 25}, - {text: '市', fontSize: 18, x: 105, y: rect.height - 25}, - {text: '数据', fontSize: 18, x: rect.width - 50, y: rect.height - 35}, - {text: '物品', fontSize: 18, x: rect.width - 50, y: rect.height - 85}, - {text: '种类', fontSize: 18, x: rect.width - 50, y: 18}, - {text: '类型', fontSize: 16, x: 176, y: rect.height - 20}, - ]; + } + }, + corner: { + merge: true, + titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, + customLayout: (args: any) => { + const { table, row, col, rect } = args; + const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 50; + const container = new VTable.CustomLayout.Group({ + height, + width + }); - // 循环添加文本 - texts.forEach(({text, fontSize, x, y}) => { - container.addChild( - new VTable.CustomLayout.Text({ - text, - fontSize, - fontFamily: 'sans-serif', - fill: 'black', - x, - y, - }) - ); - }); + // 定义文本内容的数组 + const texts = [ + { text: '省份', fontSize: 18, x: provinceCenterX - 18, y: height - 25 }, + { text: '城市', fontSize: 18, x: cityCenterX - 18, y: height - 25 }, + { text: '数据', fontSize: 18, x: endCategoryX, y: height - 35 }, + { text: '子类别', fontSize: 18, x: endCategoryX, y: height - 85 }, + { text: '类别', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: '指标', fontSize: 16, x: indicatorX, y: height - 20 } + ]; - // 定义线段的点 - const linePoints = [ - {x: rect.left, y: rect.top}, - {x: rect.width - 40, y: rect.height}, - {x: 173, y: rect.height}, - {x: 0, y: 0}, - {x: 84, y: rect.height}, - {x: 0, y: 0}, - {x: rect.width, y: rect.height - 90}, - {x: 0, y: 0}, - {x: rect.width, y: rect.height - 38}, - {x: 0, y: 0}, - ]; - - // 添加线段 + // 循环添加文本 + texts.forEach(({ text, fontSize, x, y }) => { container.addChild( - new VTable.CustomLayout.Line({ - points: linePoints, - lineWidth: 1, - stroke: '#ccc', + new VTable.CustomLayout.Text({ + text, + fontSize, + fontFamily: 'sans-serif', + fill: 'black', + x, + y }) ); + }); - return { - rootContainer: container, - renderDefault: false, - enableCellPadding: false, - }; - } + // 定义线段的点 + const linePoints = [ + { x: 0, y: 0 }, + { x: width - 40, y: height }, + { x: 0, y: 0 }, + { x: provinceWidth + cityWidth + 3, y: height }, + { x: 0, y: 0 }, + { x: provinceWidth, y: height }, + { x: 0, y: 0 }, + { x: width, y: categoryHeight }, + { x: 0, y: 0 }, + { x: width, y: height - 38 }, + { x: 0, y: 0 } + ]; + + // 添加线段 + container.addChild( + new VTable.CustomLayout.Line({ + points: linePoints, + lineWidth: 1, + stroke: '#ccc' + }) + ); + + return { + rootContainer: container, + renderDefault: false, + enableCellPadding: false + }; + } + }, + records: [ + { + sales: 891, + number: 77899999, + province: '浙江省', + city: '杭州市', + category: '家具', + sub_category: '桌子' }, - records: [ - { - sales: 891, - number: 77899999, - province: '浙江省', - city: '杭州市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 792, - number: 2367, - province: '浙江省', - city: '绍兴市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 893, - number: 3877, - province: '浙江省', - city: '宁波市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 1094, - number: 4342, - province: '浙江省', - city: '舟山市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 1295, - number: 5343, - province: '浙江省', - city: '杭州市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 496, - number: 632, - province: '浙江省', - city: '绍兴市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 1097, - number: 7234, - province: '浙江省', - city: '宁波市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 998, - number: 834, - province: '浙江省', - city: '舟山市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 766, - number: 945, - province: '浙江省', - city: '杭州市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 990, - number: 1304, - province: '浙江省', - city: '绍兴市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 891, - number: 1145, - province: '浙江省', - city: '宁波市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 792, - number: 1432, - province: '浙江省', - city: '舟山市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 745, - number: 1343, - province: '浙江省', - city: '杭州市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 843, - number: 1354, - province: '浙江省', - city: '绍兴市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 895, - number: 1523, - province: '浙江省', - city: '宁波市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 965, - number: 1634, - province: '浙江省', - city: '舟山市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 776, - number: 1723, - province: '四川省', - city: '成都市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 634, - number: 1822, - province: '四川省', - city: '绵阳市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 909, - number: 1943, - province: '四川省', - city: '南充市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 399, - number: 2330, - province: '四川省', - city: '乐山市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 700, - number: 2451, - province: '四川省', - city: '成都市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 689, - number: 2244, - province: '四川省', - city: '绵阳市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 500, - number: 2333, - province: '四川省', - city: '南充市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 800, - number: 2445, - province: '四川省', - city: '乐山市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 1044, - number: 2335, - province: '四川省', - city: '成都市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 689, - number: 245, - province: '四川省', - city: '绵阳市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 794, - number: 2457, - province: '四川省', - city: '南充市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 566, - number: 2458, - province: '四川省', - city: '乐山市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 865, - number: 4004, - province: '四川省', - city: '成都市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 3077, - province: '四川省', - city: '绵阳市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 3551, - province: '四川省', - city: '南充市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 352, - province: '四川省', - city: '乐山市', - category: '办公用品', - sub_category: '纸张' - } - ], - widthMode: 'standard', // 宽度模式:standard 标准模式; adaptive 自动填满容器 - bottomFrozenRowCount: 2, - rightFrozenColCount: 1, + { + sales: 792, + number: 2367, + province: '浙江省', + city: '绍兴市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 893, + number: 3877, + province: '浙江省', + city: '宁波市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 1094, + number: 4342, + province: '浙江省', + city: '舟山市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 1295, + number: 5343, + province: '浙江省', + city: '杭州市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 496, + number: 632, + province: '浙江省', + city: '绍兴市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 1097, + number: 7234, + province: '浙江省', + city: '宁波市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 998, + number: 834, + province: '浙江省', + city: '舟山市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 766, + number: 945, + province: '浙江省', + city: '杭州市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 990, + number: 1304, + province: '浙江省', + city: '绍兴市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 891, + number: 1145, + province: '浙江省', + city: '宁波市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 792, + number: 1432, + province: '浙江省', + city: '舟山市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 745, + number: 1343, + province: '浙江省', + city: '杭州市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 843, + number: 1354, + province: '浙江省', + city: '绍兴市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 895, + number: 1523, + province: '浙江省', + city: '宁波市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 965, + number: 1634, + province: '浙江省', + city: '舟山市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 776, + number: 1723, + province: '四川省', + city: '成都市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 634, + number: 1822, + province: '四川省', + city: '绵阳市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 909, + number: 1943, + province: '四川省', + city: '南充市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 399, + number: 2330, + province: '四川省', + city: '乐山市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 700, + number: 2451, + province: '四川省', + city: '成都市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 689, + number: 2244, + province: '四川省', + city: '绵阳市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 500, + number: 2333, + province: '四川省', + city: '南充市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 800, + number: 2445, + province: '四川省', + city: '乐山市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 1044, + number: 2335, + province: '四川省', + city: '成都市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 689, + number: 245, + province: '四川省', + city: '绵阳市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 794, + number: 2457, + province: '四川省', + city: '南充市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 566, + number: 2458, + province: '四川省', + city: '乐山市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 865, + number: 4004, + province: '四川省', + city: '成都市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 3077, + province: '四川省', + city: '绵阳市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 3551, + province: '四川省', + city: '南充市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 352, + province: '四川省', + city: '乐山市', + category: '办公用品', + sub_category: '纸张' + } + ], + widthMode: 'standard', // 宽度模式:standard 标准模式; adaptive 自动填满容器 + bottomFrozenRowCount: 2, + rightFrozenColCount: 1, + dragOrder: { dragHeaderMode: 'all' - }; + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' + } + }; - option.container = dom; - const instance = new VTable.PivotTable(option); - bindDebugTool(instance.scenegraph.stage, { customGrapicKeys: ['col', 'row'] }); - window['tableInstance'] = instance; - }) - .catch(err => { - console.error('Failed import CDN resource: ', err); - }); + option.container = dom; + const instance = new VTable.PivotTable(option); + window.tableInstance = instance; +} diff --git a/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts new file mode 100644 index 0000000000..d8a7ce5a2c --- /dev/null +++ b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts @@ -0,0 +1,96 @@ +import * as VTable from '../../src'; +import { InputEditor } from '@visactor/vtable-editors'; + +const CONTAINER_ID = 'vTable'; +const FIELD = 'facts.2025-02.qty'; +const inputEditor = new InputEditor({}); +VTable.register.editor('issue4186-input', inputEditor); + +const records = [ + { + id: 1, + facts: { + '2025-02': { + qty: 10 + } + } + } +]; + +const updateResult = (status: HTMLElement, source: HTMLElement) => { + const record = records[0] as any; + const nestedValue = record.facts?.['2025-02']?.qty; + const flatValue = record[FIELD]; + const pass = nestedValue === 18 && !Object.prototype.hasOwnProperty.call(record, FIELD); + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | nested=${nestedValue}, flat=${String(flatValue)}`; + status.style.color = pass ? '#237804' : '#a8071a'; + source.textContent = JSON.stringify(record, null, 2); + return pass; +}; + +const createControls = () => { + document.getElementById('issue4186Controls')?.remove(); + const container = document.getElementById(CONTAINER_ID)!; + const controls = document.createElement('div'); + controls.id = 'issue4186Controls'; + controls.style.cssText = 'margin-bottom: 12px; font: 13px/1.5 sans-serif;'; + + const runButton = document.createElement('button'); + runButton.textContent = 'Run #4186 check'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset'; + resetButton.style.marginLeft = '8px'; + + const status = document.createElement('span'); + status.id = 'issue4186Status'; + status.style.marginLeft = '12px'; + status.textContent = 'READY | nested=10'; + + const source = document.createElement('pre'); + source.id = 'issue4186Source'; + source.style.cssText = 'margin: 8px 0 0; padding: 8px; background: #f5f5f5;'; + source.textContent = JSON.stringify(records[0], null, 2); + + controls.append(runButton, resetButton, status, source); + container.parentElement?.insertBefore(controls, container); + + return { runButton, resetButton, status, source }; +}; + +export function createTable() { + const controls = createControls(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, { + records, + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: FIELD, title: '2025-02 Quantity', width: 220, editor: 'issue4186-input' } + ], + editCellTrigger: 'doubleclick', + widthMode: 'standard', + defaultRowHeight: 40 + }); + + const runCheck = async () => { + tableInstance.changeCellValue(1, tableInstance.columnHeaderLevelCount, '18'); + await new Promise(resolve => requestAnimationFrame(resolve)); + const pass = updateResult(controls.status, controls.source); + (window as any).BUGSERVER_SCREENSHOT?.(); + return pass; + }; + + controls.runButton.onclick = runCheck; + controls.resetButton.onclick = () => { + records[0].facts['2025-02'].qty = 10; + delete (records[0] as any)[FIELD]; + tableInstance.setRecords(records); + controls.status.textContent = 'READY | nested=10'; + controls.status.style.color = ''; + controls.source.textContent = JSON.stringify(records[0], null, 2); + }; + tableInstance.on('change_cell_value', () => updateResult(controls.status, controls.source)); + + (window as any).tableInstance = tableInstance; + (window as any).issue4186Run = runCheck; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6a43a63cee..e8e5c4d953 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -74,6 +74,10 @@ export const menus = [ path: 'debug', name: 'issue-3752-large-merge-auto-height' }, + { + path: 'debug', + name: 'issue-4186-nested-field-edit' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/package.json b/packages/vtable/package.json index db7f9bb259..aea9cb8e3f 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "canvas table width high performance", "keywords": [ "grid", @@ -52,12 +52,12 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vrender": "1.1.4", - "@visactor/vrender-core": "1.1.4", - "@visactor/vrender-kits": "1.1.4", - "@visactor/vrender-components": "1.1.4", - "@visactor/vrender-animate": "1.1.4", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vrender": "~1.1.8", + "@visactor/vrender-core": "~1.1.8", + "@visactor/vrender-kits": "~1.1.8", + "@visactor/vrender-components": "~1.1.8", + "@visactor/vrender-animate": "~1.1.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 11ce44f6c0..7fa7b25716 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -18,7 +18,7 @@ import type { } from './ts-types'; import { HierarchyState } from './ts-types'; import { SimpleHeaderLayoutMap } from './layout'; -import { isArray, isValid } from '@visactor/vutils'; +import { arrayEqual, isArray, isValid } from '@visactor/vutils'; import { _setDataSource, _setRecords, @@ -49,7 +49,7 @@ import type { IEmptyTipComponent } from './components/empty-tip/empty-tip'; import { Factory } from './core/factory'; import { getGroupByDataConfig } from './core/group-helper'; import { DataSource, type CachedDataSource } from './data'; -import { getValueFromDeepArray } from './data/DataSource'; +import { getRecordFieldValue, getValueFromDeepArray } from './data/DataSource'; import { listTableAddRecord, listTableAddRecords, @@ -100,6 +100,10 @@ function clearLayoutColumnState(columns: ColumnsDefine | undefined) { }); } +function isSameField(left: FieldDef, right: FieldDef): boolean { + return left === right || (Array.isArray(left) && Array.isArray(right) && arrayEqual(left, right)); +} + // registerAxis(); // registerEmptyTip(); // registerLegend(); @@ -703,7 +707,7 @@ export class ListTable extends BaseTable implements ListTableAPI { return this.dataSource.getTableIndex(recordIndex) + this.columnHeaderLevelCount; } getTableIndexByField(field: FieldDef) { - const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (!colObj) { return -1; } @@ -1163,7 +1167,7 @@ export class ListTable extends BaseTable implements ListTableAPI { */ getCellRangeByField(field: FieldDef, index: number): CellRange | null { const { layoutMap } = this.internalProps; - const colObj = layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (colObj) { const layoutRange = layoutMap.getBodyLayoutRangeById(colObj.id); let startRow; @@ -1411,7 +1415,7 @@ export class ListTable extends BaseTable implements ListTableAPI { for (let i = 0; i < columns.length; i++) { const header = columns[i]; if ( - ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && header.field === field)) && + ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && isSameField(header.field, field))) && header.sort && typeof header.sort === 'function' ) { @@ -1447,7 +1451,9 @@ export class ListTable extends BaseTable implements ListTableAPI { this.dataSource.sort( normalizedSortState.map((item: any) => { const sortFunc = this._getSortFuncFromHeaderOption(this.internalProps.columns, item.field); - const hd = this.internalProps.layoutMap.headerObjects.find((col: any) => col && col.field === item.field); + const hd = this.internalProps.layoutMap.headerObjects.find( + (col: any) => col && isSameField(col.field, item.field) + ); return { field: item.field, order: item.order, @@ -1652,7 +1658,7 @@ export class ListTable extends BaseTable implements ListTableAPI { const sortFunc = this._getSortFuncFromHeaderOption(undefined, item.field); // 如果sort传入的信息不能生成正确的sortFunc,直接更新表格,避免首次加载无法正常显示内容 const hd = this.internalProps.layoutMap.headerObjectsIncludeHided.find( - (col: any) => col && col.field === item.field + (col: any) => col && isSameField(col.field, item.field) ); return { field: item.field, @@ -1846,7 +1852,8 @@ export class ListTable extends BaseTable implements ListTableAPI { values: (string | number)[][], workOnEditableCell = false, triggerEvent = true, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) { return listTableChangeCellValues( startCol, @@ -1855,7 +1862,8 @@ export class ListTable extends BaseTable implements ListTableAPI { workOnEditableCell, triggerEvent, this, - noTriggerChangeCellValuesEvent + noTriggerChangeCellValuesEvent, + shouldCancel ); } @@ -1936,19 +1944,24 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (!triggerEvent) { return; } const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const cellAddr = this.getCellAddrByFieldRecord(field, recordIndex); @@ -2026,16 +2039,21 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (triggerEvent) { const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const changeValue = { col: (this.getCellAddrByFieldRecord(field, recordIndex)?.col ?? -1) as number, diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index dce0beee77..4f41a5024c 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -134,7 +134,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { this.setCustomStateNameToSpec(); this.internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; this.internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - this.internalProps.dataConfig = { isPivotChart: true }; + this.internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; @@ -356,7 +356,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { // 更新protectedSpace internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - internalProps.dataConfig = { isPivotChart: true }; + internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; @@ -674,7 +674,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -683,7 +683,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -740,7 +740,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -749,7 +749,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -791,7 +791,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -800,7 +800,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index de64c0e3fc..09b28c6efa 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -687,7 +687,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -700,7 +700,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -720,10 +720,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -779,7 +779,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -792,7 +792,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -816,10 +816,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const valueNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -859,7 +859,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -872,7 +872,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -889,10 +889,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -932,7 +932,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -945,7 +945,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -961,10 +961,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1001,7 +1001,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1014,7 +1014,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1031,10 +1031,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1069,7 +1069,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1082,7 +1082,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1099,10 +1099,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -2047,7 +2047,19 @@ export class PivotTable extends BaseTable implements PivotTableAPI { * @param values 多个单元格的数据数组 * @param workOnEditableCell 是否仅更改可编辑单元格 */ - changeCellValues(startCol: number, startRow: number, values: string[][], workOnEditableCell = false) { + changeCellValues( + startCol: number, + startRow: number, + values: (string | number)[][], + workOnEditableCell = false, + triggerEvent = true, + _noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ): boolean[][] { + const changedCellResults: boolean[][] = []; + if (shouldCancel?.()) { + return changedCellResults; + } let pasteColEnd = startCol; let pasteRowEnd = startRow; // const rowCount = values.length; @@ -2078,6 +2090,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { if (startRow + i > this.rowCount - 1) { break; } + changedCellResults[i] = []; pasteRowEnd = startRow + i; const rowValues = values[i]; let thisRowPasteColEnd = startCol; @@ -2095,12 +2108,13 @@ export class PivotTable extends BaseTable implements PivotTableAPI { let newValue: string | number = value; const oldValue = oldValues[i][j]; const rawValue = beforeChangeValues[i][j]; - if (typeof rawValue === 'number' && isAllDigits(value)) { + if (typeof rawValue === 'number' && typeof value === 'string' && isAllDigits(value)) { newValue = parseFloat(value); } + changedCellResults[i][j] = true; this._changeCellValueToDataSet(startCol + j, startRow + i, oldValue, newValue); const changedValue = this.getCellOriginValue(startCol + j, startRow + i); - if (changedValue !== oldValue) { + if (changedValue !== oldValue && triggerEvent) { this.fireListeners(TABLE_EVENT_TYPE.CHANGE_CELL_VALUE, { col: startCol + j, row: startRow + i, @@ -2109,6 +2123,8 @@ export class PivotTable extends BaseTable implements PivotTableAPI { changedValue }); } + } else { + changedCellResults[i][j] = false; } } pasteColEnd = Math.max(pasteColEnd, thisRowPasteColEnd); @@ -2167,6 +2183,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { } this.scenegraph.updateNextFrame(); + return changedCellResults; } private _changeCellValueToDataSet(col: number, row: number, oldValue: string | number, newValue: string | number) { @@ -2204,7 +2221,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -2217,7 +2234,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.dataset.changeTreeNodeValue( rowKeys, @@ -2245,10 +2262,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { ); } else { const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.flatDataToObjects.changeTreeNodeValue( rowKeys, diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 25c3d24752..c43a2b34cc 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -2796,7 +2796,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { defaultRowHeight = 40, defaultHeaderRowHeight, defaultColWidth = 80, - defaultHeaderColWidth = 80, + defaultHeaderColWidth, keyboardOptions, eventOptions, rowSeriesNumber, diff --git a/packages/vtable/src/core/record-helper.ts b/packages/vtable/src/core/record-helper.ts index 314dd30904..c02f7bc86c 100644 --- a/packages/vtable/src/core/record-helper.ts +++ b/packages/vtable/src/core/record-helper.ts @@ -31,6 +31,53 @@ function refreshCustomMergeCellGroups(table: ListTable) { } } +type ChangeCellTargetSnapshot = { + col: number; + row: number; + isHeader: boolean; + recordIndex?: number | number[]; + field?: any; +}; + +function isSameRecordIndex( + sourceRecordIndex: number | number[] | undefined, + currentRecordIndex: number | number[] | undefined +): boolean { + if (Array.isArray(sourceRecordIndex) || Array.isArray(currentRecordIndex)) { + return ( + Array.isArray(sourceRecordIndex) && + Array.isArray(currentRecordIndex) && + sourceRecordIndex.length === currentRecordIndex.length && + sourceRecordIndex.every((value, index) => value === currentRecordIndex[index]) + ); + } + return sourceRecordIndex === currentRecordIndex; +} + +function isTargetCellSnapshotCurrent(table: ListTable, snapshot: ChangeCellTargetSnapshot): boolean { + if (table.isHeader(snapshot.col, snapshot.row) !== snapshot.isHeader) { + return false; + } + if (snapshot.isHeader) { + return true; + } + const recordShowIndex = table.getRecordShowIndexByCell(snapshot.col, snapshot.row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(snapshot.col, snapshot.row); + return isSameRecordIndex(snapshot.recordIndex, recordIndex) && snapshot.field === field; +} + +function areTargetCellSnapshotsCurrent(table: ListTable, snapshots: ChangeCellTargetSnapshot[][]): boolean { + for (let i = 0; i < snapshots.length; i++) { + for (let j = 0; j < snapshots[i].length; j++) { + if (!isTargetCellSnapshotCurrent(table, snapshots[i][j])) { + return false; + } + } + } + return true; +} + /** * 更改单元格数据 会触发change_cell_value事件 * @param col @@ -164,7 +211,8 @@ export async function listTableChangeCellValues( workOnEditableCell: boolean, triggerEvent: boolean, table: ListTable, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ): Promise { const changedCellResults: boolean[][] = []; let pasteColEnd = startCol; @@ -173,6 +221,7 @@ export async function listTableChangeCellValues( //#region 提前组织好未更改前的数据 const beforeChangeValues: (string | number)[][] = []; const oldValues: (string | number)[][] = []; + const targetSnapshots: ChangeCellTargetSnapshot[][] = []; let cellUpdateType: 'normal' | 'sort' | 'group'; for (let i = 0; i < values.length; i++) { @@ -182,17 +231,26 @@ export async function listTableChangeCellValues( const rowValues = values[i]; const rawRowValues: (string | number)[] = []; const oldRowValues: (string | number)[] = []; + const rowTargetSnapshots: ChangeCellTargetSnapshot[] = []; beforeChangeValues.push(rawRowValues); oldValues.push(oldRowValues); + targetSnapshots.push(rowTargetSnapshots); for (let j = 0; j < rowValues.length; j++) { if (startCol + j > table.colCount - 1) { break; } + const col = startCol + j; + const row = startRow + i; cellUpdateType = getCellUpdateType(startCol + j, startRow + i, table, cellUpdateType); - const beforeChangeValue = table.getCellRawValue(startCol + j, startRow + i); + const beforeChangeValue = table.getCellRawValue(col, row); rawRowValues.push(beforeChangeValue); - const oldValue = table.getCellOriginValue(startCol + j, startRow + i); + const oldValue = table.getCellOriginValue(col, row); oldRowValues.push(oldValue); + const isHeader = table.isHeader(col, row); + const recordShowIndex = table.getRecordShowIndexByCell(col, row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(col, row); + rowTargetSnapshots.push({ col, row, isHeader, recordIndex, field }); } } @@ -206,6 +264,43 @@ export async function listTableChangeCellValues( changedValue: string | number; }[] = []; + const preValidatedCellResults: boolean[][] | null = shouldCancel && workOnEditableCell ? [] : null; + if (preValidatedCellResults) { + for (let i = 0; i < values.length; i++) { + if (shouldCancel?.()) { + return changedCellResults; + } + if (startRow + i > table.rowCount - 1) { + break; + } + preValidatedCellResults[i] = []; + const rowValues = values[i]; + for (let j = 0; j < rowValues.length; j++) { + if (startCol + j > table.colCount - 1) { + break; + } + let isCanChange = false; + if (table.isHasEditorDefine(startCol + j, startRow + i)) { + const editor = table.getEditor(startCol + j, startRow + i); + const oldValue = oldValues[i][j]; + const value = rowValues[j]; + const maybePromiseOrValue = + editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; + const validateResult = isPromise(maybePromiseOrValue) ? await maybePromiseOrValue : maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } + isCanChange = + validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; + } + preValidatedCellResults[i][j] = isCanChange; + } + } + } + if (shouldCancel?.() || !areTargetCellSnapshotsCurrent(table, targetSnapshots)) { + return changedCellResults; + } + //#endregion for (let i = 0; i < values.length; i++) { if (startRow + i > table.rowCount - 1) { @@ -221,7 +316,9 @@ export async function listTableChangeCellValues( } thisRowPasteColEnd = startCol + j; let isCanChange = false; - if (workOnEditableCell === false) { + if (preValidatedCellResults) { + isCanChange = preValidatedCellResults[i]?.[j] === true; + } else if (workOnEditableCell === false) { isCanChange = true; } else { if (table.isHasEditorDefine(startCol + j, startRow + i)) { @@ -232,6 +329,9 @@ export async function listTableChangeCellValues( editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; if (isPromise(maybePromiseOrValue)) { const validateResult = await maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } isCanChange = validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; } else { @@ -256,7 +356,17 @@ export async function listTableChangeCellValues( if (table.isHeader(startCol + j, startRow + i)) { table.internalProps.layoutMap.updateColumnTitle(startCol + j, startRow + i, value as string); } else { - table.dataSource.changeFieldValue(value, recordShowIndex, field, startCol + j, startRow + i, table); + const changeResult = table.dataSource.changeFieldValue( + value, + recordShowIndex, + field, + startCol + j, + startRow + i, + table + ); + if (isPromise(changeResult)) { + await changeResult; + } } const changedValue = table.getCellOriginValue(startCol + j, startRow + i); if (oldValue !== changedValue && triggerEvent) { diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index da90340fc1..99e3a183da 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -17,9 +17,9 @@ import type { import { AggregationType, HierarchyState } from '../ts-types'; import { applyChainSafe, getOrApply, obj, isPromise, emptyFn } from '../tools/helper'; import { EventTarget } from '../event/EventTarget'; -import { computeChildrenNodeLength, getValueByPath, isAllDigits } from '../tools/util'; +import { computeChildrenNodeLength, isAllDigits } from '../tools/util'; import { calculateArrayDiff } from '../tools/diff-cell'; -import { arrayEqual, cloneDeep, isArray, isNumber, isObject, isValid } from '@visactor/vutils'; +import { arrayEqual, cloneDeep, isArray, isNumber, isValid } from '@visactor/vutils'; import type { BaseTableAPI } from '../ts-types/base-table'; import { RecordAggregator, @@ -100,8 +100,12 @@ export function getField( const colIndex = col - table.leftRowSeriesNumberCount; return record[colIndex]; } - if (isObject(record) && fieldGet in (record as any)) { - const fieldResult = (record as any)[fieldGet]; + if (Array.isArray(fieldGet)) { + const fieldResult = getRecordFieldValue(record, fieldGet); + return getValue(fieldResult, promiseCallBack); + } + if (isRecordContainer(record) && fieldGet in record) { + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } @@ -109,13 +113,9 @@ export function getField( const fieldResult = fieldGet(record, col, row, table); return getValue(fieldResult, promiseCallBack); } - if (Array.isArray(fieldGet)) { - const fieldResult = getValueByPath(record, [...fieldGet]); - return getValue(fieldResult, promiseCallBack); - } const fieldArray = `${fieldGet}`.split('.'); if (fieldArray.length <= 1) { - const fieldResult = (record as any)[fieldGet]; + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } const fieldResult = applyChainSafe( @@ -126,6 +126,94 @@ export function getField( return getValue(fieldResult, promiseCallBack); } +function isRecordContainer(value: any): boolean { + return value !== null && typeof value === 'object'; +} + +function getRecordFieldPath(field: FieldDef | number): string[] | undefined { + if (Array.isArray(field)) { + return field; + } + if (typeof field === 'string' && field.includes('.')) { + return field.split('.'); + } + return undefined; +} + +function hasRecordField(record: any, field: FieldDef | number): boolean { + if (!isRecordContainer(record)) { + return false; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && (field as any) in record) { + return true; + } + if (!path) { + return false; + } + let target = record; + for (const key of path) { + if (!isRecordContainer(target) || !(key in target)) { + return false; + } + target = target[key]; + } + return true; +} + +export function getRecordFieldValue(record: any, field: FieldDef | number): any { + if (record === null || record === undefined) { + return undefined; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record) { + return record[field as any]; + } + if (!path) { + return record[field as any]; + } + return path.reduce((current, key) => { + return current === null || current === undefined ? undefined : current[key]; + }, record); +} + +function setRecordProperty(record: any, key: string, value: any): void { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true + }); + return; + } + record[key] = value; +} + +export function setRecordFieldValue(record: any, field: FieldDef | number, value: FieldData): void { + if (record === null || record === undefined) { + return; + } + const path = getRecordFieldPath(field); + if (!path || (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record)) { + setRecordProperty(record, field as any, value); + return; + } + + if (path.length === 0) { + return; + } + let target = record; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!Object.prototype.hasOwnProperty.call(target, key) || target[key] === null || typeof target[key] !== 'object') { + setRecordProperty(target, key, {}); + } + target = target[key]; + } + setRecordProperty(target, path[path.length - 1], value); +} + function _getIndex(sortedIndexMap: null | (number | number[])[], index: number): number | number[] { if (!sortedIndexMap) { return index; @@ -732,7 +820,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { col?: number, row?: number, table?: BaseTableAPI - ): FieldData { + ): FieldData | Promise { if (field === null) { return undefined; } @@ -744,28 +832,29 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (field === undefined || field === '') { field = col - table.leftRowSeriesNumberCount; } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(dataIndex.toString())?.[field as any]; // this.getOriginalField(index, field, col, row, table); + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(dataIndex.toString()), field); const record = this.getOriginalRecord(dataIndex); let formatValue = value; if (typeof beforeChangedValue === 'number' && isAllDigits(value)) { formatValue = parseFloat(value); } if (isPromise(record)) { - record + return record .then(record => { - record[field as string | number] = formatValue; + setRecordFieldValue(record, field, formatValue); + return formatValue; }) .catch((err: Error) => { console.error('VTable Error:', err); + return undefined; }); + } + if (record) { + setRecordFieldValue(record, field, formatValue); } else { - if (record) { - record[field] = formatValue; - } else { - this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; - this.records[dataIndex as number][field] = formatValue; - } + this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; + setRecordFieldValue(this.records[dataIndex as number], field, formatValue); } } } @@ -801,8 +890,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { ); } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(rawKey)?.[field as any]; + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(rawKey), field); const rawRecords = Array.isArray((this.dataSourceObj as any)?.records) ? (this.dataSourceObj as any).records : null; @@ -816,10 +905,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { formatValue = parseFloat(value); } if (record) { - record[field] = formatValue; + setRecordFieldValue(record, field, formatValue); } else if (rawRecords && typeof recordIndex === 'number') { rawRecords[recordIndex] = this.addRecordRule === 'Array' ? [] : {}; - rawRecords[recordIndex][field] = formatValue; + setRecordFieldValue(rawRecords[recordIndex], field, formatValue); } } } @@ -1650,7 +1739,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { return true; } const record = this.getOriginalRecord(index); - return Boolean(record && (field as any) in (record as any)); + return hasRecordField(record, field); } protected fieldPromiseCallBack( diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index a2f5c516e0..bafb5c4cb1 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -107,6 +107,8 @@ export class Dataset { indicatorStatistics: { max: Aggregator; min: Aggregator; total: Aggregator }[] = []; stringJoinChar = String.fromCharCode(0); + private readonly groupedGrandTotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total'; + private readonly groupedGrandTotalSubtotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total_subtotal'; //缓存rows对应每个值是否为汇总字段 private rowsIsTotal: boolean[] = []; private colsIsTotal: boolean[] = []; @@ -328,7 +330,11 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -342,7 +348,10 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + true, + this.rows ); } } @@ -376,7 +385,10 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions, + false, + this.columns ); // } } @@ -551,6 +563,11 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } + const records = (Array.isArray(this.records) ? this.records : Object.values(this.records).flat()).filter( + record => !isNeedFilter || this.filterRecord(record) + ); + this.rowsHasValue = this.rows.map(row => records.some(record => record && row in record)); + this.columnsHasValue = this.columns.map(column => records.some(record => record && column in record)); //常规records是数组的情况 if (Array.isArray(this.records)) { if (!this.filteredRecords) { @@ -609,83 +626,101 @@ export class Dataset { * @param assignedIndicatorKey 指定要计算的指标key 外部用户 用指标做records的key 分别存储不同指标对应的数据时 会传入这个参数 * @returns */ - private processRecord(record: any, assignedIndicatorKey?: string) { - //这个派生字段的计算位置有待确定,是否应该放到filter之前 - this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { - if (derivedFieldRule.fieldName && derivedFieldRule.derivedFunc) { - record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); + private collectValue(field: string, collectKeys: string, record: any) { + if (!this.collectedValues[field]) { + this.collectedValues[field] = {}; + } + if (!this.collectedValues[field][collectKeys]) { + if (this.collectValuesBy[field].sumBy) { + this.collectedValues[field][collectKeys] = {}; + } else if (this.collectValuesBy[field].range) { + this.collectedValues[field][collectKeys] = { + min: Number.MAX_SAFE_INTEGER, + max: Number.MIN_SAFE_INTEGER + }; + } else { + this.collectedValues[field][collectKeys] = []; } - }); - //#region 按照collectValuesBy 收集维度值 - for (const field in this.collectValuesBy) { - if (isValid(record[field])) { - if (!this.collectedValues[field]) { - this.collectedValues[field] = {}; - } - const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); - if (!this.collectedValues[field][collectKeys]) { - if (this.collectValuesBy[field].sumBy) { - this.collectedValues[field][collectKeys] = {}; - } else if (this.collectValuesBy[field].range) { - this.collectedValues[field][collectKeys] = { - min: Number.MAX_SAFE_INTEGER, - max: Number.MIN_SAFE_INTEGER - }; - } else { - this.collectedValues[field][collectKeys] = []; - } - } + } - if (this.collectValuesBy[field].sumBy) { - const sumByKeys: string = this.collectValuesBy[field].sumBy - ?.map(byField => record[byField]) - .join(this.stringJoinChar); - if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { - (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[ - AggregationType.SUM - ]({ - key: field, - field: field, - isRecord: undefined, - needSplitPositiveAndNegative: this.needSplitPositiveAndNegative - }); - } - (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); - } else if (this.collectValuesBy[field].range) { - const fieldRange = this.collectedValues[field][collectKeys] as { - max: number; - min: number; - }; - let max = Math.max(record[field], fieldRange.max); - let min = Math.min(record[field], fieldRange.min); - // 处理considerFields - if (this.collectValuesBy[field].considerFields) { - for (const considerField of this.collectValuesBy[field].considerFields) { - if (record[considerField]) { - if (typeof record[considerField] === 'number') { - max = Math.max(record[considerField], max); - min = Math.min(record[considerField], min); - } else if (Array.isArray(record[considerField])) { - max = Math.max(...record[considerField], max); - min = Math.min(...record[considerField], min); - } - } + if (this.collectValuesBy[field].sumBy) { + const sumByKeys: string = this.collectValuesBy[field].sumBy + ?.map(byField => record[byField]) + .join(this.stringJoinChar); + if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { + (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[AggregationType.SUM]({ + key: field, + field, + isRecord: undefined, + needSplitPositiveAndNegative: this.needSplitPositiveAndNegative + }); + } + (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); + } else if (this.collectValuesBy[field].range) { + const fieldRange = this.collectedValues[field][collectKeys] as { + max: number; + min: number; + }; + let max = Math.max(record[field], fieldRange.max); + let min = Math.min(record[field], fieldRange.min); + if (this.collectValuesBy[field].considerFields) { + for (const considerField of this.collectValuesBy[field].considerFields) { + if (record[considerField]) { + if (typeof record[considerField] === 'number') { + max = Math.max(record[considerField], max); + min = Math.min(record[considerField], min); + } else if (Array.isArray(record[considerField])) { + max = Math.max(...record[considerField], max); + min = Math.min(...record[considerField], min); } } - if (!isNaN(max)) { - fieldRange.max = max; - fieldRange.min = min; - } - } else { - const fieldRange = this.collectedValues[field][collectKeys] as Array; - if (fieldRange.indexOf(record[field]) === -1) { - fieldRange.push(record[field]); - } } } + if (!isNaN(max)) { + fieldRange.max = max; + fieldRange.min = min; + } + } else { + const fieldValues = this.collectedValues[field][collectKeys] as Array; + if (fieldValues.indexOf(record[field]) === -1) { + fieldValues.push(record[field]); + } } - //#endregion + } + private getGroupedCollectedValueKeys(by: string[], record: any): string[] { + if (!(this.dataConfig as IPivotChartDataConfig)?.isPivotChart) { + return []; + } + const keys = new Set(); + [ + { dimensions: this.rows, totals: this.totals?.row }, + { dimensions: this.columns, totals: this.totals?.column } + ].forEach(({ dimensions, totals }) => { + if (!totals?.showGrandTotals) { + return; + } + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, totals.grandTotalDimensions); + if (dimensionIndexes.length && dimensionIndexes.every(index => by.indexOf(dimensions[index]) !== -1)) { + keys.add( + join( + [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])], + this.stringJoinChar + ) + ); + keys.add(join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar)); + } + }); + return Array.from(keys); + } + + private processRecord(record: any, assignedIndicatorKey?: string) { + //这个派生字段的计算位置有待确定,是否应该放到filter之前 + this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { + if (derivedFieldRule.fieldName && derivedFieldRule.derivedFunc) { + record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); + } + }); let isToTalRecord = false; //#region 收集rowKey colKey // 原先的逻辑不关心customRowTree 只是根据rows 从record上收集维度path。现在考虑了rowTree和colTree的传入,需要依据colTree的真实定义的path来给数据做对应关系。 @@ -719,35 +754,50 @@ export class Dataset { } else { const rowKey: string[] = []; rowKeys.push({ rowKey, indicatorKey: assignedIndicatorKey }); - for (let l = 0, len1 = this.rows.length; l < len1; l++) { - const rowAttr = this.rows[l]; - if (rowAttr in record) { - this.rowsHasValue[l] = true; - rowKey.push(record[rowAttr]); - } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.row?.showGrandTotals && - l === 0 && - !this.rows.find((rk: string) => { - // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 - return rk in record; - }) - ) { - rowKey.push(this.rowGrandTotalLabel); - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.row?.showSubTotals && - this.dataConfig?.totals?.row?.subTotalsDimensions && - this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 - ) { - if (this.rowHierarchyType !== 'tree') { - //如果是tree的话 不附加标签'小计' - rowKey.push(this.rowSubTotalLabel); + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.row?.showGrandTotals && this.rowsHasValue[0] + ? this.getCustomGroupedGrandTotalKey(record, this.rows, this.dataConfig?.totals?.row?.grandTotalDimensions) + : undefined; + if (customGroupedGrandTotalKey) { + rowKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let l = 0, len1 = this.rows.length; l < len1; l++) { + const rowAttr = this.rows[l]; + if (rowAttr in record) { + this.rowsHasValue[l] = true; + rowKey.push(record[rowAttr]); + } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 + if ( + this.dataConfig?.totals?.row?.showGrandTotals && + l === 0 && + !this.rows.find((rk: string) => { + // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 + return rk in record; + }) + ) { + if ( + this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length + ) { + rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + rowKey.push(this.rowGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.row?.showSubTotals && + this.dataConfig?.totals?.row?.subTotalsDimensions && + this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 + ) { + if (this.rowHierarchyType !== 'tree') { + //如果是tree的话 不附加标签'小计' + rowKey.push(this.rowSubTotalLabel); + } + isToTalRecord = true; + break; } - isToTalRecord = true; - break; } } } @@ -779,39 +829,72 @@ export class Dataset { } else { const colKey: string[] = []; colKeys.push({ colKey, indicatorKey: assignedIndicatorKey }); - for (let n = 0, len2 = this.columns.length; n < len2; n++) { - const colAttr = this.columns[n]; - if (colAttr in record) { - this.columnsHasValue[n] = true; - colKey.push(record[colAttr]); - } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.column?.showGrandTotals && - n === 0 && - !this.columns.find((ck: string) => { - // 判断没有其他字段在record中 - return ck in record; - }) - ) { - colKey.push(this.colGrandTotalLabel); - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.column?.showSubTotals && - this.dataConfig?.totals?.column?.subTotalsDimensions && - this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 - ) { - // if (this.columnHierarchyType === 'grid') { - colKey.push(this.colSubTotalLabel); - // } - isToTalRecord = true; - break; + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.column?.showGrandTotals && this.columnsHasValue[0] + ? this.getCustomGroupedGrandTotalKey( + record, + this.columns, + this.dataConfig?.totals?.column?.grandTotalDimensions + ) + : undefined; + if (customGroupedGrandTotalKey) { + colKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let n = 0, len2 = this.columns.length; n < len2; n++) { + const colAttr = this.columns[n]; + if (colAttr in record) { + this.columnsHasValue[n] = true; + colKey.push(record[colAttr]); + } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 + if ( + this.dataConfig?.totals?.column?.showGrandTotals && + n === 0 && + !this.columns.find((ck: string) => { + // 判断没有其他字段在record中 + return ck in record; + }) + ) { + if ( + this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) + .length + ) { + colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + colKey.push(this.colGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.column?.showSubTotals && + this.dataConfig?.totals?.column?.subTotalsDimensions && + this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 + ) { + // if (this.columnHierarchyType === 'grid') { + colKey.push(this.colSubTotalLabel); + // } + isToTalRecord = true; + break; + } } } } } //#endregion + //#region 按照collectValuesBy 收集维度值 + if (!isToTalRecord) { + for (const field in this.collectValuesBy) { + if (isValid(record[field])) { + const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); + this.collectValue(field, collectKeys, record); + this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { + this.collectValue(field, groupedCollectKeys, record); + }); + } + } + } + //#endregion //#region 对path的数组 rowKeys和colKeys 做双重循环 for (let row_i = 0; row_i < rowKeys.length; row_i++) { const rowKey = rowKeys[row_i].rowKey; @@ -1062,7 +1145,11 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1076,7 +1163,10 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + true, + this.rows ); } } @@ -1093,7 +1183,10 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions, + false, + this.columns ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1185,8 +1278,8 @@ export class Dataset { } //#endregion // let agg; - let flatRowKey; - let flatColKey; + let flatRowKey: string; + let flatColKey: string; if (typeof rowKey === 'string') { flatRowKey = rowKey; } else { @@ -1200,7 +1293,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -1227,7 +1320,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && @@ -1241,6 +1334,41 @@ export class Dataset { // flatColKey = colKey.join(this.stringJoinChar); flatColKey = join(colKey, this.stringJoinChar); } + + if ( + Array.isArray(rowKey) && + rowKey[0] === this.rowGrandTotalLabel && + !this.tree[flatRowKey] && + this.getGrandTotalDimensionIndexes( + this.rows.filter((_, index) => this.rowsHasValue[index]), + this.totals?.row?.grandTotalDimensions, + this.rows + ).length + ) { + const internalRowKey = rowKey.slice(); + internalRowKey[0] = this.groupedGrandTotalKey; + if (internalRowKey.length === 2 && internalRowKey[1] === this.rowSubTotalLabel) { + internalRowKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatRowKey = join(internalRowKey, this.stringJoinChar); + } + if ( + Array.isArray(colKey) && + colKey[0] === this.colGrandTotalLabel && + !this.tree[flatRowKey]?.[flatColKey] && + this.getGrandTotalDimensionIndexes( + this.columns.filter((_, index) => this.columnsHasValue[index]), + this.totals?.column?.grandTotalDimensions, + this.columns + ).length + ) { + const internalColKey = colKey.slice(); + internalColKey[0] = this.groupedGrandTotalKey; + if (internalColKey.length === 2 && internalColKey[1] === this.colSubTotalLabel) { + internalColKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatColKey = join(internalColKey, this.stringJoinChar); + } //TODO 原有逻辑 但这里先强制跳过 // if ( rowKey.length === 0 && colKey.length === 0) { // agg = this.allTotal; @@ -1392,7 +1520,7 @@ export class Dataset { * @param fieldArr 排序维度名称 如行rows 列columns * @returns */ - arrSort(fieldArr: string[], isRow: boolean) { + arrSort(fieldArr: string[], isRow: boolean, pathPrefix?: string[]) { let field; const that = this; const sortersArr: any[] = function (_this: any) { @@ -1441,18 +1569,22 @@ export class Dataset { let bChanged = b; if (sorter.fieldIndex < fieldArr.length - 1) { aChanged = a.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { aChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { aChanged.push(that.colSubTotalLabel); } bChanged = b.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { bChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { bChanged.push(that.colSubTotalLabel); } } + if (pathPrefix?.length) { + aChanged = [...pathPrefix, ...aChanged]; + bChanged = [...pathPrefix, ...bChanged]; + } comparison = sorter.func(aChanged, bChanged, sorter.sortRule?.sortType); } else { comparison = sorter.func?.(a[sorter.fieldIndex], b[sorter.fieldIndex], sorter.sortRule?.sortType); @@ -1490,14 +1622,16 @@ export class Dataset { that.rowHierarchyType === 'grid' && rowKey.length < that.rows.length && rowKey[rowKey.length - 1] !== that.rowSubTotalLabel && - rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel + rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel && + rowKey[0] !== that.groupedGrandTotalKey ) { rowKey.push(that.rowSubTotalLabel); } if ( colKey.length < that.columns.length && colKey[colKey.length - 1] !== that.colSubTotalLabel && - colKey[colKey.length - 1] !== that.colGrandTotalLabel + colKey[colKey.length - 1] !== that.colGrandTotalLabel && + colKey[0] !== that.groupedGrandTotalKey ) { colKey.push(that.colSubTotalLabel); } @@ -1653,63 +1787,80 @@ export class Dataset { } } } - if (that.totals?.column?.showGrandTotals || this.rows.length === 0) { - const flatColTotalKey = that.colGrandTotalLabel; - if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { - // 利用汇总数据替换 - this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; - return; - } - if (!this.tree[flatRowKey][flatColTotalKey]) { - this.tree[flatRowKey][flatColTotalKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find(rule => rule.key === toComputeIndicatorKeys[i]); - if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowKey][flatColTotalKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); - } - } else { - if (!this.tree[flatRowKey][flatColTotalKey][i]) { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== this.groupedGrandTotalKey) { + const flatColTotalKeys = this.getGrandTotalKeys( + colKey, + that.columns.filter((_, index) => that.columnsHasValue[index]), + that.totals?.column?.grandTotalDimensions, + that.colGrandTotalLabel, + that.columns + ); + for (const flatColTotalKey of flatColTotalKeys) { + if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { + // 利用汇总数据替换 + this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; + continue; + } + if (!this.tree[flatRowKey][flatColTotalKey]) { + this.tree[flatRowKey][flatColTotalKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowKey][flatColTotalKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } + } else { + if (!this.tree[flatRowKey][flatColTotalKey][i]) { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + if ( + this.tree[flatRowKey][flatColTotalKey][i] instanceof CustomAggregator && + flatColTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowKey][flatColTotalKey][i] as CustomAggregator).recalculateFromChildren = true; + } + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } } } } @@ -1815,60 +1966,78 @@ export class Dataset { } } - if (that.totals?.row?.showGrandTotals || this.columns.length === 0) { - const flatRowTotalKey = that.rowGrandTotalLabel; - if (!this.tree[flatRowTotalKey]) { - this.tree[flatRowTotalKey] = {}; - rowTotalKeys.push(flatRowTotalKey); - } - if (!this.tree[flatRowTotalKey][flatColKey]) { - this.tree[flatRowTotalKey][flatColKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (!this.tree[flatRowTotalKey][flatColKey][i]) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find( - rule => rule.key === toComputeIndicatorKeys[i] - ); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowTotalKey][flatColKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } else { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ( + (that.totals?.row?.showGrandTotals || this.columns.length === 0) && + rowKey[0] !== this.groupedGrandTotalKey + ) { + const flatRowTotalKeys = this.getGrandTotalKeys( + rowKey, + that.rows.filter((_, index) => that.rowsHasValue[index]), + that.totals?.row?.grandTotalDimensions, + that.rowGrandTotalLabel, + that.rows + ); + for (const flatRowTotalKey of flatRowTotalKeys) { + if (!this.tree[flatRowTotalKey]) { + this.tree[flatRowTotalKey] = {}; + rowTotalKeys.push(flatRowTotalKey); + } + if (!this.tree[flatRowTotalKey][flatColKey]) { + this.tree[flatRowTotalKey][flatColKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (!this.tree[flatRowTotalKey][flatColKey][i]) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowTotalKey][flatColKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } else { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + if ( + this.tree[flatRowTotalKey][flatColKey][i] instanceof CustomAggregator && + flatRowTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowTotalKey][flatColKey][i] as CustomAggregator).recalculateFromChildren = true; + } + } + } + if (flatRowTotalKey !== flatRowKey) { + this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } - } - if (flatRowTotalKey !== flatRowKey) { - this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -1928,13 +2097,111 @@ export class Dataset { * @param arr * @returns */ + private getGrandTotalDimensionIndexes( + dimensions: string[], + grandTotalDimensions?: string[], + configuredDimensions: string[] = dimensions + ) { + if (!grandTotalDimensions?.length) { + return []; + } + return grandTotalDimensions + .map(dimension => ({ + configuredIndex: configuredDimensions.indexOf(dimension), + activeIndex: dimensions.indexOf(dimension) + })) + .filter( + ({ configuredIndex, activeIndex }, position, indexes) => + configuredIndex > 0 && + activeIndex >= 0 && + indexes.findIndex(item => item.activeIndex === activeIndex) === position + ) + .map(({ activeIndex }) => activeIndex) + .sort((a, b) => a - b); + } + + private getCustomGroupedGrandTotalKey( + record: any, + dimensions: string[], + grandTotalDimensions?: string[] + ): string[] | undefined { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if ( + !dimensionIndexes.length || + dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) + ) { + return undefined; + } + const prefixIndexes: number[] = []; + for (const index of dimensionIndexes) { + if (!(dimensions[index] in record)) { + break; + } + prefixIndexes.push(index); + } + if ( + !prefixIndexes.length || + dimensionIndexes.slice(prefixIndexes.length).some(index => dimensions[index] in record) + ) { + return undefined; + } + return [this.groupedGrandTotalKey, ...prefixIndexes.map(index => record[dimensions[index]])]; + } + + private getGroupedGrandTotalPaths( + keys: string[][], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + isRow: boolean = true, + configuredDimensions: string[] = dimensions + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); + const groupedPaths = new Map(); + keys.forEach(key => { + const path = dimensionIndexes.map(index => key[index]); + groupedPaths.set(join(path, this.stringJoinChar), path); + }); + return Array.from(groupedPaths.values()).sort( + this.arrSort( + dimensionIndexes.map(index => dimensions[index]), + isRow, + [this.groupedGrandTotalKey] + ) + ); + } + + private getGrandTotalKeys( + dimensionKey: string[], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + grandTotalLabel: string, + configuredDimensions: string[] = dimensions + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); + if (!dimensionIndexes.length) { + return [grandTotalLabel]; + } + const groupedKey = [ + this.groupedGrandTotalKey, + ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) + ]; + return [ + ...groupedKey.slice(1).map((_, index) => join(groupedKey.slice(0, index + 2), this.stringJoinChar)), + join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) + ]; + } + private ArrToTree1( arr: string[][], rows: string[], indicators: (string | IIndicator)[] | undefined, isGrandTotal: boolean, grandTotalLabel: string, - showGrandTotalsOnTop: boolean + showGrandTotalsOnTop: boolean, + grandTotalDimensions?: string[], + subTotalLabel?: string, + showSubTotalsOnTop?: boolean, + configuredRows: string[] = rows ) { /** * @@ -1991,9 +2258,72 @@ export class Dataset { }); } + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { + return { + indicatorKey: indicator, + value: indicator + }; + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; + arr.forEach(item => addList(item, false)); if (isGrandTotal) { - addList([grandTotalLabel], isGrandTotal); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); + if (!grandTotalDimensionIndexes.length) { + addList([grandTotalLabel], isGrandTotal); + } else { + const grandTotalNode: any = { + value: grandTotalLabel, + dataValue: this.groupedGrandTotalKey, + dimensionKey: rows[0], + children: [], + role: 'grand-total' + }; + const grandTotalMap = new Map(); + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true, configuredRows).forEach(list => { + let parent = grandTotalNode; + const path = [this.groupedGrandTotalKey]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[index]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + item = { + value, + dataValue: value, + dimensionKey: rows[dimensionIndex], + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + const totalChild = { + value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + children: createIndicatorChildren() + }; + if (showSubTotalsOnTop) { + grandTotalNode.children.unshift(totalChild); + } else { + grandTotalNode.children.push(totalChild); + } + if (showGrandTotalsOnTop) { + result.unshift(grandTotalNode); + } else { + result.push(grandTotalNode); + } + } } return result; } @@ -2012,7 +2342,10 @@ export class Dataset { grandTotalLabel: string, subTotalLabel: string, showGrandTotalsOnTop: boolean, - showSubTotalsOnTop: boolean + showSubTotalsOnTop: boolean, + grandTotalDimensions?: string[], + isRow?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2120,25 +2453,75 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { - const node: { value: string; dimensionKey: string; children: any[]; levelSpan: number; role: string } = { - value: grandTotalLabel, // getId(item?.id, 1), - dimensionKey: rows[0], - levelSpan: subTotalFlags.length, - children: - indicators?.map(indicator => { - if (typeof indicator === 'string') { - return { - indicatorKey: indicator, - value: indicator - }; - } + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { return { - indicatorKey: indicator.indicatorKey, - value: indicator.title + indicatorKey: indicator, + value: indicator }; - }) ?? [], + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; + const node: { + value: string; + dataValue?: string; + dimensionKey: string; + children: any[]; + levelSpan: number; + role: string; + } = { + value: grandTotalLabel, // getId(item?.id, 1), + dataValue: grandTotalDimensionIndexes.length ? this.groupedGrandTotalKey : undefined, + dimensionKey: rows[0], + levelSpan: Math.max(1, grandTotalDimensionIndexes[0] ?? subTotalFlags.length), + children: [], role: 'grand-total' }; + if (grandTotalDimensionIndexes.length) { + const grandTotalMap = new Map(); + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow, configuredRows).forEach(list => { + let parent = node; + const path = [this.groupedGrandTotalKey]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[index]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; + item = { + value, + dataValue: value, + dimensionKey: rows[dimensionIndex], + levelSpan: nextDimensionIndex - dimensionIndex, + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + const totalChild = { + value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + levelSpan: rows.length - grandTotalDimensionIndexes[0], + children: createIndicatorChildren() + }; + if (showSubTotalsOnTop) { + node.children.unshift(totalChild); + } else { + node.children.push(totalChild); + } + } else { + node.children = createIndicatorChildren(); + } if (showGrandTotalsOnTop) { result.unshift(node); } else { @@ -2209,7 +2592,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -2236,7 +2619,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && @@ -2328,15 +2711,15 @@ export class Dataset { //#region 参数处理与初始化 // 将输入参数转换为字符串格式,用于在树中查找 - const flatRowKey = Array.isArray(rowKey) ? rowKey.join(this.stringJoinChar) : rowKey; - const flatColKey = Array.isArray(colKey) ? colKey.join(this.stringJoinChar) : colKey; + const flatRowKey = Array.isArray(rowKey) ? join(rowKey, this.stringJoinChar) : rowKey; + const flatColKey = Array.isArray(colKey) ? join(colKey, this.stringJoinChar) : colKey; const indicatorIndex = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys.indexOf(indicator); // 找出受影响的小计和总计 const aggregatorsToReset: Set = new Set(); // 存储所有需要重置的聚合器 const subTotalAggregators: Set = new Set(); // 用于标记哪些聚合器是小计 - const rowKeyParts = flatRowKey.split(this.stringJoinChar); // 拆分行键以便构建小计键 - const colKeyParts = flatColKey.split(this.stringJoinChar); // 拆分列键以便构建小计键 + const rowKeyParts = Array.isArray(rowKey) ? rowKey.slice() : rowKey.split(this.stringJoinChar); + const colKeyParts = Array.isArray(colKey) ? colKey.slice() : colKey.split(this.stringJoinChar); //#endregion //#region 辅助函数定义 @@ -2360,8 +2743,10 @@ export class Dataset { }; //#endregion - //#region 处理行维度的小计和总计 - // 1. 找出受影响的行小计 + const rowAggregateKeys: string[] = []; + const colAggregateKeys: string[] = []; + + // 找出受影响的行小计 if (this.totals?.row?.subTotalsDimensions && this.totals.row.showSubTotals !== false) { for (let i = 0; i < this.totals.row.subTotalsDimensions.length; i++) { const dimension = this.totals.row.subTotalsDimensions[i]; @@ -2374,146 +2759,82 @@ export class Dataset { rowSubTotalKeyParts.push(this.rowSubTotalLabel); // 添加小计标识 } const flatRowSubTotalKey = rowSubTotalKeyParts.join(this.stringJoinChar); - - // 添加行小计与当前列的交叉点 - if (this.tree[flatRowSubTotalKey]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColKey][indicatorIndex], - flatRowSubTotalKey, - flatColKey - ); - } - - // 添加行小计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[flatRowSubTotalKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColSubTotalKey][indicatorIndex], - flatRowSubTotalKey, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行小计与列总计的交叉点 - if (this.totals?.column?.showGrandTotals) { - if (this.tree[flatRowSubTotalKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][this.colGrandTotalLabel][indicatorIndex], - flatRowSubTotalKey, - this.colGrandTotalLabel - ); - } + if (rowAggregateKeys.indexOf(flatRowSubTotalKey) === -1) { + rowAggregateKeys.push(flatRowSubTotalKey); } } } } - // 处理行总计 if (this.totals?.row?.showGrandTotals) { - // 添加行总计与当前列的交叉点 - if (this.tree[this.rowGrandTotalLabel]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColKey - ); - - // 添加行总计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[this.rowGrandTotalLabel]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColSubTotalKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行总计与列总计的交叉点(表格右下角的总计单元格) - if (this.totals?.column?.showGrandTotals) { - if (this.tree[this.rowGrandTotalLabel]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][this.colGrandTotalLabel][indicatorIndex], - this.rowGrandTotalLabel, - this.colGrandTotalLabel - ); - } + const rowGrandTotalKeys = this.getGrandTotalKeys( + rowKeyParts, + this.rows.filter((_, index) => this.rowsHasValue[index]), + this.totals.row.grandTotalDimensions, + this.rowGrandTotalLabel, + this.rows + ); + for (const rowGrandTotalKey of rowGrandTotalKeys) { + if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { + rowAggregateKeys.push(rowGrandTotalKey); } } - //#endregion + } - //#region 处理列维度的小计和总计 - // 2. 找出受影响的列小计 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - // 构建列小计的键 - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - // 添加当前行与列小计的交叉点 - if (this.tree[flatRowKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][flatColSubTotalKey][indicatorIndex], - flatRowKey, - flatColSubTotalKey - ); - } + // 找出受影响的列小计 + if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { + for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { + const colDimension = this.totals.column.subTotalsDimensions[j]; + const colDimensionIndex = this.columns.indexOf(colDimension); + if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { + const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); + colSubTotalKeyParts.push(this.colSubTotalLabel); + const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); + if (colAggregateKeys.indexOf(flatColSubTotalKey) === -1) { + colAggregateKeys.push(flatColSubTotalKey); } } } + } - // 处理列总计 - if (this.totals?.column?.showGrandTotals) { - // 添加当前行与列总计的交叉点 - if (this.tree[flatRowKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][this.colGrandTotalLabel][indicatorIndex], - flatRowKey, - this.colGrandTotalLabel - ); + if (this.totals?.column?.showGrandTotals) { + const colGrandTotalKeys = this.getGrandTotalKeys( + colKeyParts, + this.columns.filter((_, index) => this.columnsHasValue[index]), + this.totals.column.grandTotalDimensions, + this.colGrandTotalLabel, + this.columns + ); + for (const colGrandTotalKey of colGrandTotalKeys) { + if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { + colAggregateKeys.push(colGrandTotalKey); } } - //#endregion + } - //#region 重新计算受影响的聚合器 - // 重置并重新计算受影响的聚合器 - aggregatorsToReset.forEach(agg => { - // 只对行小计和列小计添加新的aggregator(直接相关的小计) - if (newCellAggregator && subTotalAggregators.has(agg)) { - agg.push(newCellAggregator); // 将新的单元格聚合器添加到小计聚合器中 - } - agg.recalculate(); // 重新计算聚合值 + // 先重算直接依赖当前单元格的行/列汇总,再重算行列汇总交叉点。 + rowAggregateKeys.forEach(rowAggregateKey => { + addAggregatorToResetSet(this.tree[rowAggregateKey]?.[flatColKey]?.[indicatorIndex], rowAggregateKey, flatColKey); + }); + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet(this.tree[flatRowKey]?.[colAggregateKey]?.[indicatorIndex], flatRowKey, colAggregateKey); + }); + rowAggregateKeys.forEach(rowAggregateKey => { + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet( + this.tree[rowAggregateKey]?.[colAggregateKey]?.[indicatorIndex], + rowAggregateKey, + colAggregateKey + ); }); - //#endregion - } + }); + + aggregatorsToReset.forEach(agg => { + if (newCellAggregator && subTotalAggregators.has(agg)) { + agg.push(newCellAggregator); + } + agg.recalculate(); + }); } changeRecordFieldValue(fieldName: string, oldValue: string | number, value: string | number) { diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index bae7c2fd28..4b890d6a4d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -38,6 +38,78 @@ import { bindDBClickAutoColumnWidthEvent } from './self-event-listener/base-tabl import { browser } from '../tools/helper'; import { clearActiveCellRangeState, setActiveCellRangeState } from '../tools/style'; +type PasteRange = { + col: number; + row: number; + maxCol: number; + maxRow: number; +}; + +type ClipboardRange = { + start: { col: number; row: number }; + end: { col: number; row: number }; +}; + +type CopySnapshot = { + ranges: ClipboardRange[]; + copySourceRange: { startCol: number; startRow: number } | null; + cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; + sourceColCount: number; + sourceRowCount: number; + clipboardData: EventClipboardData | null; + clipboardSignature: string; +}; + +type EventClipboardData = { + html: string; + text: string; + hasHtml?: boolean; + hasText?: boolean; + readFailed?: boolean; + internalFormulaText?: string; + internalFormulaHtmlMarker?: string; +}; + +type SourceCell = { + col: number; + row: number; + recordIndex?: number | number[]; + field?: any; + value?: any; +}; + +type SourceCellMatrix = (SourceCell | null)[][]; + +type CutState = { + id: number; + ranges: ClipboardRange[]; + cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; + sourceColCount: number; + sourceRowCount: number; + clipboardData: EventClipboardData | null; + clipboardSignature: string; + copySourceRange: { startCol: number; startRow: number } | null; + isSingleRange: boolean; +}; + +type PasteContext = { + pasteRange: PasteRange; + copySourceRange: { startCol: number; startRow: number } | null; + cutState?: CutState | null; + pasteOperationId?: number; +}; + +type PasteWriteResult = { + pasted: boolean; + changedCellResults: boolean[][]; + changedCells: SourceCell[]; + migratedSourceCells: SourceCell[]; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -80,7 +152,17 @@ export class EventManager { /** 剪切后等待粘贴 */ cutWaitPaste: boolean = false; private clipboardCheckTimer: number | null = null; // 剪贴板检测定时器 - private cutOperationTime: number = 0; // 记录剪切操作的时间 + private cutOperationId: number = 0; + private clipboardOperationId: number = 0; + private pasteOperationId: number = 0; + private pasteWriteInProgress: boolean = false; + private pasteWritePromise: Promise = Promise.resolve(); + private clipboardWritePromise: Promise = Promise.resolve(); + private lastCopiedClipboardData: EventClipboardData | null = null; + private internalFormulaHtmlMarkerId: number = 0; + private fieldKeyIds: WeakMap = new WeakMap(); + private fieldKeyId: number = 0; + private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; cutRanges: CellRange[] | null = null; @@ -744,6 +826,9 @@ export class EventManager { } /** TODO 其他的事件并么有做remove */ release() { + this.clipboardOperationId++; + this.pasteOperationId++; + this.resetCutState(); this.gesture?.release(); // remove global event listerner @@ -769,23 +854,20 @@ export class EventManager { this._enableTableScroll = false; } - async handleCopy(e: KeyboardEvent, isCut: boolean = false) { + async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; - !isCut && (this.cutWaitPaste = false); + const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; + if (!isCut) { + this.resetCutState(); + } + const copySnapshot = this.getCopySnapshot(isCut); this.copySourceRange = null; - // 记录复制时的源位置(用于公式相对引用调整) - const sourceRanges = table.stateManager.select.ranges; - if (sourceRanges && sourceRanges.length === 1) { - // 只有一个选区的时候才需要采取解析公式(和excel一致),才需要记录源位置 - const sourceRange = sourceRanges[0]; - this.copySourceRange = { - startCol: Math.min(sourceRange.start.col, sourceRange.end.col), - startRow: Math.min(sourceRange.start.row, sourceRange.end.row) - }; - } else if (!sourceRanges?.length) { + this.lastCopiedClipboardData = null; + if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 - return; + return null; } const data = this.table.getCopyValue( @@ -793,6 +875,55 @@ export class EventManager { ); if (isValid(data)) { e.preventDefault(); + let copySucceeded = false; + let copiedClipboardData: EventClipboardData | null = null; + + const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; + const canWriteRichClipboard = window.isSecureContext && !!navigator.clipboard?.write && !!window.ClipboardItem; + const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; + const dataHTML = + !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; + const formulaClipboardData = this.getCopyFormulaPlainData(data, isCut); + if (formulaClipboardData === null) { + return null; + } + const plainClipboardData = data; + const internalFormulaText = formulaClipboardData !== plainClipboardData ? formulaClipboardData : undefined; + const internalFormulaHtmlMarker = internalFormulaText ? this.createInternalFormulaHtmlMarker() : undefined; + const clipboardHTML = + dataHTML && internalFormulaHtmlMarker + ? this.attachInternalFormulaHtmlMarker(dataHTML, internalFormulaHtmlMarker) + : dataHTML; + const eventClipboardWriteData = hasEventClipboard + ? this.setCopyDataToEventClipboard( + plainClipboardData, + clipboardHTML, + e, + internalFormulaText, + internalFormulaHtmlMarker + ) + : null; + if (eventClipboardWriteData) { + copiedClipboardData = eventClipboardWriteData; + this.lastCopiedClipboardData = copiedClipboardData; + if (isCut) { + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; + copySnapshot.clipboardSignature = this.lastClipboardContent; + } + this.copySourceRange = copySnapshot.copySourceRange; + this.afterCopyData(data, isCut, copySnapshot, !isCut); + if (isCut && table.keyboardOptions?.showCopyCellBorder) { + window.setTimeout(() => { + if (!this.isClipboardOperationCurrent(operationId)) { + return; + } + setActiveCellRangeState(table, copySnapshot.ranges); + table.clearSelected(); + }, 0); + } + return copySnapshot; + } // 确保表格元素获得焦点,避免Document is not focused错误 const element = table.getElement(); @@ -800,6 +931,9 @@ export class EventManager { element.focus(); // 短暂延迟,确保焦点设置完成 await new Promise(resolve => setTimeout(resolve, 10)); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } try { @@ -813,6 +947,9 @@ export class EventManager { name: 'clipboard-write' as PermissionName }); hasPermission = permissionState.state === 'granted'; + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } catch (permissionError) { // 权限查询失败,继续尝试写入 console.warn('无法查询剪贴板权限:', permissionError); @@ -824,71 +961,455 @@ export class EventManager { // 将复制的数据转为html格式 try { - // 尝试使用 ClipboardItem(支持富文本) - if (window.ClipboardItem) { - let htmlValues = data; - if ( - table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) - table.options.keyboardOptions?.getCopyCellValue?.html - ) { - htmlValues = this.table.getCopyValue( - table.options.keyboardOptions?.getCopyCellValue.html as ( - col: number, - row: number - ) => string | number - ); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + copiedClipboardData = await this.queueClipboardWrite(async () => { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + // 尝试使用 ClipboardItem(支持富文本) + if (canWriteRichClipboard && clipboardHTML) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([clipboardHTML], { type: 'text/html' }), + 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) + }) + ]); + return { + html: clipboardHTML, + text: plainClipboardData, + hasHtml: true, + hasText: true, + internalFormulaText, + internalFormulaHtmlMarker + }; } - const dataHTML = setDataToHTML(htmlValues); - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([dataHTML], { type: 'text/html' }), - 'text/plain': new Blob([data], { type: 'text/plain' }) - }) - ]); - } else { // 降级到纯文本 - await navigator.clipboard.writeText(data); + await navigator.clipboard.writeText(plainClipboardData); + return { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; + }); + if (copiedClipboardData) { + copySucceeded = true; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 - this.fallbackCopyToClipboard(data, e); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + if (copySucceeded) { + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; + } } } else { // 没有权限,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); + if (copySucceeded) { + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; + } } } else { // 不支持现代剪贴板API,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); + if (copySucceeded) { + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; + } } - table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: table.stateManager.select.ranges, - copyData: data, - isCut - }); + if (!copySucceeded) { + return null; + } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + + this.lastCopiedClipboardData = copiedClipboardData; + if (isCut) { + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; + copySnapshot.clipboardSignature = this.lastClipboardContent; + } + this.copySourceRange = copySnapshot.copySourceRange; + this.afterCopyData(data, isCut, copySnapshot, false); } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 - this.fallbackCopyToClipboard(data, e); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + if (!this.fallbackCopyToClipboard(plainClipboardData)) { + return null; + } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; + if (isCut) { + this.lastClipboardContent = this.getClipboardSignature(this.lastCopiedClipboardData); + copySnapshot.clipboardData = this.lastCopiedClipboardData; + copySnapshot.clipboardSignature = this.lastClipboardContent; + } + this.copySourceRange = copySnapshot.copySourceRange; + this.afterCopyData(data, isCut, copySnapshot, false); } + } else { + return null; } if (table.keyboardOptions?.showCopyCellBorder) { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); } + return copySnapshot; } - // 降级复制方案 - private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void { + private afterCopyData( + data: string, + isCut: boolean, + copySnapshot: CopySnapshot, + updateCopyCellBorder: boolean = true + ): void { + const table = this.table; try { - // 尝试使用旧的 clipboardData API (在事件处理函数中直接设置) - if ((e as any).clipboardData) { - (e as any).clipboardData.setData('text/plain', data); - return; + table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { + cellRange: this.cloneRanges(copySnapshot.ranges), + copyData: data, + isCut + }); + } catch (error) { + console.warn('COPY_DATA listener failed:', error); + } + if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { + setActiveCellRangeState(table, copySnapshot.ranges); + table.clearSelected(); + } + } + + private getCopySnapshot(includeCellInfos: boolean): CopySnapshot | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + + const clonedRanges = ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + const sourceRange = ranges.length === 1 ? ranges[0] : null; + const sourceCellMatrix = this.getSourceCellMatrix(clonedRanges); + const sourceCells = this.getSourceCells(sourceCellMatrix); + const sourceSize = this.getSourceSize(clonedRanges, sourceCellMatrix); + + return { + ranges: clonedRanges, + copySourceRange: sourceRange + ? { + startCol: Math.min(sourceRange.start.col, sourceRange.end.col), + startRow: Math.min(sourceRange.start.row, sourceRange.end.row) + } + : null, + cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, + sourceCells, + sourceCellMatrix, + sourceColCount: sourceSize.colCount, + sourceRowCount: sourceSize.rowCount, + clipboardData: null, + clipboardSignature: '' + }; + } + + private cloneRanges(ranges: ClipboardRange[]): ClipboardRange[] { + return ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + } + + private getSourceCellMatrix(ranges: ClipboardRange[]): SourceCellMatrix { + const table = this.table as ListTableAPI; + const createSourceCell = (col: number, row: number): SourceCell => { + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }; + }; + if (ranges.length === 1) { + const range = ranges[0]; + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + const sourceCellMatrix: SourceCellMatrix = []; + for (let row = startRow; row <= endRow; row++) { + const rowCells: (SourceCell | null)[] = []; + for (let col = startCol; col <= endCol; col++) { + rowCells.push(createSourceCell(col, row)); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + let minCol = Math.min(ranges[0].start.col, ranges[0].end.col); + let maxCol = Math.max(ranges[0].start.col, ranges[0].end.col); + let minRow = Math.min(ranges[0].start.row, ranges[0].end.row); + let maxRow = Math.max(ranges[0].start.row, ranges[0].end.row); + ranges.forEach(range => { + minCol = Math.min(minCol, range.start.col, range.end.col); + maxCol = Math.max(maxCol, range.start.col, range.end.col); + minRow = Math.min(minRow, range.start.row, range.end.row); + maxRow = Math.max(maxRow, range.start.row, range.end.row); + }); + const isExistDataInRow = (row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + return startRow <= row && endRow >= row; + }); + const isExistDataInCol = (col: number) => + ranges.some(range => { + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col; + }); + const getRangeExistDataInCell = (col: number, row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col && startRow <= row && endRow >= row; + }); + const sourceCellMatrix: SourceCellMatrix = []; + for (let row = minRow; row <= maxRow; row++) { + if (!isExistDataInRow(row)) { + continue; + } + const rowCells: (SourceCell | null)[] = []; + for (let col = minCol; col <= maxCol; col++) { + if (!isExistDataInCol(col)) { + continue; + } + rowCells.push(getRangeExistDataInCell(col, row) ? createSourceCell(col, row) : null); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + private getSourceCells(sourceCellMatrix: SourceCellMatrix): SourceCell[] { + const sourceCells: SourceCell[] = []; + for (let row = 0; row < sourceCellMatrix.length; row++) { + for (let col = 0; col < sourceCellMatrix[row].length; col++) { + const sourceCell = sourceCellMatrix[row][col]; + if (sourceCell) { + sourceCells.push(sourceCell); + } + } + } + return sourceCells; + } + + private getSourceSize( + ranges: ClipboardRange[], + sourceCellMatrix: SourceCellMatrix + ): { colCount: number; rowCount: number } { + if (sourceCellMatrix.length) { + return { + colCount: sourceCellMatrix[0]?.length ?? 0, + rowCount: sourceCellMatrix.length + }; + } + if (ranges.length === 1) { + const range = ranges[0]; + return { + colCount: Math.abs(range.end.col - range.start.col) + 1, + rowCount: Math.abs(range.end.row - range.start.row) + 1 + }; + } + return { colCount: 0, rowCount: 0 }; + } + + private getClipboardSignature(data: EventClipboardData | null): string { + return data + ? JSON.stringify({ + html: data.hasHtml ? data.html : null, + text: data.hasText ? data.text : null + }) + : ''; + } + + private isClipboardOperationCurrent(operationId: number): boolean { + return operationId === this.clipboardOperationId && !this.table.isReleased; + } + + private isPasteOperationCurrent(operationId: number | undefined): boolean { + return (operationId === undefined || operationId === this.pasteOperationId) && !this.table.isReleased; + } + + private async queueClipboardWrite( + write: () => Promise + ): Promise { + const previousWrite = this.clipboardWritePromise; + let settleCurrentWrite: () => void = () => { + return; + }; + this.clipboardWritePromise = previousWrite + .catch(() => { + // Ignore stale write failures so the latest copy can still proceed. + }) + .then( + () => + new Promise(resolve => { + settleCurrentWrite = resolve; + }) + ); + await previousWrite.catch(() => { + // Previous clipboard write failure should not block later copy attempts. + }); + try { + return await write(); + } finally { + settleCurrentWrite(); + } + } + + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { + if (!clipboardData || clipboardData.readFailed) { + return null; + } + const sourceData = cutState.clipboardData; + if (!sourceData) { + return this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + } + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return null; + } + if (sourceData.hasText && !clipboardData.hasText) { + return null; + } + let hasComparableType = false; + if (clipboardData.hasHtml && sourceData.hasHtml) { + hasComparableType = true; + if (clipboardData.html !== sourceData.html) { + return true; + } + } + if (clipboardData.hasText && sourceData.hasText) { + hasComparableType = true; + if (clipboardData.text !== sourceData.text) { + return true; + } + } + if (hasComparableType) { + return false; + } + return null; + } + + private isClipboardDataSameAsLastCopy(clipboardData: EventClipboardData | null): boolean { + const sourceData = this.lastCopiedClipboardData; + if (!clipboardData || clipboardData.readFailed || !sourceData) { + return false; + } + return this.isClipboardDataSameBySignature(clipboardData, sourceData); + } + + private isClipboardDataSameBySignature(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { + return ( + sourceData.hasHtml === clipboardData.hasHtml && + sourceData.hasText === clipboardData.hasText && + (!sourceData.hasHtml || sourceData.html === clipboardData.html) && + (!sourceData.hasText || sourceData.text === clipboardData.text) + ); + } + + private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { + const table = this.table; + if (table.stateManager.select.ranges.length !== 1 || !table.options.keyboardOptions?.getCopyCellValue?.html) { + return data; + } + try { + return this.table.getCopyValue( + table.options.keyboardOptions.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } catch (error) { + console.warn('复制公式纯文本数据生成失败,使用显示值:', error); + return isCut ? null : data; + } + } + + private getCopyDataHTML(data: string): string | null { + const table = this.table; + let htmlValues = data; + try { + if ( + table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) + table.options.keyboardOptions?.getCopyCellValue?.html + ) { + htmlValues = this.table.getCopyValue( + table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number + ); } + return setDataToHTML(htmlValues); + } catch (error) { + console.warn('复制HTML数据生成失败,使用纯文本内容:', error); + try { + return setDataToHTML(data); + } catch (fallbackError) { + console.warn('复制HTML降级数据生成失败:', fallbackError); + return null; + } + } + } + private createInternalFormulaHtmlMarker(): string { + return `vtable-formula-${Date.now()}-${++this.internalFormulaHtmlMarkerId}`; + } + + private attachInternalFormulaHtmlMarker(html: string, marker: string): string { + return `${html}`; + } + + private setCopyDataToEventClipboard( + data: string, + dataHTML: string | null, + e: KeyboardEvent, + internalFormulaText?: string, + internalFormulaHtmlMarker?: string + ): EventClipboardData | null { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData; + if (!clipboardData) { + return null; + } + + try { + clipboardData.setData('text/plain', data); + const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true, internalFormulaText }; + if (dataHTML) { + try { + clipboardData.setData('text/html', dataHTML); + copiedClipboardData.html = dataHTML; + copiedClipboardData.hasHtml = true; + copiedClipboardData.internalFormulaHtmlMarker = internalFormulaHtmlMarker; + } catch (error) { + console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); + } + } + return copiedClipboardData; + } catch (error) { + console.warn('事件剪贴板写入失败,使用降级方案:', error); + return null; + } + } + + // 降级复制方案 + private fallbackCopyToClipboard(data: string): boolean { + try { // 确保当前文档有焦点 if (document.activeElement && document.activeElement !== document.body) { (document.activeElement as HTMLElement).blur(); @@ -915,6 +1436,7 @@ export class EventManager { if (!successful) { console.warn('execCommand复制返回false,可能不被支持'); } + return successful; } catch (execError) { console.warn('execCommand复制失败:', execError); } finally { @@ -923,16 +1445,31 @@ export class EventManager { } catch (error) { console.error('降级复制方案失败:', error); } + return false; } async handleCut(e: KeyboardEvent) { - this.handleCopy(e, true); + const copySnapshot = await this.handleCopy(e, true); + if (!copySnapshot) { + return; + } + const cutState: CutState = { + id: ++this.cutOperationId, + ranges: this.cloneRanges(copySnapshot.ranges), + cellInfos: copySnapshot.cellInfos, + sourceCells: copySnapshot.sourceCells, + sourceCellMatrix: copySnapshot.sourceCellMatrix, + sourceColCount: copySnapshot.sourceColCount, + sourceRowCount: copySnapshot.sourceRowCount, + clipboardData: copySnapshot.clipboardData, + clipboardSignature: copySnapshot.clipboardSignature, + copySourceRange: copySnapshot.copySourceRange, + isSingleRange: copySnapshot.ranges.length === 1 + }; this.cutWaitPaste = true; - this.cutCellRange = this.table.getSelectedCellInfos(); - this.cutRanges = this.table.stateManager.select.ranges?.map(r => ({ - start: { col: r.start.col, row: r.start.row }, - end: { col: r.end.col, row: r.end.row } - })); + this.activeCutState = cutState; + this.cutCellRange = cutState.cellInfos; + this.cutRanges = cutState.ranges; // 设置自动超时,防止剪切状态无限期保持 if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); @@ -942,360 +1479,395 @@ export class EventManager { this.clipboardCheckTimer = window.setTimeout(() => { if (this.cutWaitPaste) { // 剪切操作超时,重置剪切状态 - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - this.clipboardCheckTimer = null; + this.resetCutState(); } }, 30000); // 30秒超时 - // 保存剪贴板内容以便后续检测变化 - this.saveClipboardContent(); + this.lastClipboardContent = cutState.clipboardSignature; } // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteOperationId = ++this.pasteOperationId; + const pasteRange = this.getPasteRange(); + if (!pasteRange) { + return; + } + const eventClipboardData = this.getEventClipboardData(e); + const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(e); + this.readClipboardDataForPaste(eventClipboardData) + .then(clipboardData => { + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; + } + const copySourceRange = this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null; + this.executePaste(clipboardData, { + pasteRange, + copySourceRange, + pasteOperationId + }); + }) + .catch(error => { + console.warn('读取剪贴板数据失败:', error); + }); return; } - this.checkClipboardChanged() - .then(changed => { - // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - this.executePaste(e); - if (!changed) { - this.clearCutArea(this.table as ListTableAPI); + const cutState = this.activeCutState; + if (!cutState) { + this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); + return; + } + + this.readClipboardDataForPaste(eventClipboardData) + .then(async clipboardData => { + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; + } + const changed = this.isClipboardDataChanged(clipboardData, cutState); + if (changed === null) { + await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); + return; + } + const pasteResult = await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: changed ? null : cutState.copySourceRange, + cutState: changed ? null : cutState, + pasteOperationId + }); + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; } - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; + if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { + if (pasteResult.migratedSourceCells.length === cutState.sourceCells.length) { + const cleared = await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (!cleared) { + this.resetCutState(); + return; + } + if (this.activeCutState?.id === cutState.id) { + this.resetCutState(); + } } + return; + } + if (changed && this.activeCutState?.id === cutState.id) { + this.resetCutState(); } }) - .catch(() => { + .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - this.executePaste(e); - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; - } + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; } + await this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); }); } - private async executePaste(e: any) { + private async executePaste( + clipboardData: EventClipboardData | null, + pasteContext: PasteContext + ): Promise { const table = this.table; - if ((table as ListTableAPI).editorManager?.editingEditor) { - return; + const emptyResult = this.getEmptyPasteResult(); + const pasteClipboardData = this.getPasteClipboardData(clipboardData, pasteContext); + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { + return emptyResult; } - if ((table as ListTableAPI).changeCellValues && table.stateManager.select.ranges?.length > 0) { - try { - // 优先使用现代剪贴板API - if (navigator.clipboard && navigator.clipboard.read) { - try { - // 读取剪切板数据 - const clipboardItems = await navigator.clipboard.read(); - let handled = false; - - for (const item of clipboardItems) { - // 优先处理 html 格式数据 - if (item.types.includes('text/html')) { - await this.pasteHtmlToTable(item); - handled = true; - break; - } else if (item.types.includes('text/plain')) { - await this.pasteTextToTable(item); - handled = true; - break; + if (this.pasteWriteInProgress) { + await this.pasteWritePromise.catch(() => { + // Previous paste failures are handled by their own task. + }); + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { + return emptyResult; + } + } + let pasteResult = emptyResult; + let settlePasteWrite: () => void = () => { + return; + }; + this.pasteWritePromise = new Promise(resolve => { + settlePasteWrite = resolve; + }); + this.pasteWriteInProgress = true; + try { + if ((table as ListTableAPI).changeCellValues) { + try { + if (pasteClipboardData?.html) { + try { + const htmlResult = await this.processPastedHTML(pasteClipboardData.html, pasteContext); + if (htmlResult.pasted) { + pasteResult = htmlResult; + return pasteResult; + } + } catch (error) { + console.warn('粘贴HTML数据失败:', error); + if (/ { - const table = this.table; - const ranges = table.stateManager.select.ranges; - const col = Math.min(ranges[0].start.col, ranges[0].end.col); - const row = Math.min(ranges[0].start.row, ranges[0].end.row); + private getPasteRange(): PasteRange | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + const selectRangeLength = ranges.length; + const range = ranges[selectRangeLength - 1]; + return { + col: Math.min(range.start.col, range.end.col), + row: Math.min(range.start.row, range.end.row), + maxCol: Math.max(range.start.col, range.end.col), + maxRow: Math.max(range.start.row, range.end.row) + }; + } + // 降级粘贴方案 + private getEventClipboardData(e: KeyboardEvent): EventClipboardData | null { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData || (window as any).clipboardData; + if (!clipboardData) { + return null; + } try { - // 确保表格元素获得焦点 - const element = table.getElement(); - if (element && element !== document.activeElement) { - element.focus(); - // 短暂延迟,确保焦点设置完成 - await new Promise(resolve => setTimeout(resolve, 10)); - } - - // 尝试从事件对象获取剪贴板数据 - const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard; + const types = Array.from(clipboardData.types ?? []); + const html = clipboardData.getData('text/html') || ''; + const text = + clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || ''; + const hasHtml = types.includes('text/html') || !!html; + const hasText = types.includes('text/plain') || types.includes('text') || types.includes('Text') || !!text; + return { + html, + text, + hasHtml, + hasText + }; + } catch (error) { + console.warn('读取事件剪贴板数据失败:', error); + return { html: '', text: '', readFailed: true }; + } + } - if (clipboardData) { - const pastedData = clipboardData.getData('text') || clipboardData.getData('Text'); - if (pastedData) { - await this.processPastedText(pastedData, col, row); - return; + private async readClipboardDataForPaste( + eventClipboardData: EventClipboardData | null + ): Promise { + if (eventClipboardData?.readFailed || eventClipboardData?.hasHtml || eventClipboardData?.hasText) { + return eventClipboardData; + } + if (navigator.clipboard?.read) { + try { + const clipboardItems = await navigator.clipboard.read(); + const clipboardData: EventClipboardData = { html: '', text: '' }; + for (const item of clipboardItems) { + if (!clipboardData.html && item.types.includes('text/html')) { + try { + clipboardData.html = await (await item.getType('text/html')).text(); + clipboardData.hasHtml = true; + } catch (error) { + console.warn('读取HTML剪贴板数据失败:', error); + } + } + if (!clipboardData.text && item.types.includes('text/plain')) { + try { + clipboardData.text = await (await item.getType('text/plain')).text(); + clipboardData.hasText = true; + } catch (error) { + console.warn('读取纯文本剪贴板数据失败:', error); + } + } } + if (clipboardData.hasHtml || clipboardData.hasText) { + return clipboardData; + } + } catch (error) { + console.warn('现代剪贴板API读取失败,使用事件剪贴板数据:', error); + } + } + if (navigator.clipboard?.readText) { + try { + return { html: '', text: await navigator.clipboard.readText(), hasText: true }; + } catch (error) { + console.warn('剪贴板纯文本读取失败,使用事件剪贴板数据:', error); } - } catch (error) { - console.error('降级粘贴方案失败:', error); } + return eventClipboardData ?? { html: '', text: '', readFailed: true }; } - // 处理粘贴的文本数据 - private async processPastedText(pastedData: string, col: number, row: number): Promise { + private decodeHTMLCellValue(cellHTML: string): string { + const template = document.createElement('template'); + template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n').replace(/ /gi, ' '); + return template.content.textContent ?? ''; + } + + private async processPastedHTML(pastedData: string, pasteContext: PasteContext): Promise { + // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 + const regex = /]*>([\s\S]*?)<\/tr>/gi; // for webpack3 + // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 + const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; // for webpack3 const table = this.table; - const rows = pastedData.split('\n'); // 将数据拆分为行 - const values: (string | number)[][] = []; + const pasteRange = pasteContext.pasteRange; + const { col, row, maxCol, maxRow } = pasteRange; + let pasteValuesColCount = 0; + let pasteValuesRowCount = 0; + let values: (string | number)[][] = []; - rows.forEach(function (rowCells: any) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); + if (!pastedData || !/标签中的内容 + const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取/标签及其内容 + if (!cellMatches.length) { + continue; + } + const rowValues = cellMatches.map(cellMatch => { + return this.decodeHTMLCellValue(cellMatch[1]); + // .trim(); // 去除首尾空格 }); - }); + values.push(rowValues); + pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); + } + + pasteValuesRowCount = values.length ?? 0; + if (!pasteValuesRowCount || !pasteValuesColCount) { + return this.getEmptyPasteResult(); + } + values = this.handlePasteValues( + values, + pasteValuesRowCount, + pasteValuesColCount, + maxRow - row + 1, + maxCol - col + 1 + ); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 利用复制时记录的源位置,对粘贴的数据进行公式处理 + if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { + // 使用复制时记录的源位置(而不是当前的选中位置) processedValues = table.options.keyboardOptions.processFormulaBeforePaste( values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, + pasteContext.copySourceRange.startCol, + pasteContext.copySourceRange.startRow, col, row ); } - // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); + const valuesToPaste = processedValues ? processedValues : values; + const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); } - } - // 清空选中区域的内容 - private clearCutArea(table: ListTableAPI): void { - try { - const ranges = this.cutRanges; - if (!ranges || ranges.length === 0) { - return; + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); } - - table.changeCellValuesByRanges(ranges, ''); - } catch (error) { - console.error('清空单元格内容失败', error); } - } - - // 检查剪贴板内容是否被其他应用更改 - private async checkClipboardChanged(): Promise { - // 如果不支持读取剪贴板,则无法检测变化 - if (!navigator.clipboard || !navigator.clipboard.readText) { - return false; + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + valuesToPaste, + !pasteContext.cutState, + true, + undefined, + () => !this.isPasteOperationCurrent(pasteContext.pasteOperationId) + ); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); } - - try { - const currentContent = await navigator.clipboard.readText(); - // console.log('当前剪贴板内容:', currentContent); - // console.log('上次保存的剪贴板内容:', this.lastClipboardContent); - - // 比较当前剪贴板内容与剪切时保存的内容 - return currentContent !== this.lastClipboardContent; - } catch (err) { - console.warn('检查剪贴板状态失败:', err); - // 出错时假设剪贴板未变化 - return false; + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); + const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); + if (!changedCells.length) { + return this.getEmptyPasteResult(changedCellResults); } + this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; } - // 保存剪贴板内容 - private saveClipboardContent(): void { - // 尝试获取剪贴板内容 - if (navigator.clipboard && navigator.clipboard.readText) { - // 延迟一点以确保剪贴板内容已更新 - setTimeout(() => { - navigator.clipboard - .readText() - .then(text => { - this.lastClipboardContent = text; - console.log('已保存剪贴板状态'); - }) - .catch(err => { - console.warn('无法读取剪贴板内容:', err); - }); - }, 50); - } - } - private pasteHtmlToTable(item: ClipboardItem) { - // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 - const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 - // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 - const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 - const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; - item.getType('text/html').then((blob: any) => { - blob.text().then(async (pastedData: any) => { - // 解析html数据 - if (pastedData && /(标签中的内容 - const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签中的内容 - const rowValues = cellMatches.map(cellMatch => { - return ( - cellMatch[1] - .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 - .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n - // .replace(/
/g, '\n') // 替换
标签为换行符 - // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 - //将字符串中的 HTML 实体字符转换为原始的字符 - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/ /gi, '\t') - .replace(/ /g, ' ') - ); - // .trim(); // 去除首尾空格 - }); - values.push(rowValues); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - } - pasteValuesRowCount = values.length ?? 0; - values = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - let processedValues; - // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 使用复制时记录的源位置(而不是当前的选中位置) - processedValues = table.options.keyboardOptions.processFormulaBeforePaste( - values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, - col, - row - ); - } - - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); - } - } else { - navigator.clipboard.read().then(clipboardItems => { - for (const item of clipboardItems) { - if (item.types.includes('text/plain')) { - item.getType('text/plain').then((blob: Blob) => { - blob.text().then(data => this._pasteValue(data)); - }); - } - } - }); - } - }); - }); - } - - private async _pasteValue(pastedData: string) { + // 处理粘贴的文本数据 + private async processPastedText(pastedData: string, pasteContext: PasteContext): Promise { const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; - const rows = pastedData.split('\n'); // 将数据拆分为行 - rows.forEach(function (rowCells: any, rowIndex: number) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); - }); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - }); - pasteValuesRowCount = values.length ?? 0; + const pasteRange = pasteContext.pasteRange; + const { col, row, maxCol, maxRow } = pasteRange; + let values = this.parsePlainTextPastedData(pastedData); + const pasteValuesRowCount = values.length; + const pasteValuesColCount = Math.max(...values.map(rowValues => rowValues.length)); + if (!pasteValuesRowCount || !pasteValuesColCount) { + return this.getEmptyPasteResult(); + } values = this.handlePasteValues( values, pasteValuesRowCount, @@ -1305,71 +1877,347 @@ export class EventManager { ); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { + if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { // 利用复制时记录的源位置,对粘贴的数据进行公式处理 processedValues = table.options.keyboardOptions.processFormulaBeforePaste( values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, + pasteContext.copySourceRange.startCol, + pasteContext.copySourceRange.startRow, col, row ); } + + const valuesToPaste = processedValues ? processedValues : values; + const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); + } + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( col, row, - processedValues ? processedValues : values, - true + valuesToPaste, + !pasteContext.cutState, + true, + undefined, + () => !this.isPasteOperationCurrent(pasteContext.pasteOperationId) ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); + const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); + if (!changedCells.length) { + return this.getEmptyPasteResult(changedCellResults); + } + this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; + } + + private firePastedDataEvent( + col: number, + row: number, + pasteData: (string | number)[][], + changedCellResults: any + ): void { + const table = this.table; + if (!table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + return; + } + try { table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { col, row, - pasteData: processedValues ? processedValues : values, + pasteData, changedCellResults }); + } catch (error) { + console.warn('PASTED_DATA listener failed:', error); } } - private async pasteTextToTable(item: ClipboardItem) { - const table = this.table; - // 如果只有 'text/plain' - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + private getEmptyPasteResult(changedCellResults: boolean[][] = []): PasteWriteResult { + return { + pasted: false, + changedCellResults, + changedCells: [], + migratedSourceCells: [] + }; + } - try { - const blob = await item.getType('text/plain'); - const pastedData = await blob.text(); - const values = this.parsePastedData(pastedData); + private getPasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): SourceCell[][] { + const targetCells: SourceCell[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + targetCells[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + targetCells[rowIndex][colIndex] = this.getCellIdentity(pasteRange.col + colIndex, pasteRange.row + rowIndex); + } + } + return targetCells; + } - const pasteValuesRowCount = values.length; - const pasteValuesColCount = Math.max(...values.map(row => row.length), 0); + private getChangedCells(targetCells: SourceCell[][], changedCellResults: boolean[][]): SourceCell[] { + const changedCells: SourceCell[] = []; + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (changedCellResults[rowIndex][colIndex] && targetCells[rowIndex]?.[colIndex]) { + changedCells.push(targetCells[rowIndex][colIndex]); + } + } + } + return changedCells; + } - const processedValues = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); + private normalizeChangedCellResults( + changedCellResults: boolean[][] | void, + pasteRange: PasteRange, + values: (string | number)[][] + ): boolean[][] { + if (Array.isArray(changedCellResults)) { + return changedCellResults; + } + const table = this.table as any; + return values.map((rowValues, rowIndex) => + rowValues.map((_value, colIndex) => { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + return ( + targetCol < table.colCount && + targetRow < table.rowCount && + (!table.isHasEditorDefine || table.isHasEditorDefine(targetCol, targetRow)) + ); + }) + ); + } - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true); + private async validatePasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): Promise { + const table = this.table as ListTableAPI; + const changedCellResults: boolean[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + changedCellResults[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + const tableAny = table as any; + let canChange = + targetCol < tableAny.colCount && + targetRow < tableAny.rowCount && + (!tableAny.isHasEditorDefine || tableAny.isHasEditorDefine(targetCol, targetRow)); + if (canChange) { + const editor = tableAny.getEditor?.(targetCol, targetRow); + const oldValue = table.getCellOriginValue?.(targetCol, targetRow); + const validateResult = + editor?.validateValue?.(values[rowIndex][colIndex], oldValue, { col: targetCol, row: targetRow }, table) ?? + true; + const resolvedValidateResult = + validateResult && typeof (validateResult as Promise).then === 'function' + ? await validateResult + : validateResult; + canChange = + resolvedValidateResult === true || + resolvedValidateResult === 'validate-exit' || + resolvedValidateResult === 'validate-not-exit'; + } + changedCellResults[rowIndex][colIndex] = canChange; + } + } + return changedCellResults; + } - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues, - changedCellResults + private isFullPasteAllowed(values: (string | number)[][], changedCellResults: boolean[][]): boolean { + return values.every((rowValues, rowIndex) => + rowValues.every((_value, colIndex) => changedCellResults[rowIndex]?.[colIndex] === true) + ); + } + + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { + if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { + return []; + } + const migratedSourceCells: SourceCell[] = []; + const migratedKeys = new Set(); + if (!cutState.isSingleRange) { + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceCell = cutState.sourceCellMatrix[sourceRow]?.[sourceCol]; + if (!sourceCell) { + continue; + } + const sourceKey = this.getCellKey(sourceCell); + if (!migratedKeys.has(sourceKey)) { + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + } + return migratedSourceCells.length === cutState.sourceCells.length ? migratedSourceCells : []; + } + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceIndex = sourceRow * cutState.sourceColCount + sourceCol; + const sourceCell = cutState.sourceCells[sourceIndex]; + if (!sourceCell) { + continue; + } + const sourceKey = this.getCellKey(sourceCell); + if (migratedKeys.has(sourceKey)) { + continue; + } + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + return migratedSourceCells; + } + + private getCellIdentity(col: number, row: number): SourceCell { + const table = this.table as ListTableAPI; + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field }; + } + + // 清空选中区域的内容 + private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { + try { + const table = this.table as ListTableAPI; + const changedTargetKeys = new Set(); + changedTargetCells.forEach(cell => { + changedTargetKeys.add(this.getCellKey(cell)); + changedTargetKeys.add(this.getCoordKey(cell)); + }); + const clearedKeys = new Set(); + const sourceChanges: { recordIndex: number | number[]; field: any; value: string }[] = []; + const coordChanges: { col: number; row: number }[] = []; + for (let i = 0; i < migratedSourceCells.length; i++) { + const sourceCell = migratedSourceCells[i]; + const sourceKey = this.getCellKey(sourceCell); + if ( + clearedKeys.has(sourceKey) || + changedTargetKeys.has(sourceKey) || + changedTargetKeys.has(this.getCoordKey(sourceCell)) + ) { + continue; + } + const currentAddress = this.getCurrentCellAddress(sourceCell); + if (!currentAddress) { + continue; + } + if (!this.isSourceCellUnchanged(sourceCell, currentAddress)) { + continue; + } + if ( + sourceCell.recordIndex !== undefined && + sourceCell.field !== undefined && + (table as any).changeCellValuesByRecords + ) { + sourceChanges.push({ recordIndex: sourceCell.recordIndex, field: sourceCell.field, value: '' }); + } else { + coordChanges.push(currentAddress); + } + clearedKeys.add(sourceKey); + } + if (sourceChanges.length) { + (table as any).changeCellValuesByRecords(sourceChanges, { + triggerEvent: true, + autoRefresh: false }); } + for (let i = 0; i < coordChanges.length; i++) { + await table.changeCellValues(coordChanges[i].col, coordChanges[i].row, [['']], false); + } + if (sourceChanges.length) { + (table as any).refreshAfterSourceChange?.(); + } + return true; } catch (error) { - // 静默处理粘贴错误,保持原有行为 - console.warn('Paste operation failed:', error); + console.error('清空单元格内容失败', error); + return false; + } + } + + private isSourceCellUnchanged(sourceCell: SourceCell, currentAddress: { col: number; row: number }): boolean { + const table = this.table as ListTableAPI; + return Object.is(table.getCellOriginValue?.(currentAddress.col, currentAddress.row), sourceCell.value); + } + + private getCurrentCellAddress(sourceCell: SourceCell): { col: number; row: number } | null { + const table = this.table as ListTableAPI; + if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { + const address = table.getCellAddrByFieldRecord(sourceCell.field, sourceCell.recordIndex); + if (address && address.col >= 0 && address.row >= 0) { + return address; + } + return null; + } + return { col: sourceCell.col, row: sourceCell.row }; + } + + private getCellKey(cell: SourceCell): string { + if (cell.recordIndex !== undefined && cell.field !== undefined) { + return `record:${this.getStableKeyPart(cell.recordIndex)}:${this.getStableKeyPart(cell.field)}`; + } + return this.getCoordKey(cell); + } + + private getCoordKey(cell: SourceCell): string { + return `coord:${cell.col}:${cell.row}`; + } + + private getStableKeyPart(value: any): string { + if (value === null) { + return 'null'; + } + const valueType = typeof value; + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + return `${valueType}:${JSON.stringify(value)}`; + } + if (Array.isArray(value)) { + return `array:[${value.map(item => this.getStableKeyPart(item)).join(',')}]`; + } + if (valueType === 'object' || valueType === 'function') { + let id = this.fieldKeyIds.get(value); + if (id === undefined) { + id = ++this.fieldKeyId; + this.fieldKeyIds.set(value, id); + } + return `${valueType}:#${id}`; + } + return `${valueType}:${String(value)}`; + } + + private resetCutState(): void { + this.cutWaitPaste = false; + this.activeCutState = null; + this.cutCellRange = null; + this.cutRanges = null; + if (this.table.keyboardOptions?.showCopyCellBorder) { + clearActiveCellRangeState(this.table); + } + if (this.clipboardCheckTimer) { + clearTimeout(this.clipboardCheckTimer); + this.clipboardCheckTimer = null; } } @@ -1391,6 +2239,14 @@ export class EventManager { return values; } + private parsePlainTextPastedData(pastedData: string): (string | number)[][] { + const rows = pastedData.replace(/\r\n|\r/g, '\n').split('\n'); + if (rows.length > 1 && rows[rows.length - 1] === '') { + rows.pop(); + } + return rows.map(rowCells => rowCells.split('\t')); + } + private processCellValue(cell: string): string | number { if (cell.includes('\n')) { cell = cell diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 2634896777..25819d5cad 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -339,6 +339,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 @@ -390,6 +393,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 @@ -431,6 +437,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 diff --git a/packages/vtable/src/event/util.ts b/packages/vtable/src/event/util.ts index 0469ac0a13..fcc388a116 100644 --- a/packages/vtable/src/event/util.ts +++ b/packages/vtable/src/event/util.ts @@ -134,7 +134,7 @@ export function setDataToHTML(data: string) { cells.forEach(function (cell: string, cellIndex: number) { // 单元格数据处理 const parsedCellData = !cell - ? ' ' + ? '' : cell .toString() .replace(/&/g, '&') // replace & with & to prevent XSS attacks diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index c05a9dacb6..4220b87f9b 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -2348,6 +2348,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2359,6 +2360,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (colHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(colHeader.indicatorKey)?.title ?? '' : colHeader.value); + colHeaderPath.dataValue = colHeader.dataValue; colHeaderPath.virtual = colHeader.virtual; colHeaderPath.role = colHeader.role; headerPaths.colHeaderPaths!.push(colHeaderPath); @@ -2370,6 +2372,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2381,6 +2384,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (rowHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(rowHeader.indicatorKey)?.title ?? '' : rowHeader.value); + rowHeaderPath.dataValue = rowHeader.dataValue; rowHeaderPath.virtual = rowHeader.virtual; rowHeaderPath.role = rowHeader.role; headerPaths.rowHeaderPaths!.push(rowHeaderPath); @@ -3419,10 +3423,14 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { const isMatch = (!isValid(currentPath.indicatorKey) && dimension.dimensionKey === currentPath.dimensionKey && - dimension.value === currentPath.value) || + (isValid(currentPath.dataValue) + ? ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue + : dimension.value === currentPath.value)) || (isValid(currentPath.indicatorKey) && dimension.indicatorKey === currentPath.indicatorKey && - ((isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || + ((isValid(currentPath.dataValue) && + ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue) || + (isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || !isValid(dimension.value) || !isValid(currentPath.value))); @@ -3621,7 +3629,9 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { .filter((hd: HeaderData) => { return ( (hd?.field === rowDimension.dimensionKey || hd?.field === rowDimension.indicatorKey) && - hd?.title === rowDimension.value + (isValid(rowDimension.dataValue) + ? ((hd as any).dataValue ?? (hd as any).define?.dataValue ?? hd.title) === rowDimension.dataValue + : hd?.title === rowDimension.value) ); }) .map((hd: HeaderData) => { @@ -3634,17 +3644,24 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { // 从上述过程中找到的pathCellIds中找到正确匹配完整路径rowHeaderPaths的一个 然后计算row行号 const findedCellIdPath = findedCellIdPaths.find(pathIds => { const fullCellIds = this.findFullCellIds(pathIds); + const matchHeaderPath = (curHd: HeaderData, rowDimensionPath: IDimensionInfo) => + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === + rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title); return ( fullCellIds.length === rowHeaderPaths.length && - fullCellIds.every(id => { - const curHd = this._headerObjectMap[id]; - return rowHeaderPaths.find(rowDimensionPath => { - return rowDimensionPath.dimensionKey === curHd.field && rowDimensionPath.value === curHd.title; - }); - }) + (rowHeaderPaths.some(rowDimensionPath => isValid(rowDimensionPath.dataValue)) + ? fullCellIds.every((id, index) => matchHeaderPath(this._headerObjectMap[id], rowHeaderPaths[index])) + : fullCellIds.every(id => + rowHeaderPaths.some(rowDimensionPath => matchHeaderPath(this._headerObjectMap[id], rowDimensionPath)) + )) ); }); - row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + if (findedCellIdPath) { + row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + } } else { rowDimensionFinded = this.matchDimensionPath(rowHeaderPaths, this.rowTree, needLowestLevel_rowPaths, true) as | ITreeLayoutHeadNode @@ -3659,7 +3676,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { } } // 通过dimension获取col和row - if (rowDimensionFinded || forceBody) { + if (rowDimensionFinded || (forceBody && !isValid(row))) { row = this.columnHeaderLevelCount; const { startInTotal, afterSpanLevel } = (rowDimensionFinded as ITreeLayoutHeadNode) ?? defaultDimension; row += startInTotal ?? 0; @@ -4254,7 +4271,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; @@ -4287,7 +4304,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; @@ -4358,7 +4375,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.colHeaderPaths.length) { path.colHeaderPaths.forEach(path => { if (path.dimensionKey) { - colKey.push(path.value); + colKey.push(path.dataValue ?? path.value); } }); } @@ -4374,7 +4391,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.rowHeaderPaths.length) { path.rowHeaderPaths.forEach(path => { if (path.dimensionKey) { - rowKey.push(path.value); + rowKey.push(path.dataValue ?? path.value); } }); } diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index bc7d557538..bdc4cf3e64 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -43,6 +43,11 @@ import { onBeforeAttributeUpdateForInvertHighlight } from '../../plugins/invert- import { getCellBorderStrokeWidth } from '../utils/cell-border-stroke-width'; import type { CreateSwitchCellGroup } from './cell-type/switch-cell'; import type { CreateButtonCellGroup } from './cell-type/button-cell'; +import { + createCornerCustomMergeContainer, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; @@ -590,9 +595,23 @@ function _generateCustomElementsGroup( ) { let customElementsGroup; let renderDefault = true; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); + if (!shouldRenderContent) { + return { + customElementsGroup, + renderDefault: false + }; + } + if (customResult) { // custom merge custom render - customElementsGroup = customResult.elementsGroup; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + cellWidth, + cellHeight, + range, + table + ); renderDefault = customResult.renderDefault; } else if (range?.isCustom && !table.isCornerHeader(col, row)) { // 判断不是角头单元格,来兼容corner中设置的customLayout @@ -610,11 +629,13 @@ function _generateCustomElementsGroup( customLayout = define?.customLayout; } if (customLayout || customRender) { + const customCol = range && isCornerCustomMergeRange(range, table) ? range.start.col : col; + const customRow = range && isCornerCustomMergeRange(range, table) ? range.start.row : row; const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, cellWidth, cellHeight, false, @@ -623,7 +644,13 @@ function _generateCustomElementsGroup( range, table ); - customElementsGroup = customResult.elementsGroup; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + cellWidth, + cellHeight, + range, + table + ); renderDefault = customResult.renderDefault; } } @@ -675,7 +702,7 @@ export function updateCell( // cellTheme.group.cornerRadius = getCellCornerRadius(col, row, table); // } - if (customLayout || customRender) { + if ((customLayout || customRender) && shouldRenderCornerCustomMergeContent(col, row, customMergeRange, table)) { customResult = dealWithCustom( customLayout, customRender, @@ -1364,6 +1391,24 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr } = customMerge; if (customMergeLayout || customMergeRender) { + const rangeHeight = table.getRowHeight(row); + const rangeWidth = table.getColWidth(col); + const contentWidth = table.getColsWidth(customMergeRange.start.col, customMergeRange.end.col); + const contentHeight = table.getRowsHeight(customMergeRange.start.row, customMergeRange.end.row); + + cellGroup.contentWidth = contentWidth; + cellGroup.contentHeight = contentHeight; + + if (!shouldRenderCornerCustomMergeContent(col, row, customMergeRange, table)) { + const customContainer = cellGroup.getChildByName(CUSTOM_CONTAINER_NAME); + if (customContainer) { + cellGroup.removeChild(customContainer); + } + cellGroup.setAttribute('clip', true); + resizeCellGroup(cellGroup, rangeWidth, rangeHeight, customMergeRange, table); + return customMergeRange; + } + const customResult = dealWithCustom( customMergeLayout, customMergeRender, @@ -1378,7 +1423,13 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr table ); - const customElementsGroup = customResult.elementsGroup; + const customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + contentWidth, + contentHeight, + customMergeRange, + table + ); if (cellGroup.childrenCount > 0 && customElementsGroup) { cellGroup.insertBefore(customElementsGroup, cellGroup.firstChild); @@ -1386,15 +1437,10 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr cellGroup.appendChild(customElementsGroup); } - const rangeHeight = table.getRowHeight(row); - const rangeWidth = table.getColWidth(col); - - const { width: contentWidth } = cellGroup.attribute; - const { height: contentHeight } = cellGroup.attribute; - cellGroup.contentWidth = contentWidth; - cellGroup.contentHeight = contentHeight; - resizeCellGroup(cellGroup, rangeWidth, rangeHeight, customMergeRange, table); + if (isCornerCustomMergeRange(customMergeRange, table)) { + cellGroup.setAttribute('clip', false); + } return customMergeRange; } diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts index 8ce3149af2..36ed060fc2 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts @@ -9,6 +9,7 @@ import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { createMark } from '../../graphic/mark'; +import { isCornerCustomMergeRange } from '../../utils/corner-custom-merge'; /** * @description: 创建单元格场景节点 @@ -42,12 +43,15 @@ export function createCellGroup( textAlign: CanvasTextAlign, textBaseline: CanvasTextBaseline, mayHaveIcon: boolean, - customElementsGroup: VGroup, + customElementsGroup: VGroup | undefined, renderDefault: boolean, cellTheme: IThemeSpec, range: CellRange | undefined, isAsync: boolean ): Group { + const isCornerCustomMergeContentCell = + isCornerCustomMergeRange(range, table) && col === range.end.col && row === range.end.row; + // The carrier cell must allow the clipped merge container to span internal cell bounds. const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); if (isValid(functionalPadding)) { @@ -84,7 +88,7 @@ export function createCellGroup( cursor: (cellTheme?.group as any)?.cursor ?? undefined, lineDash: cellTheme?.group?.lineDash ?? undefined, lineCap: 'butt', - clip: true, + clip: !isCornerCustomMergeContentCell, cornerRadius: cellTheme.group.cornerRadius } as any); } @@ -106,7 +110,7 @@ export function createCellGroup( lineCap: 'butt', - clip: true, + clip: !isCornerCustomMergeContentCell, cornerRadius: cellTheme.group.cornerRadius } as any); @@ -118,6 +122,12 @@ export function createCellGroup( if (customElementsGroup) { cellGroup.appendChild(customElementsGroup); } + if (isCornerCustomMergeContentCell) { + cellGroup.setAttributes({ + width: Math.max(cellGroup.attribute.width, customElementsGroup?.attribute.width ?? 0), + height: Math.max(cellGroup.attribute.height, customElementsGroup?.attribute.height ?? 0) + }); + } if (renderDefault) { const textStr: string = value; let icons; 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 01b31ddeb1..4e16f01836 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 @@ -14,7 +14,9 @@ function fillVisibleBodyRows(proxy: SceneProxy, distRow: number): number { while (targetRow < bodyBottomRow && table.getRowsHeight(table.frozenRowCount, targetRow) < visibleBodyHeight) { const nextRow = targetRow + 1; - computeRowsHeight(table, nextRow, nextRow, false); + if (!table.internalProps._heightResizedRowMap.has(nextRow)) { + computeRowsHeight(table, nextRow, nextRow, false); + } targetRow = nextRow; } @@ -75,25 +77,38 @@ export function createGroupForFirstScreen( 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); + const colEndForCompute = distColForCompute ?? distCol; + let hasUnresizedColumns = false; + for (let col = 0; col <= colEndForCompute; col++) { + if (!table.internalProps._widthResizedColMap.has(col)) { + hasUnresizedColumns = true; + break; + } + } + if (hasUnresizedColumns) { + // Compute widths for new columns while preserving manually resized columns. + computeColsWidth(table, 0, colEndForCompute); } - if (table.internalProps._heightResizedRowMap.size === 0) { - // compute rows height in first screen - computeRowsHeight( - table, - 0, - table.options.canvasHeight === 'auto' || table.options.customConfig?.forceComputeAllRowHeight - ? table.rowCount - 1 - : distRowForCompute ?? distRow - ); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 - if (table.heightMode === 'autoHeight') { - bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); - syncVisibleBodyRows(proxy, bodyDistRow); + const rowEndForCompute = + table.options.canvasHeight === 'auto' || table.options.customConfig?.forceComputeAllRowHeight + ? table.rowCount - 1 + : distRowForCompute ?? distRow; + let hasUnresizedRows = false; + for (let row = 0; row <= rowEndForCompute; row++) { + if (!table.internalProps._heightResizedRowMap.has(row)) { + hasUnresizedRows = true; + break; } } + if (hasUnresizedRows) { + // compute rows height in first screen + computeRowsHeight(table, 0, rowEndForCompute); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 + } + if (table.heightMode === 'autoHeight') { + bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); + syncVisibleBodyRows(proxy, bodyDistRow); + } if (distCol < table.colCount - table.rightFrozenColCount) { // compute right frozen row height diff --git a/packages/vtable/src/scenegraph/layout/compute-col-width.ts b/packages/vtable/src/scenegraph/layout/compute-col-width.ts index b7940eefe0..0999948ec0 100644 --- a/packages/vtable/src/scenegraph/layout/compute-col-width.ts +++ b/packages/vtable/src/scenegraph/layout/compute-col-width.ts @@ -493,9 +493,7 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { return ( - table.isListTable() && - !table.isHeader(col, row) && - !(table.internalProps.dataSource as any)?.dataSourceObj?.records + table.isListTable() && !table.isHeader(col, row) && !(table.internalProps.dataSource as any)?.dataSourceObj?.records ); } @@ -719,6 +717,10 @@ export function getAdaptiveWidth( const minWidthColumnMap = new Map(); for (let col = startCol; col < endColPlus1; col++) { const width = update ? newWidths[col] ?? table.getColWidth(col) : table.getColWidth(col); + if (table.widthMode === 'adaptive' && table.internalProps._widthResizedColMap.has(col)) { + totalDrawWidth -= width; + continue; + } const maxWidth = table.getMaxColWidth(col); const minWidth = table.getMinColWidth(col); // if (width !== maxWidth && width !== minWidth) { diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index 7ae9074b67..0806eda809 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -139,14 +139,13 @@ export function computeRowsHeight( ) { // check fixed style and no wrap situation, fill all row width single compute // traspose table and row indicator pivot table cannot use single row height - const height = computeRowHeight(table.columnHeaderLevelCount, 0, table.colCount - 1, table); - fillRowsHeight( - height, - table.columnHeaderLevelCount, - table.rowCount - 1 - table.bottomFrozenRowCount, - table, - update ? newHeights : undefined - ); + const fillStartRow = table.columnHeaderLevelCount; + const fillEndRow = table.rowCount - 1 - table.bottomFrozenRowCount; + const rowForCompute = getFirstUnresizedRow(table, fillStartRow, fillEndRow); + if (rowForCompute >= 0) { + const height = computeRowHeight(rowForCompute, 0, table.colCount - 1, table); + fillRowsHeight(height, fillStartRow, fillEndRow, table, update ? newHeights : undefined); + } //底部冻结的行行高需要单独计算 for (let row = table.rowCount - table.bottomFrozenRowCount; row <= rowEnd; row++) { const height = computeRowHeight(row, 0, table.colCount - 1, table); @@ -352,6 +351,10 @@ export function computeRowsHeight( } export function computeRowHeight(row: number, startCol: number, endCol: number, table: BaseTableAPI): number { + if (table.internalProps._heightResizedRowMap.has(row)) { + return table.getRowHeight(row); + } + return computeRowHeightInternal(row, startCol, endCol, table, true); } @@ -607,6 +610,9 @@ function fillRowsHeight( return; } for (let row = startRow; row <= endRow; row++) { + if (table.internalProps._heightResizedRowMap.has(row)) { + continue; + } if (newHeights) { newHeights[row] = height; } else { @@ -616,6 +622,15 @@ function fillRowsHeight( table.internalProps.useOneRowHeightFillAll = true; } +function getFirstUnresizedRow(table: BaseTableAPI, startRow: number, endRow: number): number { + for (let row = startRow; row <= endRow; row++) { + if (!table.internalProps._heightResizedRowMap.has(row)) { + return row; + } + } + return -1; +} + /** * @description: compute customRender height * @param {number} col diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 55a70bc19b..517622040d 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -18,8 +18,25 @@ import type { IGraphic } from '@src/vrender'; import { getCellMergeRange } from '../../tools/merge-range'; import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; import { Factory } from '../../core/factory'; +import { + type CornerCustomMergeRangeUpdateMap, + createCornerCustomMergeContainer, + flushCornerCustomMergeContentUpdates, + isCornerCustomMergeRange, + queueCornerCustomMergeContentUpdate, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; + +export function updateRowHeight( + scene: Scenegraph, + row: number, + detaY: number, + skipTableHeightMap?: boolean, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap +) { + const shouldFlushCornerCustomMergeRanges = !pendingCornerCustomMergeRanges; + pendingCornerCustomMergeRanges ??= new Map(); -export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, skipTableHeightMap?: boolean) { // 更新table行高存储 if (!skipTableHeightMap && detaY) { scene.table._setRowHeight(row, scene.table.getRowHeight(row) + detaY, true); @@ -37,7 +54,16 @@ export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, s const height = cell.attribute.height; // cell.setAttribute('height', height); // (cell.firstChild as Rect).setAttribute('height', cell.attribute.height); - updateCellHeightForRow(scene, cell, col, row, height + detaY, detaY, scene.table.isHeader(col, row)); + updateCellHeightForRow( + scene, + cell, + col, + row, + height + detaY, + detaY, + scene.table.isHeader(col, row), + pendingCornerCustomMergeRanges + ); scene.updateCellContentWhileResize(col, row); } @@ -70,6 +96,10 @@ export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, s } } } + + if (shouldFlushCornerCustomMergeRanges) { + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, scene.table); + } } /** @@ -83,7 +113,8 @@ export function updateCellHeightForRow( row: number, height: number, detaY: number, - isHeader: boolean + isHeader: boolean, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap // autoRowHeight: boolean ) { // cell.setAttribute('height', height); @@ -95,7 +126,7 @@ export function updateCellHeightForRow( return; } - updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader); + updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader, pendingCornerCustomMergeRanges); } export function updateCellHeightForColumn( @@ -119,7 +150,8 @@ export function updateCellHeight( row: number, distHeight: number, detaY: number, - isHeader: boolean + isHeader: boolean, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (cell.attribute.height === distHeight && !cell.needUpdateHeight) { return; @@ -168,7 +200,16 @@ export function updateCellHeight( oldBarCell.release(); // deal with text - updateMergeCellContentHeight(cell, distHeight, detaY, scene.table.isAutoRowHeight(row), true, scene.table); + updateMergeCellContentHeight( + cell, + distHeight, + detaY, + scene.table.isAutoRowHeight(row), + true, + scene.table, + true, + pendingCornerCustomMergeRanges + ); } else if (type === 'sparkline') { // 目前先采用重新生成节点的方案 cell.removeAllChild(); @@ -199,9 +240,15 @@ export function updateCellHeight( (cell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (cell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); if (customContainer) { - // if (scene.table.reactCustomLayout) { - // scene.table.reactCustomLayout.removeCustomCell(col, row); - // } + if (scene.table.reactCustomLayout) { + const mergeRange = isMergeCellGroup(cell) + ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) + : undefined; + const shouldUseMergeStart = mergeRange && isCornerCustomMergeRange(mergeRange, scene.table); + const removeCol = shouldUseMergeStart ? mergeRange.start.col : col; + const removeRow = shouldUseMergeStart ? mergeRange.start.row : row; + scene.table.reactCustomLayout.removeCustomCell(removeCol, removeRow); + } // customContainer.removeAllChild(); let customElementsGroup; cell.removeChild(customContainer); @@ -216,8 +263,10 @@ export function updateCellHeight( const customContainer = (mergedCell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (mergedCell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); - customContainer.removeAllChild(); - mergedCell.removeChild(customContainer); + if (customContainer) { + customContainer.removeAllChild(); + mergedCell.removeChild(customContainer); + } getCustomCellMergeCustom(col, mergeRow, mergedCell, scene.table); } } else { @@ -248,7 +297,13 @@ export function updateCellHeight( } } - if (customLayout || customRender) { + const mergeRange = isMergeCellGroup(cell) + ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) + : undefined; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + if (!shouldRenderContent) { + renderDefault = false; + } else if (customLayout || customRender) { // const { autoRowHeight } = table.internalProps; const style = scene.table._getCellStyle(col, row) as ProgressBarStyle; const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); @@ -258,25 +313,32 @@ export function updateCellHeight( width = scene.table.getColsWidth(cell.mergeStartCol, cell.mergeEndCol); height = scene.table.getRowsHeight(cell.mergeStartRow, cell.mergeEndRow); } + let customCol = col; + let customRow = row; + if (mergeRange && isCornerCustomMergeRange(mergeRange, scene.table)) { + customCol = mergeRange.start.col; + customRow = mergeRange.start.row; + } const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, width, height, false, scene.table.isAutoRowHeight(row), padding, - isMergeCellGroup(cell) - ? { - start: { col: cell.mergeStartCol, row: cell.mergeStartRow }, - end: { col: cell.mergeEndCol, row: cell.mergeEndRow } - } - : undefined, + mergeRange, + scene.table + ); + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, scene.table ); - customElementsGroup = customResult.elementsGroup; renderDefault = customResult.renderDefault; } @@ -285,6 +347,12 @@ export function updateCellHeight( } else if (customElementsGroup) { cell.appendChild(customElementsGroup); } + if (isMergeCellGroup(cell)) { + const mergeRange = scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, scene.table)) { + cell.setAttribute('clip', !shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table)); + } + } } } // if (renderDefault) { @@ -301,7 +369,16 @@ export function updateCellHeight( // scene.table // ); // } - updateMergeCellContentHeight(cell, distHeight, detaY, scene.table.isAutoRowHeight(row), renderDefault, scene.table); + updateMergeCellContentHeight( + cell, + distHeight, + detaY, + scene.table.isAutoRowHeight(row), + renderDefault, + scene.table, + !customContainer, + pendingCornerCustomMergeRanges + ); } } @@ -311,7 +388,9 @@ function updateMergeCellContentHeight( detaY: number, autoRowHeight: boolean, renderDefault: boolean, - table: BaseTableAPI + table: BaseTableAPI, + refreshCornerCustomMergeContent = true, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (isMergeCellGroup(cellGroup)) { distHeight = 0; @@ -375,6 +454,10 @@ function updateMergeCellContentHeight( } } } + const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { + queueCornerCustomMergeContentUpdate(mergeRange, table, pendingCornerCustomMergeRanges); + } } else { const style = table._getCellStyle(cellGroup.col, cellGroup.row); const padding = getQuadProps(getProp('padding', style, cellGroup.col, cellGroup.row, table)); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index ac06b5a68e..55d156e3dd 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -17,6 +17,14 @@ import { getProp } from '../utils/get-prop'; import { isMergeCellGroup } from '../utils/is-merge-cell-group'; import { getQuadProps } from '../utils/padding'; import { updateCellContentWidth } from '../utils/text-icon-layout'; +import { + type CornerCustomMergeRangeUpdateMap, + createCornerCustomMergeContainer, + flushCornerCustomMergeContentUpdates, + isCornerCustomMergeRange, + queueCornerCustomMergeContentUpdate, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; import { computeRowHeight } from './compute-row-height'; import { updateCellHeightForRow } from './update-height'; import { getHierarchyOffset } from '../utils/get-hierarchy-offset'; @@ -31,7 +39,16 @@ import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; * @param {number} detaX * @return {*} */ -export function updateColWidth(scene: Scenegraph, col: number, detaX: number, skipTableWidthMap?: boolean) { +export function updateColWidth( + scene: Scenegraph, + col: number, + detaX: number, + skipTableWidthMap?: boolean, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap +) { + const shouldFlushCornerCustomMergeRanges = !pendingCornerCustomMergeRanges; + pendingCornerCustomMergeRanges ??= new Map(); + if (!skipTableWidthMap) { scene.table._setColWidth(col, scene.table.getColWidth(col) + detaX, true); } @@ -40,33 +57,33 @@ export function updateColWidth(scene: Scenegraph, col: number, detaX: number, sk const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; const rightTopColumn = scene.getColGroupInRightTopCorner(col); if (colOrCornerHeaderColumn && !rightTopColumn) { - updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene); + updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene, pendingCornerCustomMergeRanges); } // deal with right bottom frozen cells if (rightTopColumn) { - updateColunmWidth(rightTopColumn, detaX, 'right-top', scene); + updateColunmWidth(rightTopColumn, detaX, 'right-top', scene, pendingCornerCustomMergeRanges); } // deal with row header or body or right frozen cells const rowHeaderOrBodyColumn = scene.getColGroup(col) as Group; if (rowHeaderOrBodyColumn) { - updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene); + updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene, pendingCornerCustomMergeRanges); } const leftBottomColumn = scene.getColGroupInLeftBottomCorner(col); // deal with left bottom frozen cells if (leftBottomColumn) { - updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene); + updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene, pendingCornerCustomMergeRanges); } // deal with bottom frozen cells const bottomColumn = scene.getColGroupInBottom(col); if (bottomColumn) { - updateColunmWidth(bottomColumn, detaX, 'bottom', scene); + updateColunmWidth(bottomColumn, detaX, 'bottom', scene, pendingCornerCustomMergeRanges); } // deal with right bottom frozen cells const rightBottomColumn = scene.getColGroupInRightBottomCorner(col); if (rightBottomColumn) { - updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene); + updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene, pendingCornerCustomMergeRanges); } // 更新剩余列位置 @@ -116,6 +133,10 @@ export function updateColWidth(scene: Scenegraph, col: number, detaX: number, sk }); } + if (shouldFlushCornerCustomMergeRanges) { + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, scene.table); + } + // scene.table.setColWidth(col, rowHeaderOrBodyColumn.attribute.width, true); } @@ -124,7 +145,8 @@ function updateColunmWidth( detaX: number, // autoRowHeight: boolean, mode: 'col-corner' | 'row-body' | 'bottom' | 'left-bottom' | 'right-top' | 'right-bottom', - scene: Scenegraph + scene: Scenegraph, + pendingCornerCustomMergeRanges: CornerCustomMergeRangeUpdateMap ) { let needRerangeRow = false; // const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; @@ -141,7 +163,8 @@ function updateColunmWidth( oldColumnWidth + detaX, detaX, mode === 'row-body' ? cell.col < scene.table.rowHeaderLevelCount : true, - scene.table.internalProps.autoWrapText + scene.table.internalProps.autoWrapText, + pendingCornerCustomMergeRanges ); if (isHeightChange) { const mergeInfo = getCellMergeInfo(scene.table, cell.col, cell.row); @@ -246,7 +269,8 @@ function updateCellWidth( detaX: number, isHeader: boolean, // autoColWidth: boolean, - autoWrapText: boolean + autoWrapText: boolean, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ): boolean { if (cell.attribute.width === distWidth && !cell.needUpdateWidth) { return false; @@ -308,7 +332,16 @@ function updateCellWidth( oldBarCell.release(); // deal width text - const cellChange = updateMergeCellContentWidth(cellGroup, distWidth, detaX, autoRowHeight, true, scene.table); + const cellChange = updateMergeCellContentWidth( + cellGroup, + distWidth, + detaX, + autoRowHeight, + true, + scene.table, + true, + pendingCornerCustomMergeRanges + ); isHeightChange = isHeightChange || cellChange; } else if (type === 'sparkline') { // 目前先采用重新生成节点的方案 @@ -362,9 +395,15 @@ function updateCellWidth( (cell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); if (customContainer) { // fix for custom component flash - // if (scene.table.reactCustomLayout) { - // scene.table.reactCustomLayout.removeCustomCell(col, row); - // } + if (scene.table.reactCustomLayout) { + const mergeRange = isMergeCellGroup(cellGroup) + ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) + : undefined; + const shouldUseMergeStart = mergeRange && isCornerCustomMergeRange(mergeRange, scene.table); + const removeCol = shouldUseMergeStart ? mergeRange.start.col : col; + const removeRow = shouldUseMergeStart ? mergeRange.start.row : row; + scene.table.reactCustomLayout.removeCustomCell(removeCol, removeRow); + } // customContainer.removeAllChild(); let customElementsGroup; cell.removeChild(customContainer); @@ -379,8 +418,10 @@ function updateCellWidth( const customContainer = (mergedCell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (mergedCell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); - customContainer.removeAllChild(); - mergedCell.removeChild(customContainer); + if (customContainer) { + customContainer.removeAllChild(); + mergedCell.removeChild(customContainer); + } getCustomCellMergeCustom(mergeCol, row, mergedCell, scene.table); } } else { @@ -411,7 +452,13 @@ function updateCellWidth( } } - if (customLayout || customRender) { + const mergeRange = isMergeCellGroup(cellGroup) + ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) + : undefined; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + if (!shouldRenderContent) { + renderDefault = false; + } else if (customLayout || customRender) { // const { autoRowHeight } = table.internalProps; const style = scene.table._getCellStyle(col, row) as ProgressBarStyle; const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); @@ -421,25 +468,32 @@ function updateCellWidth( width = scene.table.getColsWidth(cellGroup.mergeStartCol, cellGroup.mergeEndCol); height = scene.table.getRowsHeight(cellGroup.mergeStartRow, cellGroup.mergeEndRow); } + let customCol = col; + let customRow = row; + if (mergeRange && isCornerCustomMergeRange(mergeRange, scene.table)) { + customCol = mergeRange.start.col; + customRow = mergeRange.start.row; + } const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, width, height, false, scene.table.isAutoRowHeight(row), padding, - isMergeCellGroup(cellGroup) - ? { - start: { col: cellGroup.mergeStartCol, row: cellGroup.mergeStartRow }, - end: { col: cellGroup.mergeEndCol, row: cellGroup.mergeEndRow } - } - : undefined, + mergeRange, + scene.table + ); + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, scene.table ); - customElementsGroup = customResult.elementsGroup; renderDefault = customResult.renderDefault; isHeightChange = true; } @@ -449,6 +503,12 @@ function updateCellWidth( } else if (customElementsGroup) { cell.appendChild(customElementsGroup); } + if (isMergeCellGroup(cellGroup)) { + const mergeRange = scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, scene.table)) { + cell.setAttribute('clip', !shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table)); + } + } } } const cellChange = updateMergeCellContentWidth( @@ -457,7 +517,9 @@ function updateCellWidth( detaX, autoRowHeight, renderDefault, - scene.table + scene.table, + !customContainer, + pendingCornerCustomMergeRanges ); isHeightChange = isHeightChange || cellChange; } @@ -474,7 +536,9 @@ function updateMergeCellContentWidth( detaX: number, autoRowHeight: boolean, renderDefault: boolean, - table: BaseTableAPI + table: BaseTableAPI, + refreshCornerCustomMergeContent = true, + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (isMergeCellGroup(cellGroup)) { distWidth = 0; @@ -567,6 +631,10 @@ function updateMergeCellContentWidth( isHeightChange = isHeightChange || changed; } } + const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { + queueCornerCustomMergeContentUpdate(mergeRange, table, pendingCornerCustomMergeRanges); + } return isHeightChange; } diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index fb43b590b6..f2a18bbad8 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -512,6 +512,8 @@ export class Scenegraph { } releaseStage() { + this.clear = true; + this._needUpdateContainer = false; const releaseAppRef = this.releaseVRenderAppRef; this.releaseVRenderAppRef = undefined; @@ -994,8 +996,14 @@ export class Scenegraph { * @param {number} detaX 改变的宽度值 * @return {*} */ - updateColWidth(col: number, detaX: number, skipUpdateContainer?: boolean, skipTableWidthMap?: boolean) { - updateColWidth(this, col, Math.round(detaX), skipTableWidthMap); + updateColWidth( + col: number, + detaX: number, + skipUpdateContainer?: boolean, + skipTableWidthMap?: boolean, + pendingCornerCustomMergeRanges?: Map + ) { + updateColWidth(this, col, Math.round(detaX), skipTableWidthMap, pendingCornerCustomMergeRanges); // this.updateContainerWidth(col, detaX); if (!skipUpdateContainer) { // this.updateContainerAttrWidthAndX(); @@ -1538,9 +1546,14 @@ export class Scenegraph { } } - updateRowHeight(row: number, detaY: number, skipTableHeightMap?: boolean) { + updateRowHeight( + row: number, + detaY: number, + skipTableHeightMap?: boolean, + pendingCornerCustomMergeRanges?: Map + ) { detaY = Math.round(detaY); - updateRowHeight(this, row, detaY, skipTableHeightMap); + updateRowHeight(this, row, detaY, skipTableHeightMap, pendingCornerCustomMergeRanges); this.updateContainerHeight(row, detaY); } updateRowsHeight(rows: number[], detaYs: number[], skipTableHeightMap?: boolean) { @@ -2223,6 +2236,10 @@ export class Scenegraph { if (!this._needUpdateContainer) { this._needUpdateContainer = true; setTimeout(() => { + if (!this._needUpdateContainer || this.clear) { + this._needUpdateContainer = false; + return; + } this.updateContainerSync(updateConfig.needUpdateCellY ?? false); }, 0); } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts new file mode 100644 index 0000000000..434eac5545 --- /dev/null +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -0,0 +1,111 @@ +import type { Group as VGroup } from '@src/vrender'; +import type { CellRange } from '../../ts-types'; +import type { BaseTableAPI } from '../../ts-types/base-table'; +import { CUSTOM_CONTAINER_NAME, CUSTOM_MERGE_CONTAINER_NAME } from '../component/custom'; +import { Group } from '../graphic/group'; + +export type CornerCustomMergeRangeUpdateMap = Map; + +export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { + return ( + !!range?.isCustom && + (range.start.col !== range.end.col || range.start.row !== range.end.row) && + table.isCornerHeader(range.start.col, range.start.row) && + table.isCornerHeader(range.end.col, range.end.row) + ); +} + +export function shouldRenderCornerCustomMergeContent( + col: number, + row: number, + range: CellRange | undefined, + table: BaseTableAPI +): boolean { + return !isCornerCustomMergeRange(range, table) || (col === range.end.col && row === range.end.row); +} + +export function createCornerCustomMergeContainer( + customElementsGroup: VGroup | undefined, + width: number, + height: number, + range: CellRange | undefined, + table: BaseTableAPI +): VGroup | undefined { + if (!customElementsGroup || !isCornerCustomMergeRange(range, table)) { + return customElementsGroup; + } + + const customContainer = new Group({ + x: 0, + y: 0, + width, + height, + fill: false, + stroke: false, + pickable: false, + clip: true + }); + customContainer.name = CUSTOM_CONTAINER_NAME; + customContainer.col = range.start.col; + customContainer.row = range.start.row; + customContainer.appendChild(customElementsGroup); + + return customContainer as unknown as VGroup; +} + +export function updateCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { + refreshCornerCustomMergeContent(range, table); +} + +export function queueCornerCustomMergeContentUpdate( + range: CellRange, + table: BaseTableAPI, + pendingRanges?: CornerCustomMergeRangeUpdateMap +): void { + if (!pendingRanges) { + refreshCornerCustomMergeContent(range, table); + return; + } + + pendingRanges.set(getCornerCustomMergeRangeKey(range), range); +} + +export function flushCornerCustomMergeContentUpdates( + pendingRanges: CornerCustomMergeRangeUpdateMap, + table: BaseTableAPI +): void { + pendingRanges.forEach(range => { + refreshCornerCustomMergeContentForResize(range, table); + }); + pendingRanges.clear(); +} + +function getCornerCustomMergeRangeKey(range: CellRange): string { + return `${range.start.col}-${range.start.row}-${range.end.col}-${range.end.row}`; +} + +function refreshCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { + const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); + const customContainer = + cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); + + if (customContainer) { + table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row); + customContainer.removeAllChild(); + cellGroup.removeChild(customContainer); + } + + table.scenegraph.updateCellContent(range.end.col, range.end.row); +} + +function refreshCornerCustomMergeContentForResize(range: CellRange, table: BaseTableAPI): void { + const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); + const customContainer = + cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); + + if (customContainer) { + cellGroup.removeChild(customContainer); + } + + table.scenegraph.updateCellContent(range.end.col, range.end.row); +} diff --git a/packages/vtable/src/state/resize/update-resize-column.ts b/packages/vtable/src/state/resize/update-resize-column.ts index 3c7add4245..7d7c7cf41e 100644 --- a/packages/vtable/src/state/resize/update-resize-column.ts +++ b/packages/vtable/src/state/resize/update-resize-column.ts @@ -1,5 +1,6 @@ import type { ListTable } from '../../ListTable'; import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; +import { flushCornerCustomMergeContentUpdates } from '../../scenegraph/utils/corner-custom-merge'; import type { IndicatorData } from '../../ts-types/list-table/layout-map/api'; import type { StateManager } from '../state'; @@ -133,26 +134,47 @@ export function updateResizeColumn(xInTable: number, yInTable: number, state: St } function updateResizeColForColumn(detaX: number, state: StateManager) { + const pendingCornerCustomMergeRanges = new Map(); if (state.table.widthMode === 'adaptive' && state.columnResize.col < state.table.colCount - 1) { // in adaptive mode, the right column width can not be negative // const rightColWidth = state.table.getColWidth(state.columnResize.col + 1); // if (rightColWidth - detaX < 0) { // detaX = rightColWidth; // } - state.table.scenegraph.updateColWidth(state.columnResize.col, detaX); - state.table.scenegraph.updateColWidth(state.columnResize.col + 1, -detaX); + state.table.scenegraph.updateColWidth( + state.columnResize.col, + detaX, + undefined, + undefined, + pendingCornerCustomMergeRanges + ); + state.table.scenegraph.updateColWidth( + state.columnResize.col + 1, + -detaX, + undefined, + undefined, + pendingCornerCustomMergeRanges + ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); state.table.internalProps._widthResizedColMap.add(state.columnResize.col + 1); } else { - state.table.scenegraph.updateColWidth(state.columnResize.col, detaX); + state.table.scenegraph.updateColWidth( + state.columnResize.col, + detaX, + undefined, + undefined, + pendingCornerCustomMergeRanges + ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForAll(detaX: number, state: StateManager) { // 全列调整 const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; + const pendingCornerCustomMergeRanges = new Map(); for (let col = state.table.frozenColCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -162,9 +184,10 @@ function updateResizeColForAll(detaX: number, state: StateManager) { } } // state.table.setColWidth(col, afterSize); - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicator(detaX: number, state: StateManager) { @@ -180,20 +203,22 @@ function updateResizeColForIndicator(detaX: number, state: StateManager) { resizeDimensionKey = headerPath.dimensionKey; resizeDimensionValue = headerPath.value; } + const pendingCornerCustomMergeRanges = new Map(); for (let col = state.table.rowHeaderLevelCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { const indicatorKey = layout.getIndicatorKey(col, state.table.columnHeaderLevelCount); if (layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } else if (!layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(col, state.table.columnHeaderLevelCount - 1); const headerPath = headerPaths?.colHeaderPaths[headerPaths.colHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } } } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { @@ -210,6 +235,7 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalColWidth = state.table.getColsWidth(startCol, endCol); const moveX = detaX; // 纠正moveX 用于指标列均分该值 + const pendingCornerCustomMergeRanges = new Map(); for (let col = startCol; col <= endCol; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -227,7 +253,8 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { } else if (deltaWidth < 0 && deltaWidth >= -0.5) { deltaWidth = -0.5; } - state.table.scenegraph.updateColWidth(col, deltaWidth); + state.table.scenegraph.updateColWidth(col, deltaWidth, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } diff --git a/packages/vtable/src/state/resize/update-resize-row.ts b/packages/vtable/src/state/resize/update-resize-row.ts index 3622b6f58d..0d61f43790 100644 --- a/packages/vtable/src/state/resize/update-resize-row.ts +++ b/packages/vtable/src/state/resize/update-resize-row.ts @@ -1,5 +1,6 @@ import type { ListTable } from '../../ListTable'; import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; +import { flushCornerCustomMergeContentUpdates } from '../../scenegraph/utils/corner-custom-merge'; import type { IndicatorData } from '../../ts-types/list-table/layout-map/api'; import type { StateManager } from '../state'; @@ -69,21 +70,24 @@ export function updateResizeRow(xInTable: number, yInTable: number, state: State } function updateResizeColForRow(detaY: number, state: StateManager) { + const pendingCornerCustomMergeRanges = new Map(); if (state.table.heightMode === 'adaptive' && state.rowResize.row < state.table.rowCount - 1) { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY); - state.table.scenegraph.updateRowHeight(state.rowResize.row + 1, -detaY); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, pendingCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(state.rowResize.row + 1, -detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row + 1); } else { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForAll(detaY: number, state: StateManager) { // 全列调整 // const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; + const pendingCornerCustomMergeRanges = new Map(); for (let row = state.table.frozenRowCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; row++) { // // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -92,9 +96,10 @@ function updateResizeColForAll(detaY: number, state: StateManager) { // continue; // } // } - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicator(detaY: number, state: StateManager) { @@ -110,6 +115,7 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { resizeDimensionKey = headerPath?.dimensionKey; resizeDimensionValue = headerPath?.value; } + const pendingCornerCustomMergeRanges = new Map(); for ( let row = state.table.columnHeaderLevelCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; @@ -117,17 +123,18 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { ) { const indicatorKey = layout.getIndicatorKey(state.table.rowHeaderLevelCount, row); if (!layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } else if (layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(state.table.rowHeaderLevelCount - 1, row); const headerPath = headerPaths?.rowHeaderPaths?.[headerPaths.rowHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } } } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { @@ -144,6 +151,7 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalRowHeight = state.table.getRowsHeight(startRow, endRow); const moveY = detaY; // 纠正moveX 用于指标列均分该值 + const pendingCornerCustomMergeRanges = new Map(); for (let row = startRow; row <= endRow; row++) { // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -161,7 +169,8 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { } else if (deltaHeight < 0 && deltaHeight >= -0.5) { deltaHeight = -0.51; } - state.table.scenegraph.updateRowHeight(row, deltaHeight); + state.table.scenegraph.updateRowHeight(row, deltaHeight, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } diff --git a/packages/vtable/src/themes.ts b/packages/vtable/src/themes.ts index 4b36d241af..1a5c1afa4c 100644 --- a/packages/vtable/src/themes.ts +++ b/packages/vtable/src/themes.ts @@ -11,7 +11,7 @@ import type { ITableThemeDefine } from './ts-types'; export const DARK = new TableTheme(darkTheme, darkTheme); export const BRIGHT = new TableTheme(brightTheme, brightTheme); export const ARCO = new TableTheme(arcoTheme, arcoTheme); -export const DEFAULT = new TableTheme(defaultTheme, defaultTheme); +export let DEFAULT = new TableTheme(defaultTheme, defaultTheme); export const SIMPLIFY = new TableTheme(materialDesignTheme, materialDesignTheme); const builtin: { [key: string]: TableTheme } = { @@ -21,7 +21,16 @@ const builtin: { [key: string]: TableTheme } = { DARK, BRIGHT }; -// let defTheme = DEFAULT; +/** + * Replace the default theme used when no instance theme is specified. + * Existing instances and previously extended themes are not updated. + * The supplied theme replaces the default; use DEFAULT.extends() to merge styles. + */ +export function setDefaultTheme(theme: ITableThemeDefine): void { + DEFAULT = theme instanceof TableTheme ? theme.extends({}) : new TableTheme(theme, theme); + builtin.DEFAULT = DEFAULT; +} + export const theme = { TableTheme }; export function of(value: ITableThemeDefine | string | undefined | null): TableTheme | null { if (!value) { @@ -52,9 +61,12 @@ export default { DARK, BRIGHT, ARCO, - DEFAULT, + get DEFAULT() { + return DEFAULT; + }, SIMPLIFY, theme, of, - get + get, + setDefaultTheme }; diff --git a/packages/vtable/src/tools/get-data-path/create-dataset.ts b/packages/vtable/src/tools/get-data-path/create-dataset.ts index 30bf7fc551..3a5ed34d24 100644 --- a/packages/vtable/src/tools/get-data-path/create-dataset.ts +++ b/packages/vtable/src/tools/get-data-path/create-dataset.ts @@ -15,7 +15,7 @@ import type { IChartColumnIndicator } from '../../ts-types/pivot-table/indicator export function createDataset(options: PivotChartConstructorOptions) { const layoutNodeId = { seqId: 0 }; - const dataConfig: IPivotChartDataConfig = { isPivotChart: true }; + const dataConfig: IPivotChartDataConfig = { ...options.dataConfig, isPivotChart: true }; let columnDimensionTree; let rowDimensionTree; let columnTree; @@ -32,7 +32,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } rowDimensionTree = new DimensionTree((options.rowTree as ITreeLayoutHeadNode[]) ?? [], layoutNodeId); } - const rowKeys = rowDimensionTree.dimensionKeys?.count + const rowKeys = rowDimensionTree?.dimensionKeys?.count ? rowDimensionTree.dimensionKeys.valueArr() : options.rows?.reduce((keys, rowObj) => { if (typeof rowObj === 'string') { @@ -42,7 +42,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } return keys; }, []) ?? []; - const columnKeys = columnDimensionTree.dimensionKeys?.count + const columnKeys = columnDimensionTree?.dimensionKeys?.count ? columnDimensionTree.dimensionKeys.valueArr() : options.columns?.reduce((keys, columnObj) => { if (typeof columnObj === 'string') { diff --git a/packages/vtable/src/tools/get-data-path/index.ts b/packages/vtable/src/tools/get-data-path/index.ts index dcf5eb4f17..b2662f2933 100644 --- a/packages/vtable/src/tools/get-data-path/index.ts +++ b/packages/vtable/src/tools/get-data-path/index.ts @@ -69,10 +69,10 @@ export function getDataCellPath( const cellDimensionPath = layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); // const aggregator = dataset.getAggregator( // rowKey[rowKey.length - 1], diff --git a/packages/vtable/src/tools/style.ts b/packages/vtable/src/tools/style.ts index 1ebaa64b43..4fa7e3da7f 100644 --- a/packages/vtable/src/tools/style.ts +++ b/packages/vtable/src/tools/style.ts @@ -39,8 +39,8 @@ export function isZeroStyle(style: number | number[]) { } // 设置复制区域的状态 类似excel的虚线框 -export function setActiveCellRangeState(table: BaseTableAPI) { - const selectRanges = table.stateManager.select.ranges; +export function setActiveCellRangeState(table: BaseTableAPI, ranges = table.stateManager.select.ranges) { + const selectRanges = ranges; const setRanges = []; for (let i = 0; i < selectRanges.length; i++) { const range = selectRanges[i]; diff --git a/packages/vtable/src/ts-types/common.ts b/packages/vtable/src/ts-types/common.ts index a05a30cd9e..aea73b3083 100644 --- a/packages/vtable/src/ts-types/common.ts +++ b/packages/vtable/src/ts-types/common.ts @@ -104,6 +104,8 @@ export type IPivotTableCellHeaderPaths = { export interface IDimensionInfo { dimensionKey?: string; value?: string; + /** Internal value used to locate aggregated data when it differs from the displayed value. */ + dataValue?: string; indicatorKey?: string; isPivotCorner?: boolean; virtual?: boolean; diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index e6e4df96d9..f1aad2d0ae 100644 --- a/packages/vtable/src/ts-types/dataset/aggregation.ts +++ b/packages/vtable/src/ts-types/dataset/aggregation.ts @@ -212,6 +212,7 @@ export class NoneAggregator extends Aggregator { export class CustomAggregator extends Aggregator { type: string = AggregationType.CUSTOM; isRecord?: boolean = true; + recalculateFromChildren?: boolean; declare field?: string; aggregationFun?: Function; values: (string | number)[] = []; @@ -222,6 +223,19 @@ export class CustomAggregator extends Aggregator { } push(record: any): void { if (record) { + if (this.recalculateFromChildren && record.isAggregator) { + if (this.isRecord && this.records) { + this.records.push(...record.records); + } + if (this.children) { + this.children.push(record); + } + if (this.field) { + this.values.push(...record.records.map((item: any) => item[this.field as string])); + } + this.clearCacheValue(); + return; + } if (this.isRecord && this.records) { if (record.isAggregator) { this.records.push(...record.records); @@ -290,6 +304,24 @@ export class CustomAggregator extends Aggregator { this.fieldValue = undefined; } recalculate() { + if (this.recalculateFromChildren && this.children?.length && this.field) { + this.values = []; + this.records = []; + this.children.forEach(child => { + if (isValid(child.changedValue)) { + const value = child.value(); + this.values.push(value); + this.records.push({ [this.field as string]: value }); + } else if (child instanceof CustomAggregator && child.recalculateFromChildren) { + this.values.push(...child.values); + this.records.push(...child.records); + } else { + const records = child.records; + this.values.push(...records.map(record => record[this.field as string])); + this.records.push(...records); + } + }); + } this.fieldValue = undefined; this._formatedValue = undefined; // do nothing diff --git a/packages/vtable/src/ts-types/new-data-set.ts b/packages/vtable/src/ts-types/new-data-set.ts index 22332c25c5..4b135291ef 100644 --- a/packages/vtable/src/ts-types/new-data-set.ts +++ b/packages/vtable/src/ts-types/new-data-set.ts @@ -48,6 +48,8 @@ export interface Total { // calcSubTotals?: CalcTotals; /** 小计汇总维度定义 */ subTotalsDimensions?: string[]; + /** 总计中保留的下级维度,用于按维度分组显示总计 */ + grandTotalDimensions?: string[]; /** 汇总节点显示名称 默认'总计' */ grandTotalLabel?: string; /** 汇总节点显示名称 默认'小计' */ diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index ac4ce2c794..0bc3afebb5 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -399,7 +399,8 @@ export interface ListTableAPI extends BaseTableAPI { values: (string | number)[][], workOnEditableCell?: boolean, triggerEvent?: boolean, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) => Promise | boolean[][]; /** * 根据源数据 records 的 index + field 修改值。 @@ -630,6 +631,8 @@ export interface PivotChartConstructorOptions extends BaseTableConstructorOption * 数据集合, 平坦数据集合。另外一种特殊方式是传入分组后的数据,分组依据为指标 */ records?: any[] | Record; + /** 数据分析相关配置 */ + dataConfig?: IPivotTableDataConfig; /** * @deprecated 请使用resize.columnResizeType */ @@ -750,7 +753,15 @@ export interface PivotTableAPI extends BaseTableAPI { * @param row 粘贴数据的起始行号 * @param values 多个单元格的数据数组 */ - changeCellValues: (col: number, row: number, values: (string | number)[][], workOnEditableCell: boolean) => void; + changeCellValues: ( + col: number, + row: number, + values: (string | number)[][], + workOnEditableCell: boolean, + triggerEvent?: boolean, + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ) => boolean[][]; /** * 获取行表头全路径 diff --git a/packages/vue-vtable/package.json b/packages/vue-vtable/package.json index c9337f18ee..cbb9c469bc 100644 --- a/packages/vue-vtable/package.json +++ b/packages/vue-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vue-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The vue version of VTable", "keywords": [ "vue", @@ -59,7 +59,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index a744555a17..97dabfa9a4 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.7", - "@visactor/vtable-gantt": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-plugins": "workspace:1.26.7", - "@visactor/vtable-sheet": "workspace:1.26.7" + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-plugins": "workspace:1.26.8", + "@visactor/vtable-sheet": "workspace:1.26.8" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4",