diff --git a/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json new file mode 100644 index 0000000000..9c39da21f5 --- /dev/null +++ b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vtable", + "comment": "fix: prevent plugin options from causing a React render loop (GitHub #4859)", + "type": "patch" + } + ], + "packageName": "@visactor/react-vtable", + "email": "biukam.w@gmail.com" +} diff --git a/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json b/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json new file mode 100644 index 0000000000..80bd5a9e12 --- /dev/null +++ b/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-editors", + "comment": "fix: avoid HTML injection in list editor", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-editors", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json b/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json new file mode 100644 index 0000000000..471956bb63 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-plugins", + "comment": "fix: emit context menu click event with snapshot cell value (GitHub #4655)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json new file mode 100644 index 0000000000..1a81604059 --- /dev/null +++ b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-sheet", + "comment": "fix: keep vtable-sheet package tests runnable without prebuilt workspace dependencies", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-sheet", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json b/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json new file mode 100644 index 0000000000..7b90702743 --- /dev/null +++ b/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vue-vtable", + "comment": "fix: render vue components in top frozen rows (GitHub #5265)", + "type": "patch" + } + ], + "packageName": "@visactor/vue-vtable", + "email": "892739385@qq.com" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b36bdbfeb9..e266a022cb 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.6 + specifier: workspace:1.26.7 version: link:../packages/openinula-vtable '@visactor/react-vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/react-vtable '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-editors '@visactor/vtable-export': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-export '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-gantt '@visactor/vtable-search': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-search '@visactor/vtable-sheet': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-sheet '@visactor/vue-vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 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.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -278,7 +278,7 @@ importers: ../../packages/react-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 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.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -477,6 +477,9 @@ importers: '@babel/core': specifier: 7.20.12 version: 7.20.12 + '@babel/plugin-transform-react-jsx': + specifier: 7.28.6 + version: 7.28.6(@babel/core@7.20.12) '@babel/preset-env': specifier: 7.20.2 version: 7.20.2(@babel/core@7.20.12) @@ -637,7 +640,7 @@ importers: ../../packages/vtable-calendar: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -860,7 +863,7 @@ importers: specifier: 2.0.7 version: 2.0.7 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1020,10 +1023,10 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -1256,13 +1259,13 @@ importers: specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-gantt '@vitejs/plugin-react': specifier: 3.1.0 @@ -1361,7 +1364,7 @@ importers: ../../packages/vtable-search: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1515,13 +1518,13 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vtable-plugins': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-plugins '@visactor/vutils': specifier: ~1.0.17 @@ -1660,7 +1663,7 @@ importers: ../../packages/vue-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1841,19 +1844,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-gantt '@visactor/vtable-plugins': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-plugins '@visactor/vtable-sheet': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 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 8110d7329f..2fa348fd6c 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.6","mainProject":"@visactor/vtable","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.7","mainProject":"@visactor/vtable","nextBump":"patch"}] diff --git a/docs/assets/api/en/methods.md b/docs/assets/api/en/methods.md index d24cc48048..d8aa3f4774 100644 --- a/docs/assets/api/en/methods.md +++ b/docs/assets/api/en/methods.md @@ -1492,6 +1492,8 @@ export type TooltipOptions = { padding?: number[]; arrowMark?: boolean; }; + /** Set tooltip appearance delay time */ + appearDelay?: number; /** Set tooltip disappearance time */ disappearDelay?: number; }; diff --git a/docs/assets/api/zh/methods.md b/docs/assets/api/zh/methods.md index 0168642727..b1fd29620e 100644 --- a/docs/assets/api/zh/methods.md +++ b/docs/assets/api/zh/methods.md @@ -1494,6 +1494,8 @@ export type TooltipOptions = { padding?: number[]; arrowMark?: boolean; }; + /** 设置tooltip的出现延迟时间 */ + appearDelay?: number; /** 设置tooltip的消失时间 */ disappearDelay?: number; }; diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 6dc535311f..8a3c7c18e3 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -1,3 +1,26 @@ +# v1.26.7 + +2026-08-20 + +**🆕 New Features** + +- **@visactor/vtable**: support tooltip appear delay +- **@visactor/vtable**: add audio cell type support (GitHub #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**: 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) + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 + +[more detail about v1.26.7](https://github.com/VisActor/VTable/releases/tag/v1.26.7) + # v1.26.6 2026-08-03 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index ea1efd7643..7c8059ff82 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -1,3 +1,26 @@ +# v1.26.7 + +2026-08-20 + +**🆕 新增功能** + +- **@visactor/vtable**: 支持配置 Tooltip 展示延迟 +- **@visactor/vtable**: 新增音频单元格类型支持(GitHub #5271) + +**🐛 问题修复** + +- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白(GitHub #3752) +- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态(GitHub #3975) +- **@visactor/vtable**: 新增右键菜单点击事件类型定义(GitHub #4655) +- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录(GitHub #4964) +- **@visactor/vtable**: 支持在顶部冻结行中渲染 Vue 组件 +- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐(GitHub #5277) +- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示(GitHub #5278) + +**完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 + +[更多详情请查看 v1.26.7](https://github.com/VisActor/VTable/releases/tag/v1.26.7) + # v1.26.6 2026-08-03 diff --git a/docs/assets/demo/en/cell-type/multi-type.md b/docs/assets/demo/en/cell-type/multi-type.md index c24d26de37..933bca5e1d 100644 --- a/docs/assets/demo/en/cell-type/multi-type.md +++ b/docs/assets/demo/en/cell-type/multi-type.md @@ -14,13 +14,14 @@ Specify the cell content type by cellType. Image video source: https://birdsofth ## Key Configurations -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'Text' | 'link' | 'image' | 'video' +| 'audio' | 'Sparkline' | 'progressbar' | 'chart'; @@ -28,12 +29,19 @@ cellType: ## Code demo ```javascript livedemo template=vtable +const audioFiles = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; + const records = [ { name: 'pigeon', introduction: 'The pigeon is a common urban bird with gray plumage and a short, stout beak', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.mp4', + audio: audioFiles[0], YoY: 50, QoQ: 10, trend: [ @@ -50,6 +58,7 @@ const records = [ introduction: 'Swallow is a kind of bird that is good at flying, usually perches near houses and buildings.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.mp4', + audio: audioFiles[1], YoY: 10, QoQ: -10, trend: [ @@ -66,6 +75,7 @@ const records = [ 'The magpie is a common small bird mainly found in Asia. They are small in size with a black head and throat, gray back and white belly. Magpies are social animals and often live in woods Breeding nests in China or in urban parks, feeding on insects, fruit and seeds. They are also highly intelligent and social, and are considered an intelligent, playful bird.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.mp4', + audio: audioFiles[2], YoY: -10, QoQ: -10, trend: [ @@ -82,6 +92,7 @@ const records = [ 'The peacock is a large, beautiful bird with brilliant blue-green plumage and a long tail. Native to South Asia, it feeds on insects, fruit, and seeds.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.mp4', + audio: audioFiles[0], YoY: -10, QoQ: -10, trend: [ @@ -98,6 +109,7 @@ const records = [ 'The flamingo is a beautiful pink bird with long legs and neck, good at swimming, and is a common bird in tropical areas.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.mp4', + audio: audioFiles[1], YoY: -1, QoQ: -6, trend: [ @@ -114,6 +126,7 @@ const records = [ 'The ostrich is a large bird that cannot fly and runs fast. It is one of the largest birds in the world', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.mp4', + audio: audioFiles[2], YoY: -3, QoQ: 10, trend: [ @@ -130,6 +143,7 @@ const records = [ 'Mandarin duck is a kind of two-winged bird. The head of the male bird is blue and the head of the female bird is brown. It usually perches and mates in pairs. It is one of the symbols in Chinese culture.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.mp4', + audio: audioFiles[0], YoY: 10, QoQ: 16, trend: [ @@ -171,6 +185,12 @@ const columns = [ width: 150 // imageSizing : 'keep-aspect-ratio', }, + { + field: 'audio', + title: 'bird audio', + cellType: 'audio', + width: 150 + }, { field: 'YoY', title: 'count Year-over-Year', diff --git a/docs/assets/demo/en/cell-type/progressbar.md b/docs/assets/demo/en/cell-type/progressbar.md index 1d61345b91..6b9d4fc209 100644 --- a/docs/assets/demo/en/cell-type/progressbar.md +++ b/docs/assets/demo/en/cell-type/progressbar.md @@ -14,7 +14,7 @@ Demonstrate multiple ways to use stripe types ## Key Configurations -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'Text' diff --git a/docs/assets/demo/zh/cell-type/multi-type.md b/docs/assets/demo/zh/cell-type/multi-type.md index 63d7fa2a21..37ef0f6337 100644 --- a/docs/assets/demo/zh/cell-type/multi-type.md +++ b/docs/assets/demo/zh/cell-type/multi-type.md @@ -14,13 +14,14 @@ option: ListTable-columns-text#cellType ## 关键配置 -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'text' | 'link' | 'image' | 'video' +| 'audio' | 'sparkline' | 'progressbar' | 'chart'; @@ -28,12 +29,19 @@ cellType: ## 代码演示 ```javascript livedemo template=vtable +const audioFiles = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; + const records = [ { name: 'pigeon', introduction: 'The pigeon is a common urban bird with gray plumage and a short, stout beak', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.mp4', + audio: audioFiles[0], YoY: 50, QoQ: 10, trend: [ @@ -50,6 +58,7 @@ const records = [ introduction: 'Swallow is a kind of bird that is good at flying, usually perches near houses and buildings.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.mp4', + audio: audioFiles[1], YoY: 10, QoQ: -10, trend: [ @@ -66,6 +75,7 @@ const records = [ 'The magpie is a common small bird mainly found in Asia. They are small in size with a black head and throat, gray back and white belly. Magpies are social animals and often live in woods Breeding nests in China or in urban parks, feeding on insects, fruit and seeds. They are also highly intelligent and social, and are considered an intelligent, playful bird.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.mp4', + audio: audioFiles[2], YoY: -10, QoQ: -10, trend: [ @@ -82,6 +92,7 @@ const records = [ 'The peacock is a large, beautiful bird with brilliant blue-green plumage and a long tail. Native to South Asia, it feeds on insects, fruit, and seeds.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.mp4', + audio: audioFiles[0], YoY: -10, QoQ: -10, trend: [ @@ -98,6 +109,7 @@ const records = [ 'The flamingo is a beautiful pink bird with long legs and neck, good at swimming, and is a common bird in tropical areas.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.mp4', + audio: audioFiles[1], YoY: -1, QoQ: -6, trend: [ @@ -114,6 +126,7 @@ const records = [ 'The ostrich is a large bird that cannot fly and runs fast. It is one of the largest birds in the world', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.mp4', + audio: audioFiles[2], YoY: -3, QoQ: 10, trend: [ @@ -130,6 +143,7 @@ const records = [ 'Mandarin duck is a kind of two-winged bird. The head of the male bird is blue and the head of the female bird is brown. It usually perches and mates in pairs. It is one of the symbols in Chinese culture.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.mp4', + audio: audioFiles[0], YoY: 10, QoQ: 16, trend: [ @@ -171,6 +185,12 @@ const columns = [ width: 150 // imageSizing : 'keep-aspect-ratio', }, + { + field: 'audio', + title: 'bird audio', + cellType: 'audio', + width: 150 + }, { field: 'YoY', title: 'count Year-over-Year', diff --git a/docs/assets/demo/zh/cell-type/progressbar.md b/docs/assets/demo/zh/cell-type/progressbar.md index d3157cb433..a6ab0bce32 100644 --- a/docs/assets/demo/zh/cell-type/progressbar.md +++ b/docs/assets/demo/zh/cell-type/progressbar.md @@ -14,7 +14,7 @@ option: ListTable-columns-progressbar#cellType ## 关键配置 -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'text' diff --git a/docs/assets/guide/en/basic_function/row_series_number.md b/docs/assets/guide/en/basic_function/row_series_number.md index 43ae8f0cad..6ca7770260 100644 --- a/docs/assets/guide/en/basic_function/row_series_number.md +++ b/docs/assets/guide/en/basic_function/row_series_number.md @@ -16,7 +16,8 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; diff --git a/docs/assets/guide/en/cell_type/cellType.md b/docs/assets/guide/en/cell_type/cellType.md index dfd0699d71..5a389873b6 100644 --- a/docs/assets/guide/en/cell_type/cellType.md +++ b/docs/assets/guide/en/cell_type/cellType.md @@ -1,20 +1,25 @@ # data type -In the field of data analytics and visualization, one of the typical applications of VTable is to display and present various types of data. The so-called types here include: text, links, pictures, videos, progress bars, and charts. By supporting multiple column types, VTable can provide users with a richer and more diverse data experience, allowing users to observe and understand data from different perspectives and Dimensions. +In the field of data analytics and visualization, one of the typical applications of VTable is to display and present various types of data. The so-called types here include: text, links, pictures, videos, audio, progress bars, and charts. By supporting multiple column types, VTable can provide users with a richer and more diverse data experience, allowing users to observe and understand data from different perspectives and Dimensions. Next, this article will focus on the various column types and their usage and characteristics to help you gain insight into data lake visualization using VTable. ## Support type -There are 7 data types supported by VTable, namely: +There are 12 data types supported by VTable, namely: 1. Text 2. Link 3. Image 4. Video -5. progressbar -6. Sparkline -7. Chart +5. Audio +6. progressbar +7. Sparkline +8. Chart +9. Checkbox +10. Radio +11. Switch +12. Button Next, we'll cover each column type one by one. @@ -119,6 +124,10 @@ Through the above settings, we can customize the columns of the "image" type to When the column type is "video", it is used to display the data of the video type. All configuration items of "video" are the same as image, you can refer to the image type configuration item. +## Audio + +When the column type is "audio", it is used to display audio data. The cell displays an audio icon, and clicking it opens the preview window with audio playback controls. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. + ## progressbar When the column type is "progressbar", it is used to display the data of the progress bar type. The progress bar data can be processed by setting the type of the progress bar, the data range of the progress bar, etc. @@ -258,7 +267,7 @@ The headerType in the rows and columns of the pivot table, and the cellType in t ## Extension: Custom Column Types (TypeScript) -In real-world applications, in addition to VTable’s built-in `cellType` values (such as `text`, `link`, `image`, `video`, `progressbar`, `sparkline`, and `chart`), it is often necessary to attach **business-specific configurations** to column definitions, for example: +In real-world applications, in addition to VTable’s built-in `cellType` values (such as `text`, `link`, `image`, `video`, `audio`, `progressbar`, `sparkline`, and `chart`), it is often necessary to attach **business-specific configurations** to column definitions, for example: - Permission or visibility control: `permissionKey` - Tracking and analytics metadata: `trackId` / `trackParams` diff --git a/docs/assets/guide/en/cell_type/image_video.md b/docs/assets/guide/en/cell_type/image_video.md index 1190274802..0060b7d3b8 100644 --- a/docs/assets/guide/en/cell_type/image_video.md +++ b/docs/assets/guide/en/cell_type/image_video.md @@ -1,8 +1,8 @@ # Multimedia Type -Tables can display various types of data, such as text, numbers, charts, etc. Among them, the display of multimedia types (such as images and videos) in some scenes is particularly important, they can visually display information and help users quickly obtain key data. For example, e-commerce websites can display product pictures in tables, which is convenient for store owners to quickly view product information; video websites can display video preview diagrams in tables, which is convenient for users to quickly understand video content. By displaying multimedia types in tables, the effect of data display and user experience can be effectively improved. +Tables can display various types of data, such as text, numbers, charts, etc. Among them, the display of multimedia types (such as images, videos, and audio) in some scenes is particularly important, they can visually display information and help users quickly obtain key data. For example, e-commerce websites can display product pictures in tables, which is convenient for store owners to quickly view product information; video websites can display video preview diagrams in tables; audio libraries can display playable audio entries in tables. By displaying multimedia types in tables, the effect of data display and user experience can be effectively improved. -Note: The following descriptions are based on image types, and video types are also applicable. +Note: The following descriptions are based on image types. Video and audio share media capabilities such as click preview; image-specific options such as `keepAspectRatio`, `imageAutoSizing`, and image styles do not apply to audio. ## Introduction to unique configuration items @@ -45,5 +45,5 @@ Image type specific configuration items in terms of style style: ## Click preview of image -In some scenes, we want to click on the image to preview the larger image. VTable defaults to clicking on the multimedia (including videos and pictures) cell to pop up the preview window. +In some scenes, we want to click on the image to preview the larger image. VTable defaults to clicking on the multimedia (including images, videos, and audio) cell to pop up the preview window. The audio type displays an audio player in the preview window. ![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) diff --git a/docs/assets/guide/en/components/tooltip.md b/docs/assets/guide/en/components/tooltip.md index 9a388f188f..51a0824d4f 100644 --- a/docs/assets/guide/en/components/tooltip.md +++ b/docs/assets/guide/en/components/tooltip.md @@ -105,6 +105,7 @@ The interface showTooltip can actively display tooltip information, which is use content: 'Order ID:'+tableInstance.getCellValue(col,row), referencePosition: { rect, placement: VTable.TYPES.Placement.right }, //TODO className: 'defineTooltip', + appearDelay: 500, style: { bgColor: 'black', color: 'white', diff --git a/docs/assets/guide/en/custom_define/custom_icon.md b/docs/assets/guide/en/custom_define/custom_icon.md index 8abf93d114..b328477090 100644 --- a/docs/assets/guide/en/custom_define/custom_icon.md +++ b/docs/assets/guide/en/custom_define/custom_icon.md @@ -210,7 +210,7 @@ The list of resettable internal icons is as follows: | Fixed Column | VTable. TYPES. IconFuncTypeEnum.frozen | "freeze" | Fixed Column Function Icon, Fixed State | | Fixed Columns | VTable. TYPES. IconFuncTypeEnum.frozen | "frozen" | Fixed Columns Function Icon, Fixed Status | | Fixed Columns | VTable. TYPES. IconFuncTypeEnum.frozen | "frozenCurrent" | Fixed Columns Function Icon, Frozen When Columns | -| Image or video address invalid | VTable. TYPES. IconFuncTypeEnum.damagePic | "damage_pic" | Multimedia resource parsing failed | +| Image, video, or audio address invalid | VTable. TYPES. IconFuncTypeEnum.damagePic | "damage_pic" | Multimedia resource parsing failed | | Loading | VTable. TYPES. IconFuncTypeEnum.loading_pic | "loading_pic" | Loading status | | Image image type parsing failed | VTable. TYPES. IconFuncTypeEnum.imageDamagePic | "image_damage_pic" | Image resource parsing failed | | Video video type parsing failed | VTable. TYPES. IconFuncTypeEnum.videoDamagePic | "video_damage_pic" | Video resource parsing failed | diff --git a/docs/assets/guide/en/plugin/context-menu.md b/docs/assets/guide/en/plugin/context-menu.md index 28bb38173b..7211754946 100644 --- a/docs/assets/guide/en/plugin/context-menu.md +++ b/docs/assets/guide/en/plugin/context-menu.md @@ -11,6 +11,7 @@ ContextMenu provides rich right-click menu features for VTable tables, supportin - Support custom menu items and separators - Support menu item grouping and submenus - Support custom menu item click callbacks +- Support listening to menu item click events with menu item and cell position details - Support menu item icons and shortcut hints - Support numeric input in menu items (e.g., insert multiple rows/columns) @@ -132,6 +133,48 @@ const contextMenuPlugin = new ContextMenuPlugin({ }); ``` +## Listen to Menu Item Click Events + +If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled, and it is fired after the corresponding menu item handling logic is called. Note that when `menuClickCallback` is configured as a function, the plugin runs that callback and skips the built-in menu handling. Built-in operations such as freeze, insert, and delete are only executed when `menuClickCallback` is not configured, or when it is configured as an object and the current `menuKey` does not match a custom callback. + +```typescript +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '@visactor/vtable-plugins'; + +const contextMenuPlugin = new ContextMenuPlugin(); + +const tableInstance = new VTable.ListTable({ + container: document.getElementById('container'), + columns, + records, + plugins: [contextMenuPlugin] +}); + +tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + const { col, row, contextMenu } = args; + + console.log('Context menu click position:', col, row); + console.log('Menu item:', contextMenu.menuKey, contextMenu.menuText); + + if (contextMenu.menuKey === 'freeze_to_this_row_and_column') { + console.log('Current frozen counts:', tableInstance.frozenRowCount, tableInstance.frozenColCount); + } +}); +``` + +Event argument description: + +| Parameter | Type | Description | +| --- | --- | --- | +| `col` | number | Column index of the menu trigger position; `-1` when there is no corresponding cell | +| `row` | number | Row index of the menu trigger position; `-1` when there is no corresponding cell | +| `contextMenu.menuKey` | string | Unique key of the clicked menu item | +| `contextMenu.menuText` | string | Display text of the clicked menu item | +| `contextMenu.rowIndex` | number \| undefined | Row index of the menu trigger position; may be `undefined` when no row context exists | +| `contextMenu.colIndex` | number \| undefined | Column index of the menu trigger position; may be `undefined` when no column context exists | +| `contextMenu.cellValue` | any \| undefined | Cell value at the menu trigger position; `undefined` when there is no corresponding cell | +| `contextMenu.inputValue` | number \| string \| undefined | Input value of an input menu item; only exists when an input menu item is submitted | + ## Complete Example Here is a complete example showing how to create a table with right-click menu functionality: @@ -244,6 +287,10 @@ const generateTestData = (count) => { // Create table instance const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + console.log('Context menu item clicked:', args.contextMenu); + }); ``` diff --git a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md index a2df037e00..507110531b 100644 --- a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ The following are the Key Configurations items of the basic table and their desc * `container`: The container DOM element of the table, which needs to have width and height. * `records`: The data of the table, represented as an array. -* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'sparkline', 'progressbar', 'chart'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). +* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'audio', 'sparkline', 'progressbar', 'chart', 'checkbox', 'radio', 'switch', 'button'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). * `frozenColCount`: Specifies the number of frozen columns. * `transpose`: Whether to transpose the table, the default is false. * `showHeader`: Whether to display the header, the default is true. diff --git a/docs/assets/guide/zh/basic_function/row_series_number.md b/docs/assets/guide/zh/basic_function/row_series_number.md index b99a87e3ad..3f09af0607 100644 --- a/docs/assets/guide/zh/basic_function/row_series_number.md +++ b/docs/assets/guide/zh/basic_function/row_series_number.md @@ -16,7 +16,8 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; diff --git a/docs/assets/guide/zh/cell_type/cellType.md b/docs/assets/guide/zh/cell_type/cellType.md index 5c404d4827..4c37cd7fe1 100644 --- a/docs/assets/guide/zh/cell_type/cellType.md +++ b/docs/assets/guide/zh/cell_type/cellType.md @@ -1,20 +1,25 @@ # 数据类型 -在数据分析和可视化领域,VTable 的典型应用之一便是显示和呈现各种类型的数据,这里所谓的类型包括:文本、链接、图片、视频、进度条以及图表等。通过支持多种列类型,VTable 可以为用户现更丰富、更多样化的数据体验,便于用户从不同视角和维度来观察和理解数据。 +在数据分析和可视化领域,VTable 的典型应用之一便是显示和呈现各种类型的数据,这里所谓的类型包括:文本、链接、图片、视频、音频、进度条以及图表等。通过支持多种列类型,VTable 可以为用户现更丰富、更多样化的数据体验,便于用户从不同视角和维度来观察和理解数据。 接下来,本文将重点讨论各类列类型及其用法和特点,以帮助您深入了解使用 VTable 进行数据可视化的方法。 ## 支持类型 -VTable 所支持的数据类型共有 7 种,分别为: +VTable 所支持的数据类型共有 12 种,分别为: 1. text 2. link 3. image 4. video -5. progressbar -6. sparkline -7. chart +5. audio +6. progressbar +7. sparkline +8. chart +9. checkbox +10. radio +11. switch +12. button 接下来,我们将逐一介绍每一种列类型的相关内容。 @@ -117,6 +122,10 @@ VTable 所支持的数据类型共有 7 种,分别为: 当列类型为 "video" 时,用于显示视频类型的数据。"video"所有配置项和image一样,可参考image类型配置项。 +## audio + +当列类型为 "audio" 时,用于显示音频类型的数据。单元格会展示音频图标,点击后会弹出预览窗口并使用音频播放控件播放。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 + ## progressbar @@ -260,7 +269,7 @@ VTable 所支持的数据类型共有 7 种,分别为: ## 扩展:自定义列类型(TypeScript) -在实际业务中,除了 VTable 内置的 `cellType`(text/link/image/video/progressbar/sparkline/chart 等),我们常常还需要在列定义里附加一些 **业务侧专用配置**,例如: +在实际业务中,除了 VTable 内置的 `cellType`(text、link、image、video、audio、progressbar、sparkline、chart 等),我们常常还需要在列定义里附加一些 **业务侧专用配置**,例如: - 权限/可见性控制:`permissionKey` - 埋点信息:`trackId` / `trackParams` diff --git a/docs/assets/guide/zh/cell_type/image_video.md b/docs/assets/guide/zh/cell_type/image_video.md index 98e33b05ed..ce130a5a00 100644 --- a/docs/assets/guide/zh/cell_type/image_video.md +++ b/docs/assets/guide/zh/cell_type/image_video.md @@ -1,8 +1,8 @@ # 多媒体类型 -表格可以展示各种类型的数据,例如文本、数字、图表等。其中,多媒体类型(如 image 和 video)在某些场景中的展示尤其重要,它们可以直观地展示信息,帮助用户快速获取关键数据。例如,电商网站可以在表格中展示商品图片,便于店主快速查看商品信息;视频网站可以在表格中展示视频预览图,方便用户快速了解视频内容。通过在表格中展示多媒体类型,可以有效提高数据展示的效果和用户体验。 +表格可以展示各种类型的数据,例如文本、数字、图表等。其中,多媒体类型(如 image、video 和 audio)在某些场景中的展示尤其重要,它们可以直观地展示信息,帮助用户快速获取关键数据。例如,电商网站可以在表格中展示商品图片,便于店主快速查看商品信息;视频网站可以在表格中展示视频预览图,音频库可以在表格中展示可播放的音频入口。通过在表格中展示多媒体类型,可以有效提高数据展示的效果和用户体验。 -注:以下介绍均以image类型为例,video类型也同样适用。 +注:以下介绍均以 image 类型为例,video 和 audio 共享点击预览等媒体能力;`keepAspectRatio`、`imageAutoSizing` 和图片样式等 image 专属配置不适用于 audio。 ## 特有配置项介绍 @@ -42,5 +42,5 @@ image 类型在样式 style 方面的特有配置项: ``` ## image 的点击预览 -当某些场景下,我们希望点击图片可以预览大图。VTable默认点击多媒体(包括视频和图片)单元格即可以弹出预览窗口。 -![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) \ No newline at end of file +当某些场景下,我们希望点击图片可以预览大图。VTable 默认点击多媒体(包括图片、视频和音频)单元格即可以弹出预览窗口。其中 audio 类型会在预览窗口中展示音频播放控件。 +![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) diff --git a/docs/assets/guide/zh/components/tooltip.md b/docs/assets/guide/zh/components/tooltip.md index 61ab428fa6..2077d15e5e 100644 --- a/docs/assets/guide/zh/components/tooltip.md +++ b/docs/assets/guide/zh/components/tooltip.md @@ -110,6 +110,7 @@ const tableInstance = new VTable.ListTable({ content: 'Order ID:'+tableInstance.getCellValue(col,row), referencePosition: { rect, placement: VTable.TYPES.Placement.right }, //TODO className: 'defineTooltip', + appearDelay: 500, style: { bgColor: 'black', color: 'white', diff --git a/docs/assets/guide/zh/custom_define/custom_icon.md b/docs/assets/guide/zh/custom_define/custom_icon.md index e6d882150c..c29106ef25 100644 --- a/docs/assets/guide/zh/custom_define/custom_icon.md +++ b/docs/assets/guide/zh/custom_define/custom_icon.md @@ -210,7 +210,7 @@ VTable.register.icon('frozenCurrent', { | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "freeze" | 固定列功能图标 可固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozen" | 固定列功能图标 已固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozenCurrent" | 固定列功能图标 被冻结当列 | -| 图片 or 视频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | +| 图片或视频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | | 加载中 | VTable.TYPES.IconFuncTypeEnum.loading_pic | "loading_pic" | 加载中状态 | |图片image类型解析失败 |VTable.TYPES.IconFuncTypeEnum.imageDamagePic | "image_damage_pic" | 图片资源解析失败 | |视频video类型解析失败 |VTable.TYPES.IconFuncTypeEnum.videoDamagePic | "video_damage_pic" | 视频资源解析失败 | diff --git a/docs/assets/guide/zh/plugin/context-menu.md b/docs/assets/guide/zh/plugin/context-menu.md index 63289ec6f9..e999ba4268 100644 --- a/docs/assets/guide/zh/plugin/context-menu.md +++ b/docs/assets/guide/zh/plugin/context-menu.md @@ -11,6 +11,7 @@ - 支持自定义菜单项和分隔线 - 支持菜单项分组和子菜单 - 支持自定义菜单项点击回调 +- 支持监听菜单项点击事件,获取菜单项和单元格位置信息 - 支持菜单项图标和快捷键提示 - 支持菜单项中的数字输入(如插入多行/列) @@ -132,6 +133,48 @@ const contextMenuPlugin = new ContextMenuPlugin({ }); ``` +## 监听菜单项点击事件 + +如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在菜单项对应的处理逻辑调用后触发。需要注意的是,如果 `menuClickCallback` 配置为函数,插件会执行该回调并跳过内置菜单处理;如果未配置 `menuClickCallback`,或配置为对象但当前 `menuKey` 未命中自定义回调,才会执行内置的冻结、插入、删除等菜单操作。 + +```typescript +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '@visactor/vtable-plugins'; + +const contextMenuPlugin = new ContextMenuPlugin(); + +const tableInstance = new VTable.ListTable({ + container: document.getElementById('container'), + columns, + records, + plugins: [contextMenuPlugin] +}); + +tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + const { col, row, contextMenu } = args; + + console.log('右键菜单点击位置:', col, row); + console.log('菜单项信息:', contextMenu.menuKey, contextMenu.menuText); + + if (contextMenu.menuKey === 'freeze_to_this_row_and_column') { + console.log('当前冻结行列数:', tableInstance.frozenRowCount, tableInstance.frozenColCount); + } +}); +``` + +事件参数说明: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `col` | number | 菜单触发位置对应的列号;没有对应单元格时为 `-1` | +| `row` | number | 菜单触发位置对应的行号;没有对应单元格时为 `-1` | +| `contextMenu.menuKey` | string | 被点击菜单项的唯一标识 | +| `contextMenu.menuText` | string | 被点击菜单项的展示文本 | +| `contextMenu.rowIndex` | number \| undefined | 菜单触发位置对应的行号;没有对应行时可能为 `undefined` | +| `contextMenu.colIndex` | number \| undefined | 菜单触发位置对应的列号;没有对应列时可能为 `undefined` | +| `contextMenu.cellValue` | any \| undefined | 菜单触发位置对应的单元格值;没有对应单元格时为 `undefined` | +| `contextMenu.inputValue` | number \| string \| undefined | 输入型菜单项中的输入值;仅输入型菜单项提交时存在 | + ## 完整示例 以下是一个完整的示例,展示了如何创建具有右键菜单功能的表格: @@ -244,6 +287,10 @@ const generateTestData = (count) => { // 创建表格实例 const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + console.log('右键菜单项点击:', args.contextMenu); + }); ``` diff --git a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md index ce6483df31..e31c8a6f63 100644 --- a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ * `container`:表格的容器 DOM 元素,dom容器需要提前设置好宽高。这里非必填,可以放到初始化函数的第一个参数中如`new VTable.ListTable(container, option);` * `records`:表格的数据,以数组形式表示。 -* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'sparkline'、'progressbar'、'chart'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 +* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'audio'、'sparkline'、'progressbar'、'chart'、'checkbox'、'radio'、'switch'、'button'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 * `frozenColCount`:指定冻结列数。 * `transpose`:是否转置表格,默认为 false。 * `showHeader`:是否显示表头,默认为 true。 diff --git a/docs/assets/option/en/column/audio-column-type.md b/docs/assets/option/en/column/audio-column-type.md new file mode 100644 index 0000000000..7f57051374 --- /dev/null +++ b/docs/assets/option/en/column/audio-column-type.md @@ -0,0 +1,17 @@ +{{ target: audio-cell-type }} + +#${prefix} columns.audio(string) + +Specify the column type as `'audio'`, cellType can be omitted with the default as 'text' + +##${prefix} cellType(string) = 'audio' + +Specify the column type as `'audio'`, cellType can be omitted with the default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. + +{{ use: base-cell-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/column/base-column-type.md b/docs/assets/option/en/column/base-column-type.md index 79f360aa01..1132322d88 100644 --- a/docs/assets/option/en/column/base-column-type.md +++ b/docs/assets/option/en/column/base-column-type.md @@ -2,7 +2,7 @@ ${prefix} headerType(string) = 'text' -Specify header type, optional: `'text'|'link'|'image'|'video'|'checkbox'`, default `'text'`. +Specify header type, optional: `'text'|'link'|'image'|'video'|'audio'|'checkbox'`, default `'text'`. ${prefix} field(string) diff --git a/docs/assets/option/en/column/column-define.md b/docs/assets/option/en/column/column-define.md index 89d01c867d..85da2af9f7 100644 --- a/docs/assets/option/en/column/column-define.md +++ b/docs/assets/option/en/column/column-define.md @@ -2,7 +2,7 @@ #${prefix} columns -Table column configuration, each column needs to set configuration items separately, and the configuration items differ slightly depending on the cellType. cellType can support: `'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart`, The specific configuration items for each cellType are as follows: +Table column configuration, each column needs to set configuration items separately, and the configuration items differ slightly depending on the cellType. cellType can support: `'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'`, The specific configuration items for each cellType are as follows: {{ use: text-cell-type( prefix = ${prefix} @@ -20,6 +20,10 @@ Table column configuration, each column needs to set configuration items separat prefix = ${prefix} ) }} +{{ use: audio-cell-type( + prefix = ${prefix} +) }} + {{ use: progressbar-cell-type( prefix = ${prefix} ) }} diff --git a/docs/assets/option/en/column/image-column-type.md b/docs/assets/option/en/column/image-column-type.md index 6c2892bf14..33ded61c59 100644 --- a/docs/assets/option/en/column/image-column-type.md +++ b/docs/assets/option/en/column/image-column-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, the default cellType is 'text'. ##${prefix} cellType(string) = 'image' -Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows (also applicable to types with 'video'): +Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-cell-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, the default cellType is `'text'`. Other co ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/audio-dimension-type.md b/docs/assets/option/en/dimension/audio-dimension-type.md new file mode 100644 index 0000000000..2d62d44b84 --- /dev/null +++ b/docs/assets/option/en/dimension/audio-dimension-type.md @@ -0,0 +1,17 @@ +{{ target: audio-dimension-type }} + +#${prefix} ${dimensionHeaderType}.audio(string) + +Specifies the column type as `'audio'`, headerType can be omitted and defaults to 'text' + +##${prefix} headerType(string) = 'audio' + +Specifies the column type as `'audio'`, headerType can be omitted and defaults to `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. + +{{ use: base-dimension-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/columns-dimenison-define.md b/docs/assets/option/en/dimension/columns-dimenison-define.md index bb09213fd4..7f936e2b61 100644 --- a/docs/assets/option/en/dimension/columns-dimenison-define.md +++ b/docs/assets/option/en/dimension/columns-dimenison-define.md @@ -2,7 +2,7 @@ #${prefix} columns -The style and format configuration corresponding to the header of each level of dimensions should be set separately for each dimension. The configuration items will vary slightly depending on the headerType. Supported headerType values are: `'text' | 'link' | 'image' | 'video'`. The specific configuration items for each headerType are as follows: +The style and format configuration corresponding to the header of each level of dimensions should be set separately for each dimension. The configuration items will vary slightly depending on the headerType. Supported headerType values are: `'text' | 'link' | 'image' | 'video' | 'audio'`. The specific configuration items for each headerType are as follows: {{ use: text-dimension-type( @@ -23,4 +23,9 @@ The style and format configuration corresponding to the header of each level of {{ use: video-dimension-type( prefix = ${prefix}, dimensionHeaderType = 'columns' -) }} \ No newline at end of file +) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'columns' +) }} diff --git a/docs/assets/option/en/dimension/image-dimension-type.md b/docs/assets/option/en/dimension/image-dimension-type.md index 365dbd1a81..63c7493c0b 100644 --- a/docs/assets/option/en/dimension/image-dimension-type.md +++ b/docs/assets/option/en/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} headerType(string) = 'image' -Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to type 'video'): +Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/rows-dimension-define.md b/docs/assets/option/en/dimension/rows-dimension-define.md index dc69d016a5..98d3c03c13 100644 --- a/docs/assets/option/en/dimension/rows-dimension-define.md +++ b/docs/assets/option/en/dimension/rows-dimension-define.md @@ -4,7 +4,7 @@ **The structure is the same as the list header tree structure columns configuration** -The row header corresponds to the style and format configuration of each level of dimension. Each type of dimension needs to be set separately. The configuration items have slight differences according to the headerType. The supported headerTypes are: `'text' | 'link' | 'image' | 'video'`. The specific configuration items for each headerType are as follows: +The row header corresponds to the style and format configuration of each level of dimension. Each type of dimension needs to be set separately. The configuration items have slight differences according to the headerType. The supported headerTypes are: `'text' | 'link' | 'image' | 'video' | 'audio'`. The specific configuration items for each headerType are as follows: {{ use: text-dimension-type( @@ -25,4 +25,9 @@ The row header corresponds to the style and format configuration of each level o {{ use: video-dimension-type( prefix = ${prefix}, dimensionHeaderType = 'rows' -) }} \ No newline at end of file +) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'rows' +) }} diff --git a/docs/assets/option/en/icon/base-icon.md b/docs/assets/option/en/icon/base-icon.md index b681caa518..6026f1a3c9 100644 --- a/docs/assets/option/en/icon/base-icon.md +++ b/docs/assets/option/en/icon/base-icon.md @@ -113,6 +113,9 @@ Placement enumeration definition: } ``` +#${prefix} appearDelay (number) +The delay time for the tooltip to appear. + #${prefix} disappearDelay (number) The delay time for the tooltip to disappear. If you need to move the mouse to the tooltip, please configure this parameter. diff --git a/docs/assets/option/en/indicator/audio-indicator-type.md b/docs/assets/option/en/indicator/audio-indicator-type.md new file mode 100644 index 0000000000..3a699d49b1 --- /dev/null +++ b/docs/assets/option/en/indicator/audio-indicator-type.md @@ -0,0 +1,17 @@ +{{ target: audio-indicator-type }} + +#${prefix} indicators.audio(string) + +Specifies the column type as `'audio'`, cellType can be omitted with default as 'text' + +##${prefix} cellType(string) = 'audio' + +Specifies the column type as `'audio'`, cellType can be omitted with default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. + +{{ use: base-indicator-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/base-indicator-type.md b/docs/assets/option/en/indicator/base-indicator-type.md index 09da7b6a75..34445486e2 100644 --- a/docs/assets/option/en/indicator/base-indicator-type.md +++ b/docs/assets/option/en/indicator/base-indicator-type.md @@ -41,7 +41,7 @@ Minimum width of the indicator column ${prefix} headerType(string) = 'text' -Specify the header type, options: `'text'|'link'|'image'|'video'`, default `'text'`. +Specify the header type, options: `'text'|'link'|'image'|'video'|'audio'`, default `'text'`. ${prefix} headerStyle(TODO) @@ -51,6 +51,7 @@ Header cell style. Configuration items vary slightly depending on the headerType - For headerType 'link', see [headerStyle](../option/PivotTable-columns-link#headerStyle.bgColor) - For headerType 'image', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) - For headerType 'video', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) +- For headerType 'audio', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) ${prefix} style diff --git a/docs/assets/option/en/indicator/image-indicator-type.md b/docs/assets/option/en/indicator/image-indicator-type.md index 5e3c8bc24d..bf3f4f6079 100644 --- a/docs/assets/option/en/indicator/image-indicator-type.md +++ b/docs/assets/option/en/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} cellType(string) = 'image' -Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' type): +Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/indicator-define.md b/docs/assets/option/en/indicator/indicator-define.md index f605ff10d3..6421fa3bbb 100644 --- a/docs/assets/option/en/indicator/indicator-define.md +++ b/docs/assets/option/en/indicator/indicator-define.md @@ -2,7 +2,7 @@ #${prefix} indicators -The specific configuration of each indicator in the pivot table, such as style, format, title, etc., needs to be separately set for each type of indicator. The configuration items vary slightly according to the different `cellType`. `cellType` can support: `'text' | 'link' | 'image' | 'video' | 'progressbar' | 'sparkline'`. The specific configuration items for each `cellType` are as follows: +The specific configuration of each indicator in the pivot table, such as style, format, title, etc., needs to be separately set for each type of indicator. The configuration items vary slightly according to the different `cellType`. `cellType` can support: `'text' | 'link' | 'image' | 'video' | 'audio' | 'progressbar' | 'sparkline'`. The specific configuration items for each `cellType` are as follows: {{ use: text-indicator-type( prefix = ${prefix}) }} @@ -16,6 +16,9 @@ The specific configuration of each indicator in the pivot table, such as style, {{ use: video-indicator-type( prefix = ${prefix}) }} +{{ use: audio-indicator-type( + prefix = ${prefix}) }} + {{ use: progressbar-indicator-type( prefix = ${prefix}) }} @@ -23,4 +26,4 @@ The specific configuration of each indicator in the pivot table, such as style, prefix = ${prefix}) }} {{ use: chart-indicator-type( - prefix = ${prefix}) }} \ No newline at end of file + prefix = ${prefix}) }} diff --git a/docs/assets/option/zh/column/audio-column-type.md b/docs/assets/option/zh/column/audio-column-type.md new file mode 100644 index 0000000000..600ae7be62 --- /dev/null +++ b/docs/assets/option/zh/column/audio-column-type.md @@ -0,0 +1,17 @@ +{{ target: audio-cell-type }} + +#${prefix} columns.audio(string) + +指定该列或该行单元格类型为`'audio'`,cellType 缺省的话会被默认为'text' + +##${prefix} cellType(string) = 'audio' + +指定该列或该行单元格类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 + +{{ use: base-cell-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/column/base-column-type.md b/docs/assets/option/zh/column/base-column-type.md index b15f7c09c8..c7349573d3 100644 --- a/docs/assets/option/zh/column/base-column-type.md +++ b/docs/assets/option/zh/column/base-column-type.md @@ -2,7 +2,7 @@ ${prefix} headerType(string) = 'text' -指定表头类型,可选:`'text'|'link'|'image'|'video'|'checkbox'`,默认 `'text'`。 +指定表头类型,可选:`'text'|'link'|'image'|'video'|'audio'|'checkbox'`,默认 `'text'`。 ${prefix} field(string) diff --git a/docs/assets/option/zh/column/column-define.md b/docs/assets/option/zh/column/column-define.md index 68a3241e01..5086eab08c 100644 --- a/docs/assets/option/zh/column/column-define.md +++ b/docs/assets/option/zh/column/column-define.md @@ -2,7 +2,7 @@ #${prefix} columns -表格列配置,每一列需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart`, 每种 cellType 的配置项具体如下: +表格列配置,每一列需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'`, 每种 cellType 的配置项具体如下: {{ use: text-cell-type( prefix = ${prefix} @@ -20,6 +20,10 @@ prefix = ${prefix} ) }} +{{ use: audio-cell-type( + prefix = ${prefix} +) }} + {{ use: progressbar-cell-type( prefix = ${prefix} ) }} diff --git a/docs/assets/option/zh/column/image-column-type.md b/docs/assets/option/zh/column/image-column-type.md index 3f40b01fb8..b1b4e1bf2c 100644 --- a/docs/assets/option/zh/column/image-column-type.md +++ b/docs/assets/option/zh/column/image-column-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-cell-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 +是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/audio-dimension-type.md b/docs/assets/option/zh/dimension/audio-dimension-type.md new file mode 100644 index 0000000000..5c81969cfb --- /dev/null +++ b/docs/assets/option/zh/dimension/audio-dimension-type.md @@ -0,0 +1,17 @@ +{{ target: audio-dimension-type }} + +#${prefix} ${dimensionHeaderType}.audio(string) + +指定列类型为`'audio'`, headerType 可缺省默认为'text' + +##${prefix} headerType(string) = 'audio' + +指定列类型为`'audio'`,headerType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 + +{{ use: base-dimension-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/columns-dimenison-define.md b/docs/assets/option/zh/dimension/columns-dimenison-define.md index b4801b7293..d51f573d70 100644 --- a/docs/assets/option/zh/dimension/columns-dimenison-define.md +++ b/docs/assets/option/zh/dimension/columns-dimenison-define.md @@ -2,7 +2,7 @@ #${prefix} columns -列表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video'`, 每种headerType的配置项具体如下: +列表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video' | 'audio'`, 每种headerType的配置项具体如下: {{ use: text-dimension-type( @@ -24,3 +24,8 @@ prefix = ${prefix}, dimensionHeaderType = 'columns' ) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'columns' +) }} diff --git a/docs/assets/option/zh/dimension/image-dimension-type.md b/docs/assets/option/zh/dimension/image-dimension-type.md index 2ae091941d..1ae664494f 100644 --- a/docs/assets/option/zh/dimension/image-dimension-type.md +++ b/docs/assets/option/zh/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ ##${prefix} headerType(string) = 'image' -指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 \ No newline at end of file +是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/rows-dimension-define.md b/docs/assets/option/zh/dimension/rows-dimension-define.md index bb911fa6f5..9cdcdfe7dc 100644 --- a/docs/assets/option/zh/dimension/rows-dimension-define.md +++ b/docs/assets/option/zh/dimension/rows-dimension-define.md @@ -4,7 +4,7 @@ **结构同列表头树形结构columns配置** -行表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video'`, 每种headerType的配置项具体如下: +行表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video' | 'audio'`, 每种headerType的配置项具体如下: {{ use: text-dimension-type( @@ -27,3 +27,7 @@ dimensionHeaderType = 'rows' ) }} +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'rows' +) }} diff --git a/docs/assets/option/zh/icon/base-icon.md b/docs/assets/option/zh/icon/base-icon.md index 08763c0693..9901ce6604 100644 --- a/docs/assets/option/zh/icon/base-icon.md +++ b/docs/assets/option/zh/icon/base-icon.md @@ -113,6 +113,9 @@ Placement 枚举类型定义: } ``` +#${prefix} appearDelay (number) +提示框显示延迟时间。 + #${prefix} disappearDelay (number) 提示框消失延迟时间,如果有需要鼠标移动到 tooltip 上的需求,请配置上这个参数。 diff --git a/docs/assets/option/zh/indicator/audio-indicator-type.md b/docs/assets/option/zh/indicator/audio-indicator-type.md new file mode 100644 index 0000000000..76d45434fb --- /dev/null +++ b/docs/assets/option/zh/indicator/audio-indicator-type.md @@ -0,0 +1,17 @@ +{{ target: audio-indicator-type }} + +#${prefix} indicators.audio(string) + +指定列类型为`'audio'`,cellType 缺省的话会被默认为'text' + +##${prefix} cellType(string) = 'audio' + +指定列类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 + +{{ use: base-indicator-type( + prefix = '##'+${prefix} +) }} + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/base-indicator-type.md b/docs/assets/option/zh/indicator/base-indicator-type.md index 1a54f13768..ddd8d695aa 100644 --- a/docs/assets/option/zh/indicator/base-indicator-type.md +++ b/docs/assets/option/zh/indicator/base-indicator-type.md @@ -41,7 +41,7 @@ ${prefix} minWidth(number|string) ${prefix} headerType(string) = 'text' -指定表头类型,可选:`'text'|'link'|'image'|'video'`,默认 `'text'`。 +指定表头类型,可选:`'text'|'link'|'image'|'video'|'audio'`,默认 `'text'`。 ${prefix} headerStyle(TODO) @@ -51,6 +51,7 @@ ${prefix} headerStyle(TODO) - headerType 为'link',对应[headerStyle](../option/PivotTable-columns-link#headerStyle.bgColor) - headerType 为'image',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) - headerType 为'video',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) +- headerType 为'audio',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) ${prefix} style diff --git a/docs/assets/option/zh/indicator/image-indicator-type.md b/docs/assets/option/zh/indicator/image-indicator-type.md index ccac13e515..1ac47d9c5c 100644 --- a/docs/assets/option/zh/indicator/image-indicator-type.md +++ b/docs/assets/option/zh/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 \ No newline at end of file +是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/indicator-define.md b/docs/assets/option/zh/indicator/indicator-define.md index 8aec5dc667..3c659cb64d 100644 --- a/docs/assets/option/zh/indicator/indicator-define.md +++ b/docs/assets/option/zh/indicator/indicator-define.md @@ -2,7 +2,7 @@ #${prefix} indicators(Array) -透视表中各个指标的具体配置,如样式,format,title 等,每种指标需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'progressbar' | 'sparkline'`, 每种 cellType 的配置项具体如下: +透视表中各个指标的具体配置,如样式,format,title 等,每种指标需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'audio' | 'progressbar' | 'sparkline'`, 每种 cellType 的配置项具体如下: {{ use: text-indicator-type( prefix = ${prefix}) }} @@ -16,6 +16,9 @@ {{ use: video-indicator-type( prefix = ${prefix}) }} +{{ use: audio-indicator-type( + prefix = ${prefix}) }} + {{ use: progressbar-indicator-type( prefix = ${prefix}) }} diff --git a/docs/package.json b/docs/package.json index 040de82b26..cbab95dc3f 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.6", - "@visactor/vtable-gantt": "workspace:1.26.6", - "@visactor/react-vtable": "workspace:1.26.6", - "@visactor/vue-vtable": "workspace:1.26.6", - "@visactor/openinula-vtable": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-export": "workspace:1.26.6", - "@visactor/vtable-search": "workspace:1.26.6", - "@visactor/vtable-sheet": "workspace:1.26.6", + "@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", "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 9ca95d26a1..78be8cbcd4 100644 --- a/packages/openinula-vtable/package.json +++ b/packages/openinula-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/openinula-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The openinula version of VTable", "keywords": [ "openinula", @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/react-vtable/__tests__/list-table-plugins.test.tsx b/packages/react-vtable/__tests__/list-table-plugins.test.tsx new file mode 100644 index 0000000000..e080bb782b --- /dev/null +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -0,0 +1,55 @@ +/* eslint-env jest, browser */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import { FilterPlugin } from '../../vtable-plugins/src/filter'; +import { ListColumn, ListTable } from '../src'; + +describe('ListTable plugins', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + container.style.width = '800px'; + container.style.height = '400px'; + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + test('does not enter an update loop when a filter plugin is configured', async () => { + let readyResolve!: () => void; + const ready = new Promise(resolve => { + readyResolve = resolve; + }); + const onReady = jest.fn(); + onReady.mockImplementationOnce(() => readyResolve()); + + await act(async () => { + root.render( + + + + + ); + }); + await ready; + + expect(onReady).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js new file mode 100644 index 0000000000..31f4177a67 --- /dev/null +++ b/packages/react-vtable/jest.config.js @@ -0,0 +1,59 @@ +const path = require('path'); +const { createVRenderModuleNameMapper } = require('../../common/config/jest/vrender-module-name-mapper'); + +module.exports = { + preset: 'ts-jest', + runner: 'jest-electron/runner', + testEnvironment: 'jest-electron/environment', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts|tsx)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: false, + isolatedModules: true, + tsconfig: { + resolveJsonModule: true, + esModuleInterop: true, + jsx: 'react', + baseUrl: '.', + paths: { + '@visactor/vtable': ['../vtable/src/index'], + '@visactor/vtable/*': ['../vtable/src/*'], + '@src/*': ['../vtable/src/*'], + '@vutils-extension': ['../vtable/src/vutil-extension-temp/index'] + } + } + }, + __DEV__: true + }, + cacheDirectory: '/.jest-cache', + moduleNameMapper: { + 'd3-array': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-array@3.2.3/node_modules/d3-array/dist/d3-array.min.js' + ), + 'd3-geo': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-geo@3.1.1/node_modules/d3-geo/dist/d3-geo.min.js' + ), + 'd3-dsv': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-dsv@3.0.1/node_modules/d3-dsv/dist/d3-dsv.min.js' + ), + 'd3-hexbin': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hexbin@0.2.2/node_modules/d3-hexbin/build/d3-hexbin.min.js' + ), + 'd3-hierarchy': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hierarchy@3.1.2/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js' + ), + ...createVRenderModuleNameMapper('/../vtable/node_modules'), + '@visactor/vtable$': '/../vtable/src/index', + '@visactor/vtable/es/(.*)': '/../vtable/src/$1', + '@src/(.*)': '/../vtable/src/$1', + '@vutils-extension': '/../vtable/src/vutil-extension-temp/index' + }, + setupFiles: ['./setup-mock.js'] +}; diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 3295617b53..2df5245aa8 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The react version of VTable", "keywords": [ "react", @@ -41,6 +41,7 @@ "start:react19": "npm run setup:react19-deps && vite --config ./demo/vite.config.react19.ts ./demo", "build": "npm run fix-memory-limit && bundle --clean", "compile": "tsc --noEmit", + "test": "jest --config jest.config.js", "eslint": "eslint --debug --fix src/", "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, @@ -57,7 +58,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "react-is": "^18.2.0", "react-reconciler": "0.29.0" diff --git a/packages/react-vtable/src/tables/base-table.tsx b/packages/react-vtable/src/tables/base-table.tsx index 8d96949aa2..1616be9fcd 100644 --- a/packages/react-vtable/src/tables/base-table.tsx +++ b/packages/react-vtable/src/tables/base-table.tsx @@ -139,6 +139,8 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { const optionFromChildren = useRef>(null); const prevRecords = useRef(props.records); const eventsBinded = React.useRef(null); + const latestProps = useRef(props); + const isInitialReady = useRef(true); const skipFunctionDiff = !!props.skipFunctionDiff; const keepColumnWidthChange = !!props.keepColumnWidthChange; const columnWidths = useRef>(new Map()); @@ -148,6 +150,7 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (tableContext.current) { tableContext.current.onError = props.onError; } + latestProps.current = props; const parseOption = useCallback( (props: Props) => { @@ -235,17 +238,17 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (!tableContext.current || !tableContext.current.table) { return; } + const currentProps = latestProps.current; // rebind events after render - bindEventsToTable(tableContext.current.table, props, eventsBinded.current, TABLE_EVENTS); + bindEventsToTable(tableContext.current.table, currentProps, eventsBinded.current, TABLE_EVENTS); - // to be fixed - // will cause another useEffect - setUpdateId(updateId + 1); - if (props.onReady) { - props.onReady(tableContext.current.table, updateId === 0); + setUpdateId(currentUpdateId => currentUpdateId + 1); + if (currentProps.onReady) { + currentProps.onReady(tableContext.current.table, isInitialReady.current); } + isInitialReady.current = false; } - }, [updateId, setUpdateId, props]); + }, []); const renderTable = useCallback(() => { if (tableContext.current.table) { diff --git a/packages/react-vtable/tscofig.eslint.json b/packages/react-vtable/tscofig.eslint.json index a8b2b56da8..e275387533 100644 --- a/packages/react-vtable/tscofig.eslint.json +++ b/packages/react-vtable/tscofig.eslint.json @@ -15,6 +15,7 @@ }, "include": [ "src", - "demo" + "demo", + "__tests__" ] -} \ No newline at end of file +} diff --git a/packages/vtable-calendar/package.json b/packages/vtable-calendar/package.json index 4444fbd5ff..38dc37ccae 100644 --- a/packages/vtable-calendar/package.json +++ b/packages/vtable-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-calendar", - "version": "1.26.6", + "version": "1.26.7", "description": "The calendar component of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "date-fns": "3.6.0" }, diff --git a/packages/vtable-editors/__tests__/list-editor.test.ts b/packages/vtable-editors/__tests__/list-editor.test.ts new file mode 100644 index 0000000000..96d328b5ad --- /dev/null +++ b/packages/vtable-editors/__tests__/list-editor.test.ts @@ -0,0 +1,48 @@ +import { ListEditor } from '../src/list-editor'; + +describe('ListEditor', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('treats configured option values as text instead of HTML', () => { + const values = [ + '', + 'x" onclick="alert(1)', + '' + ]; + const container = document.createElement('div'); + document.body.appendChild(container); + + const editor = new ListEditor({ values }); + editor.onStart({ + container, + value: values[1], + referencePosition: { + rect: { + left: 0, + top: 0, + width: 100, + height: 24 + } + }, + endEdit: () => undefined, + table: {}, + col: 0, + row: 0 + }); + + const select = container.querySelector('select'); + expect(select).not.toBeNull(); + if (!select) { + throw new Error('ListEditor did not create a select element'); + } + expect(container.querySelector('img#xss-image')).toBeNull(); + expect(container.querySelector('svg#xss-svg')).toBeNull(); + expect(container.querySelectorAll('select')).toHaveLength(1); + expect(select.options).toHaveLength(values.length); + expect(Array.from(select.options).map(option => option.textContent)).toEqual(values); + expect(Array.from(select.options).map(option => option.value)).toEqual(values); + expect(select.value).toBe(values[1]); + }); +}); diff --git a/packages/vtable-editors/jest.config.js b/packages/vtable-editors/jest.config.js new file mode 100644 index 0000000000..1bb6fee5b4 --- /dev/null +++ b/packages/vtable-editors/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: { + exclude: ['**'] + }, + tsconfig: './tsconfig.test.json' + }, + __DEV__: true + }, + cacheDirectory: '/.jest-cache' +}; diff --git a/packages/vtable-editors/package.json b/packages/vtable-editors/package.json index 600baa8154..24c8edb847 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-editors", - "version": "1.26.6", + "version": "1.26.7", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -28,6 +28,7 @@ "eslint": "eslint --debug --fix src/", "build": "npm run fix-memory-limit && bundle", "dev": "bundle --clean -f es -w", + "test": "jest --silent", "test-cov": "jest -w 16 --coverage", "test-live": "npm run test-watch __tests__/unit/", "test-watch": "DEBUG_MODE=1 jest --watch", diff --git a/packages/vtable-editors/src/list-editor.ts b/packages/vtable-editors/src/list-editor.ts index b18f37d5b2..474f3381f0 100644 --- a/packages/vtable-editors/src/list-editor.ts +++ b/packages/vtable-editors/src/list-editor.ts @@ -41,14 +41,13 @@ export class ListEditor implements IEditor { // create option tags const { values } = this.editorConfig; - let opsStr = ''; values.forEach(item => { - opsStr += - item === value - ? `` - : ``; + const option = document.createElement('option'); + option.value = item; + option.textContent = item; + option.selected = item === value; + select.appendChild(option); }); - select.innerHTML = opsStr; this.container.appendChild(select); // this._bindSelectChangeEvent(); diff --git a/packages/vtable-editors/tsconfig.test.json b/packages/vtable-editors/tsconfig.test.json new file mode 100644 index 0000000000..f46aaf69df --- /dev/null +++ b/packages/vtable-editors/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "@internal/ts-config/tsconfig.base.json", + "compilerOptions": { + "types": ["jest", "offscreencanvas", "node"], + "lib": ["DOM", "ESNext"], + "baseUrl": "./", + "rootDir": ".", + "paths": {} + }, + "include": ["src", "__tests__"] +} diff --git a/packages/vtable-export/package.json b/packages/vtable-export/package.json index 8fceb141f8..a431f3c962 100644 --- a/packages/vtable-export/package.json +++ b/packages/vtable-export/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-export", - "version": "1.26.6", + "version": "1.26.7", "description": "The export util of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "file-saver": "2.0.5", "@types/file-saver": "2.0.7", diff --git a/packages/vtable-gantt/package.json b/packages/vtable-gantt/package.json index 06e96c8088..baca2d03d9 100644 --- a/packages/vtable-gantt/package.json +++ b/packages/vtable-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-gantt", - "version": "1.26.6", + "version": "1.26.7", "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.6", - "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index b9579f9562..abde6ea62f 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -134,6 +134,178 @@ describe('Context menu canvas option', () => { ); }); + test('ContextMenuPlugin emits context_menu_click after menu item click', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin(); + + table = new ListTable({ + container, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records: [{ id: 1, name: 'Alice' }], + plugins: [contextMenuPlugin] + }); + + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'freeze_to_this_row_and_column', + menuText: '冻结到本行本列', + rowIndex: 1, + colIndex: 0 + }, + table + ); + + expect(table.frozenRowCount).toBe(2); + expect(table.frozenColCount).toBe(1); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'freeze_to_this_row_and_column', + menuText: '冻结到本行本列', + rowIndex: 1, + colIndex: 0, + cellValue: 1 + } + }); + }); + + test('ContextMenuPlugin emits the right-click cell value after a row deletion menu action', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + bodyCellMenuItems: [{ text: '删除行', menuKey: 'delete_row' }] + }); + + table = new ListTable({ + container, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ], + syncRecordOperationsToSourceRecords: true, + plugins: [contextMenuPlugin] + }); + + table.stateManager.select.ranges = [ + { + start: { col: 0, row: 1 }, + end: { col: 0, row: 1 } + } + ]; + const showMenu = jest.spyOn(contextMenuPlugin['menuManager'], 'showMenu'); + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin.run( + { + col: 0, + row: 1, + event: { + clientX: 10, + clientY: 20, + preventDefault: jest.fn() + } + }, + ListTable.EVENT_TYPE.CONTEXTMENU_CELL, + table + ); + + const contextSnapshot = showMenu.mock.calls[0][3]; + expect(contextSnapshot.cellValue).toBe(1); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'delete_row', + menuText: '删除行', + ...contextSnapshot + }, + table + ); + + expect(table.records.map(record => record.id)).toEqual([2]); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'delete_row', + menuText: '删除行', + rowIndex: 1, + colIndex: 0, + cellValue: 1 + } + }); + }); + + test('ContextMenuPlugin preserves null and undefined cell value snapshots', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + menuClickCallback: jest.fn() + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + const getCellValue = jest.spyOn(table, 'getCellValue'); + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'custom_null', + menuText: 'Null Value', + rowIndex: 1, + colIndex: 0, + cellValue: null + }, + table + ); + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'custom_undefined', + menuText: 'Undefined Value', + rowIndex: 1, + colIndex: 0, + cellValue: undefined + }, + table + ); + + expect(getCellValue).not.toHaveBeenCalled(); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'custom_null', + menuText: 'Null Value', + rowIndex: 1, + colIndex: 0, + cellValue: null + } + }); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'custom_undefined', + menuText: 'Undefined Value', + rowIndex: 1, + colIndex: 0, + cellValue: undefined + } + }); + }); + test('ContextMenuPlugin init uses new options when added through updateOption', () => { const container = createDiv(); const contextMenuPlugin = new ContextMenuPlugin({ diff --git a/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts new file mode 100644 index 0000000000..f26580ae23 --- /dev/null +++ b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts @@ -0,0 +1,102 @@ +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '../../src/context-menu'; + +const CONTAINER_ID = 'vTable'; +const INFO_ID = 'context-menu-click-info'; +const STATUS_ID = 'context-menu-click-status'; + +function createRecords() { + return Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + name: `name-${index + 1}`, + city: ['Beijing', 'Shanghai', 'Shenzhen'][index % 3], + value: Math.round((index + 1) * 12.6) + })); +} + +function updateStatus(table: VTable.ListTable, status: HTMLElement, eventArgs?: any) { + const lines = [ + `frozenRowCount: ${table.frozenRowCount}`, + `frozenColCount: ${table.frozenColCount}`, + eventArgs + ? `last context_menu_click: ${JSON.stringify(eventArgs.contextMenu)}` + : 'last context_menu_click: waiting for menu click' + ]; + status.textContent = lines.join('\n'); +} + +export function createTableInstance(status: HTMLElement) { + const plugin = new ContextMenuPlugin(); + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records: createRecords(), + columns: [ + { field: 'id', title: 'ID', width: 80 }, + { field: 'name', title: 'Name', width: 160 }, + { field: 'city', title: 'City', width: 160 }, + { field: 'value', title: 'Value', width: 120 } + ], + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + widthMode: 'standard', + heightMode: 'standard', + plugins: [plugin] + }; + + const tableInstance = new VTable.ListTable(option); + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + updateStatus(tableInstance, status, args); + // eslint-disable-next-line no-console + console.log('context_menu_click', args); + }); + + updateStatus(tableInstance, status); + window.tableInstance = tableInstance; + return tableInstance; +} + +export function createTable() { + document.getElementById(INFO_ID)?.remove(); + document.getElementById(STATUS_ID)?.remove(); + + const container = document.getElementById(CONTAINER_ID) ?? document.createElement('div'); + container.id = CONTAINER_ID; + container.style.width = '100%'; + container.style.height = '460px'; + if (!container.parentNode) { + document.body.appendChild(container); + } + + const info = document.createElement('div'); + info.id = INFO_ID; + info.style.margin = '10px'; + info.style.padding = '10px'; + info.style.border = '1px solid #ddd'; + info.style.borderRadius = '4px'; + info.style.backgroundColor = '#f9f9f9'; + info.innerHTML = ` +

Issue 4655: context menu click event

+

Right-click a body cell, open the freeze submenu, then click freeze or unfreeze.

+

The table should emit context_menu_click with menu details in contextMenu.

+ `; + document.body.appendChild(info); + + const status = document.createElement('pre'); + status.id = STATUS_ID; + status.style.margin = '10px'; + status.style.padding = '10px'; + status.style.border = '1px solid #ddd'; + status.style.borderRadius = '4px'; + status.style.backgroundColor = '#fff'; + document.body.appendChild(status); + + const tableInstance = createTableInstance(status); + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + document.getElementById(INFO_ID)?.remove(); + document.getElementById(STATUS_ID)?.remove(); + release(); + }; + + return tableInstance; +} diff --git a/packages/vtable-plugins/demo/menu.ts b/packages/vtable-plugins/demo/menu.ts index f110a5cb4b..da8063e917 100644 --- a/packages/vtable-plugins/demo/menu.ts +++ b/packages/vtable-plugins/demo/menu.ts @@ -156,6 +156,10 @@ export const menus = [ path: 'context-menu', name: 'context-menu' }, + { + path: 'context-menu', + name: 'issue-4655-context-menu-click' + }, { path: 'context-menu', name: 'issue-5215-context-menu-canvas' diff --git a/packages/vtable-plugins/package.json b/packages/vtable-plugins/package.json index 417b64ec98..0486e0cc86 100644 --- a/packages/vtable-plugins/package.json +++ b/packages/vtable-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-plugins", - "version": "1.26.6", + "version": "1.26.7", "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.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-gantt": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-gantt": "workspace:1.26.7", "@visactor/vchart": "2.1.3", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 47f9cf06bc..1827757ba9 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -156,7 +156,7 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } // 显示右键菜单 - this.showContextMenu(menuItems, mouseX, mouseY, col, row); + this.showContextMenu(menuItems, mouseX, mouseY, col, row, table.getCellValue(col, row)); } }; @@ -172,7 +172,14 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } if (menuItems.length > 0) { - this.showContextMenu(menuItems, eventArgs.event.clientX, eventArgs.event.clientY, col, row); + this.showContextMenu( + menuItems, + eventArgs.event.clientX, + eventArgs.event.clientY, + col, + row, + col >= 0 && row >= 0 ? table.getCellValue(col, row) : undefined + ); } }; @@ -204,7 +211,14 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } // 显示右键菜单 - this.showContextMenu(menuItems, mouseX, mouseY, colIndex, rowIndex); + this.showContextMenu( + menuItems, + mouseX, + mouseY, + colIndex, + rowIndex, + colIndex >= 0 && rowIndex >= 0 ? table.getCellValue(colIndex, rowIndex) : undefined + ); } }; @@ -249,9 +263,39 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { // 菜单项处理逻辑 this.handleMenuClick(args, table); } + this.fireContextMenuClick(args, table); }; - private showContextMenu(menuItems: MenuItemOrSeparator[], x: number, y: number, col: number, row: number): void { + private fireContextMenuClick(args: MenuClickEventArgs, table: ListTable): void { + const { colIndex, rowIndex, ...contextMenu } = args; + const hasCellPosition = + typeof colIndex === 'number' && typeof rowIndex === 'number' && colIndex >= 0 && rowIndex >= 0; + const hasCellValueSnapshot = Object.prototype.hasOwnProperty.call(contextMenu, 'cellValue'); + const cellValue = hasCellValueSnapshot + ? contextMenu.cellValue + : hasCellPosition + ? table.getCellValue(colIndex, rowIndex) + : undefined; + table.fireListeners(TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: colIndex ?? -1, + row: rowIndex ?? -1, + contextMenu: { + ...contextMenu, + colIndex, + rowIndex, + cellValue + } + }); + } + + private showContextMenu( + menuItems: MenuItemOrSeparator[], + x: number, + y: number, + col: number, + row: number, + cellValue?: any + ): void { // 显示菜单 this.menuManager.showMenu( menuItems, @@ -259,7 +303,8 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { y, { rowIndex: row, - colIndex: col + colIndex: col, + cellValue }, this.table ); diff --git a/packages/vtable-plugins/src/contextmenu/menu-manager.ts b/packages/vtable-plugins/src/contextmenu/menu-manager.ts index 44816c106a..d90da01bdc 100644 --- a/packages/vtable-plugins/src/contextmenu/menu-manager.ts +++ b/packages/vtable-plugins/src/contextmenu/menu-manager.ts @@ -17,6 +17,7 @@ import type { MenuClickEventArgs } from './types'; interface MenuContext { rowIndex?: number; colIndex?: number; + cellValue?: any; } /** diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 9d988fcd35..7d0ff32ce1 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-search", - "version": "1.26.6", + "version": "1.26.7", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts b/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts index 083d81e1bf..b3b93459a6 100644 --- a/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts +++ b/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts @@ -220,6 +220,10 @@ describe('FormulaEngine', () => { value: null, error: 'Basic arithmetic evaluation failed' }); + expect(engine.calculateFormula('=1+ +1')).toEqual({ + value: 2, + error: undefined + }); }); test('应该正确处理除以零', () => { @@ -234,6 +238,7 @@ describe('FormulaEngine', () => { test('应该正确验证有效公式', () => { expect(engine.validateFormula('=SUM(1,2,3)')).toEqual({ isValid: true, error: undefined }); expect(engine.validateFormula('=A1+B1')).toEqual({ isValid: true, error: undefined }); + expect(engine.validateFormula('=1+ +1')).toEqual({ isValid: true, error: undefined }); expect(engine.validateFormula('=IF(1>0,1,0)')).toEqual({ isValid: true, error: undefined }); }); diff --git a/packages/vtable-sheet/jest.config.js b/packages/vtable-sheet/jest.config.js index 01dfebe76c..d3a24388d0 100644 --- a/packages/vtable-sheet/jest.config.js +++ b/packages/vtable-sheet/jest.config.js @@ -18,6 +18,10 @@ module.exports = { resolveJsonModule: true, esModuleInterop: true, paths: { + '@visactor/vtable-editors': ['../vtable-editors/src/index.ts'], + '@visactor/vtable-plugins': ['../vtable-plugins/src/index.ts'], + '@visactor/vtable-plugins/src/*': ['../vtable-plugins/src/*'], + '@visactor/vtable-gantt': ['../vtable-gantt/src/index.ts'], '@src/vrender': ['../vtable/src/vrender.ts'], '@src/*': ['../vtable/src/*'] } @@ -68,8 +72,10 @@ module.exports = { '../../common/temp/node_modules/.pnpm/d3-hierarchy@3.1.2/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js' ), ...createVRenderModuleNameMapper('/../vtable/node_modules'), - '@visactor/vtable-editors': path.resolve(__dirname, '../vtable-editors/src/index.ts'), - '@visactor/vtable-plugins': path.resolve(__dirname, '../vtable-plugins/src/index.ts'), + '^@visactor/vtable-editors$': path.resolve(__dirname, '../vtable-editors/src/index.ts'), + '^@visactor/vtable-plugins$': path.resolve(__dirname, '../vtable-plugins/src/index.ts'), + '^@visactor/vtable-plugins/src/(.*)$': '/../vtable-plugins/src/$1', + '^@visactor/vtable-gantt$': path.resolve(__dirname, '../vtable-gantt/src/index.ts'), // 子路径映射需放在 @visactor/vtable 之前,确保精确匹配优先生效 '^@visactor/vtable/es/(.*)$': '/../vtable/src/$1', '@visactor/vtable': path.resolve(__dirname, '../vtable/src/index.ts'), diff --git a/packages/vtable-sheet/package.json b/packages/vtable-sheet/package.json index 93fb7abfde..f25091d3ef 100644 --- a/packages/vtable-sheet/package.json +++ b/packages/vtable-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-sheet", - "version": "1.26.6", + "version": "1.26.7", "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.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-plugins": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-plugins": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-sheet/src/formula/formula-engine.ts b/packages/vtable-sheet/src/formula/formula-engine.ts index b0a470651a..0db441b2a3 100644 --- a/packages/vtable-sheet/src/formula/formula-engine.ts +++ b/packages/vtable-sheet/src/formula/formula-engine.ts @@ -1545,6 +1545,10 @@ export class FormulaEngine { private evaluateBasicArithmetic(expr: string): { value: unknown; error?: string } { try { + if (/[+\-*/]\+/.test(expr)) { + return { value: null, error: 'Basic arithmetic evaluation failed' }; + } + let index = 0; const skipWhitespace = () => { @@ -1633,7 +1637,7 @@ export class FormulaEngine { const result = parseAdditive(); skipWhitespace(); - if (index !== expr.length || !Number.isFinite(result)) { + if (index !== expr.length || Number.isNaN(result)) { return { value: null, error: 'Basic arithmetic evaluation failed' }; } diff --git a/packages/vtable-sheet/src/test-shims/vtable-plugins.ts b/packages/vtable-sheet/src/test-shims/vtable-plugins.ts index 4a62bd9719..1c426b09c3 100644 --- a/packages/vtable-sheet/src/test-shims/vtable-plugins.ts +++ b/packages/vtable-sheet/src/test-shims/vtable-plugins.ts @@ -1,4 +1,4 @@ -export * from '@visactor/vtable-plugins/cjs/index.js'; +export * from '../../../vtable-plugins/src/index'; const { HistoryPlugin } = require('../../../vtable-plugins/src/history'); const { FilterPlugin } = require('../../../vtable-plugins/src/filter'); diff --git a/packages/vtable/CHANGELOG.json b/packages/vtable/CHANGELOG.json index f481544ad1..34e307d147 100644 --- a/packages/vtable/CHANGELOG.json +++ b/packages/vtable/CHANGELOG.json @@ -1,6 +1,46 @@ { "name": "@visactor/vtable", "entries": [ + { + "version": "1.26.7", + "tag": "@visactor/vtable_v1.26.7", + "date": "Thu, 20 Aug 2026 08:02:48 GMT", + "comments": { + "minor": [ + { + "comment": "feat: support tooltip appear delay" + }, + { + "comment": "feat: add audio cell type support (GitHub #5271)" + } + ], + "patch": [ + { + "comment": "fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752)" + }, + { + "comment": "fix: preserve tree checkbox state after row drag reorder (GitHub #3975)" + }, + { + "comment": "fix: add context menu click event typing (GitHub #4655)" + }, + { + "comment": "fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964)" + }, + { + "comment": "fix: align frame border with short frozen rows (GitHub #5277)" + }, + { + "comment": "fix: keep headers after setRecords at scroll edge (GitHub #5278)" + } + ], + "none": [ + { + "comment": "fix: render vue components in top frozen rows" + } + ] + } + }, { "version": "1.26.6", "tag": "@visactor/vtable_v1.26.6", diff --git a/packages/vtable/CHANGELOG.md b/packages/vtable/CHANGELOG.md index 1020fe84cb..8c454c62c8 100644 --- a/packages/vtable/CHANGELOG.md +++ b/packages/vtable/CHANGELOG.md @@ -1,6 +1,27 @@ # Change Log - @visactor/vtable -This log was last generated on Mon, 03 Aug 2026 11:18:03 GMT and should not be manually modified. +This log was last generated on Thu, 20 Aug 2026 08:02:48 GMT and should not be manually modified. + +## 1.26.7 +Thu, 20 Aug 2026 08:02:48 GMT + +### Minor changes + +- feat: support tooltip appear delay +- feat: add audio cell type support (GitHub #5271) + +### Patches + +- fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752) +- fix: preserve tree checkbox state after row drag reorder (GitHub #3975) +- fix: add context menu click event typing (GitHub #4655) +- fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- fix: align frame border with short frozen rows (GitHub #5277) +- fix: keep headers after setRecords at scroll edge (GitHub #5278) + +### Updates + +- fix: render vue components in top frozen rows ## 1.26.6 Mon, 03 Aug 2026 11:18:03 GMT diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 2080a366fb..8dce5a3f5d 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -1,7 +1,10 @@ // @ts-nocheck import { createVideoCellGroup } from '../../src/scenegraph/group-creater/cell-type/video-cell'; +import { audioIconSvg, createAudioCellGroup } from '../../src/scenegraph/group-creater/cell-type/audio-cell'; +import { createImageCellGroup } from '../../src/scenegraph/group-creater/cell-type/image-cell'; import { updateImageCellContentWhileResize } from '../../src/scenegraph/group-creater/cell-type/image-cell'; -import { registerForVrender } from '../../src/vrender'; +import { Group } from '../../src/scenegraph/graphic/group'; +import { application, createImage, registerForVrender } from '../../src/vrender'; import * as icons from '../../src/icons'; global.__VERSION__ = 'none'; @@ -10,16 +13,16 @@ registerForVrender(); describe('video cell first frame snapshot', () => { const originalCreateElement = document.createElement.bind(document); - let createdVideo: HTMLVideoElement; + let createdVideo: HTMLVideoElement | undefined; let drawImage: jest.Mock; - function createTable(customConfig?: Record) { + function createTable(customConfig?: Record, value = 'https://example.com/video.mp4') { return { options: { customConfig }, _getCellStyle: jest.fn(() => ({})), - getCellValue: jest.fn(() => 'https://example.com/video.mp4'), + getCellValue: jest.fn(() => value), colCount: 1, rowCount: 1, theme: { @@ -35,8 +38,12 @@ describe('video cell first frame snapshot', () => { }; } - function createCell(customConfig?: Record, size = { width: 200, height: 120 }) { - const table = createTable(customConfig); + function createCell( + customConfig?: Record, + size = { width: 200, height: 120 }, + value = 'https://example.com/video.mp4' + ) { + const table = createTable(customConfig, value); const cellGroup = createVideoCellGroup( undefined, 0, @@ -70,6 +77,16 @@ describe('video cell first frame snapshot', () => { } beforeEach(() => { + createdVideo = undefined; + (application.global as any).loadSvg = jest.fn(() => + Promise.resolve({ + data: { + width: 24, + height: 24 + } + }) + ); + (application.global as any).getRequestAnimationFrame = () => (cb: FrameRequestCallback) => cb(0); drawImage = jest.fn(); jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage @@ -116,6 +133,288 @@ describe('video cell first frame snapshot', () => { expect(video.load).not.toHaveBeenCalled(); }); + it('renders an audio placeholder when a video cell receives an audio url', () => { + const { image, video } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/audio.mp3'); + + expect(video).toBeUndefined(); + expect(image.attribute.image).toBe(audioIconSvg); + expect(image.attribute.width).toBe(32); + expect(image.attribute.height).toBe(32); + }); + + it('uses the resolved async value instead of rereading a stale table value', () => { + const table = createTable(undefined, 'https://example.com/stale-video.mp4'); + const cellGroup = createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + false, + 'https://example.com/resolved-audio.mp3' + ); + + const image = cellGroup.getChildByName('image', true); + expect(createdVideo).toBeUndefined(); + expect(image.attribute.image).toBe(audioIconSvg); + }); + + it('keeps ogg in a video cell as video because the container can carry video', () => { + const { image, video } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.ogg'); + + expect(video).toBeDefined(); + expect(image.attribute.image).toBe(video); + }); + + it('keeps custom icons named image or play-icon when reusing a video cell for audio', () => { + const { cellGroup, table } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.mp4'); + const customImageIcon = createImage({ x: 1, y: 1, width: 10, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 2, y: 2, width: 10, height: 10, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + + expect(children).toContain(customImageIcon); + expect(children).toContain(customPlayIcon); + expect(children.filter(child => child.name === 'image')).toHaveLength(2); + expect(children.filter(child => child.name === 'play-icon')).toHaveLength(1); + }); + + it('releases the old video resource when reusing a video cell for audio', () => { + const { cellGroup, table, video } = createCell( + undefined, + { width: 200, height: 120 }, + 'https://example.com/video.mp4' + ); + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + expect(video.pause).toHaveBeenCalledTimes(1); + expect(video.hasAttribute('src')).toBe(false); + expect(video.load).toHaveBeenCalledTimes(1); + }); + + it('clears stale icon width when reusing a video cell without icons', () => { + const { cellGroup, table } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.mp4'); + (cellGroup as any)._cellLeftIconWidth = 24; + (cellGroup as any)._cellRightIconWidth = 16; + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 40, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + expect((cellGroup as any)._cellLeftIconWidth).toBe(0); + expect((cellGroup as any)._cellRightIconWidth).toBe(0); + expect(cellGroup.getChildByName('image', true).attribute.width).toBe(32); + }); + + it('resizes an image cell media node without touching custom icons with media names', () => { + const table = createTable(undefined, ''); + const cellGroup = new Group({ + x: 0, + y: 0, + width: 200, + height: 120 + }); + cellGroup.role = 'cell'; + cellGroup.col = 0; + cellGroup.row = 0; + const customImageIcon = createImage({ x: 7, y: 8, width: 9, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 11, y: 12, width: 13, height: 14, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + table.scenegraph.getCell.mockReturnValue(cellGroup); + + createImageCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + const mediaImage = children.find(child => child.name === 'image' && child !== customImageIcon); + mediaImage.resources.set(mediaImage.attribute.image, { + state: 'success', + data: { + width: 20, + height: 10 + } + }); + cellGroup.setAttribute('width', 260); + updateImageCellContentWhileResize(cellGroup, 0, 0, 60, 0, table); + + expect(mediaImage.attribute.width).toBe(260); + expect(mediaImage.attribute.height).toBe(120); + expect(customImageIcon.attribute).toMatchObject({ x: 7, y: 8, width: 9, height: 10 }); + expect(customPlayIcon.attribute).toMatchObject({ x: 11, y: 12, width: 13, height: 14 }); + }); + + it('resizes an audio cell media node without touching custom icons with media names', () => { + const table = createTable(undefined, 'https://example.com/audio.mp3'); + const cellGroup = new Group({ + x: 0, + y: 0, + width: 200, + height: 120 + }); + cellGroup.role = 'cell'; + cellGroup.col = 0; + cellGroup.row = 0; + const customImageIcon = createImage({ x: 7, y: 8, width: 9, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 11, y: 12, width: 13, height: 14, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + table.scenegraph.getCell.mockReturnValue(cellGroup); + + createAudioCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + const mediaImage = children.find(child => child.name === 'image' && child !== customImageIcon); + cellGroup.setAttribute('width', 260); + updateImageCellContentWhileResize(cellGroup, 0, 0, 60, 0, table); + + expect(mediaImage.attribute.width).toBe(32); + expect(mediaImage.attribute.height).toBe(32); + expect(customImageIcon.attribute).toMatchObject({ x: 7, y: 8, width: 9, height: 10 }); + expect(customPlayIcon.attribute).toMatchObject({ x: 11, y: 12, width: 13, height: 14 }); + }); + it('uses a canvas snapshot and releases the video when enabled', () => { const { image, table, video } = createCell({ videoFirstFrameSnapshot: true, @@ -150,6 +449,32 @@ describe('video cell first frame snapshot', () => { expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); }); + it('shows the damage image instead of drawing a zero-size video frame', () => { + const { image, table, video } = createCell({ + videoFirstFrameSnapshot: true + }); + Object.defineProperties(video, { + videoWidth: { + value: 0, + configurable: true + }, + videoHeight: { + value: 0, + configurable: true + } + }); + const regedIcons = icons.get(); + const damageImage = regedIcons.video_damage_pic + ? (regedIcons.video_damage_pic as any).svg + : (regedIcons.damage_pic as any).svg; + + video.dispatchEvent(new Event('loadeddata')); + + expect(drawImage).not.toHaveBeenCalled(); + expect(image.attribute.image).toBe(damageImage); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); + }); + it('keeps the video damage image aspect ratio immediately after load error', () => { const { image, video } = createCell( { diff --git a/packages/vtable/__tests__/components/listTable-tooltip.test.ts b/packages/vtable/__tests__/components/listTable-tooltip.test.ts index f378fd162a..7d25ff7af9 100644 --- a/packages/vtable/__tests__/components/listTable-tooltip.test.ts +++ b/packages/vtable/__tests__/components/listTable-tooltip.test.ts @@ -110,6 +110,43 @@ describe('listTable-tooltip init test', () => { } }); }); + test('listTable-tooltip supports appearDelay', async () => { + const col = 0; + const row = 2; + const rect = listTable.getVisibleCellRangeRelativeRect({ col, row }); + + listTable.internalProps.tooltipHandler._unbindFromCell(); + listTable.showTooltip(col, row, { + content: 'Order ID:' + listTable.getCellValue(col, row), + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + appearDelay: 30 + }); + + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(listTable.internalProps.tooltipHandler._attachInfo.range).toStrictEqual({ + start: { + col: 0, + row: 2 + }, + end: { + col: 0, + row: 2 + } + }); + + listTable.internalProps.tooltipHandler._unbindFromCell(); + listTable.showTooltip(col, row, { + content: 'Order ID:' + listTable.getCellValue(col, row), + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + appearDelay: 30 + }); + listTable.internalProps.tooltipHandler._unbindFromCell(); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + }); // setTimeout(() => { // listTable.release(); // }, 1000); diff --git a/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts b/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts new file mode 100644 index 0000000000..92ff44412a --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv } from '../dom'; + +global.__VERSION__ = 'none'; + +describe('listTable setRecords with horizontal scroll', () => { + test('keeps visible headers after setRecords clears data at right scroll edge', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '900px'; + containerDom.style.height = '420px'; + + const columns = Array.from({ length: 60 }, (_, index) => ({ + field: `field${index}`, + title: `Field ${index}`, + width: 120 + })); + const records = Array.from({ length: 6 }, (_, rowIndex) => { + const record: Record = {}; + for (let colIndex = 0; colIndex < columns.length; colIndex++) { + record[`field${colIndex}`] = rowIndex * columns.length + colIndex; + } + return record; + }); + + const table = new ListTable(containerDom, { + records, + frozenRowCount: 3, + bottomFrozenRowCount: 1, + frozenColCount: 2, + rightFrozenColCount: 2, + columns, + widthMode: 'standard', + maintainedColumnCount: 20, + rowSeriesNumber: { + width: 50, + format: () => '', + cellType: 'checkbox', + headerType: 'checkbox' + } + }); + + table.setScrollLeft(100000); + expect(table.getScrollLeft()).toBeGreaterThan(0); + + table.setRecords([]); + + const visibleHeader = table.getCellAtRelativePosition(500, 20); + const headerCell = table.scenegraph.getCell(visibleHeader.col, visibleHeader.row); + const frozenHeader = table.getCellAtRelativePosition(80, 20); + const frozenHeaderCell = table.scenegraph.getCell(frozenHeader.col, frozenHeader.row); + + expect(table.records.length).toBe(0); + expect(visibleHeader.row).toBe(0); + expect(table.getCellValue(visibleHeader.col, visibleHeader.row)).toBeTruthy(); + expect(headerCell.children[0].attribute.text).toBe(table.getCellValue(visibleHeader.col, visibleHeader.row)); + expect(frozenHeader.col).toBe(1); + expect(frozenHeader.row).toBe(0); + expect(frozenHeaderCell.children[0].attribute.text).toBe('Field 0'); + expect(table.scenegraph.cornerHeaderGroup.attribute.width).toBe(table.getFrozenColsWidth()); + expect(table.scenegraph.colHeaderGroup.attribute.x + table.getScrollLeft()).toBe(table.getFrozenColsWidth()); + + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts index 6cfa42c657..3d1fb90e11 100644 --- a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts +++ b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts @@ -1,5 +1,7 @@ // @ts-nocheck import { ListTable, TYPES } from '../src'; +import { changeCheckboxOrder } from '../src/state/checkbox/checkbox'; +import { changeRadioOrder } from '../src/state/radio/radio'; import { createDiv } from './dom'; global.__VERSION__ = 'none'; @@ -160,4 +162,134 @@ describe('ListTable checkbox record index api', () => { expect(table.stateManager.checkedState.get('0,0').task).toBe(false); expect(table.stateManager.checkedState.get('0,1').task).toBe(false); }); + + test('moves checkbox state for a tree subtree by record path', () => { + const checkedState = new Map([ + ['0', { task: 'source' }], + ['0,0', { task: 'source-child' }], + ['1', { task: 'sibling-1' }], + ['1,0', { task: 'sibling-1-child' }], + ['2', { task: 'sibling-2' }] + ]); + + changeCheckboxOrder(0, 2, { checkedState }); + + expect(checkedState.get('2').task).toBe('source'); + expect(checkedState.get('2,0').task).toBe('source-child'); + expect(checkedState.get('0').task).toBe('sibling-1'); + expect(checkedState.get('0,0').task).toBe('sibling-1-child'); + expect(checkedState.get('1').task).toBe('sibling-2'); + expect(checkedState.size).toBe(5); + }); + + test('moves nested checkbox state without matching sibling prefix numbers', () => { + const checkedState = new Map([ + ['0,1', { task: 'source' }], + ['0,1,0', { task: 'source-child' }], + ['0,2', { task: 'sibling-2' }], + ['0,10', { task: 'sibling-10' }], + ['0,10,0', { task: 'sibling-10-child' }] + ]); + + changeCheckboxOrder([0, 1], [0, 2], { checkedState }); + + expect(checkedState.get('0,2').task).toBe('source'); + expect(checkedState.get('0,2,0').task).toBe('source-child'); + expect(checkedState.get('0,1').task).toBe('sibling-2'); + expect(checkedState.get('0,10').task).toBe('sibling-10'); + expect(checkedState.get('0,10,0').task).toBe('sibling-10-child'); + expect(checkedState.size).toBe(5); + }); + + test('keeps checkbox state unchanged for unsupported cross-parent row moves', () => { + const checkedState = new Map([ + ['0,0', { task: 'source' }], + ['1,0', { task: 'target-parent-child' }] + ]); + + changeCheckboxOrder([0, 0], [1, 0], { checkedState }); + + expect(checkedState.get('0,0').task).toBe('source'); + expect(checkedState.get('1,0').task).toBe('target-parent-child'); + }); + + test('moves flat radio state by record path', () => { + const radioState = { + columnRadio: 0, + cellRadio: { + 0: 0, + 1: 1, + 2: 2 + } + }; + + changeRadioOrder(0, 2, { radioState }); + + expect(radioState.columnRadio).toBe(2); + expect(radioState.cellRadio[2]).toBe(0); + expect(radioState.cellRadio[0]).toBe(1); + expect(radioState.cellRadio[1]).toBe(2); + }); + + test('moves nested radio state without matching sibling prefix numbers', () => { + const radioState = { + cellRadio: { + '0,1': true, + '0,1,0': 1, + '0,2': true, + '0,10': true + } + }; + + changeRadioOrder([0, 1], [0, 2], { radioState }); + + expect(radioState.cellRadio['0,2']).toBe(true); + expect(radioState.cellRadio['0,2,0']).toBe(1); + expect(radioState.cellRadio['0,1']).toBe(true); + expect(radioState.cellRadio['0,10']).toBe(true); + }); + + test('moves serialized radio record path state', () => { + const radioState = { + columnRadio: '0,1' + }; + + changeRadioOrder([0, 1], [0, 2], { radioState }); + + expect(radioState.columnRadio).toBe('0,2'); + }); + + test('keeps radio state unchanged for unsupported cross-parent row moves', () => { + const radioState = { + cellRadio: { + '0,0': 1, + '1,0': 2 + } + }; + + changeRadioOrder([0, 0], [1, 0], { radioState }); + + expect(radioState.cellRadio['0,0']).toBe(1); + expect(radioState.cellRadio['1,0']).toBe(2); + }); + + test('StateManager moves radio state stored as an object', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'choice', + title: 'Choice', + cellType: 'radio', + width: 120 + } + ], + records: [{ choice: true }, { choice: false }, { choice: false }] + }); + table.stateManager.radioState = { choice: 0 }; + + table.stateManager.changeRadioOrder(0, 2); + + expect(table.stateManager.radioState.choice).toBe(2); + }); }); diff --git a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts index c6081a0e07..29b93d49ca 100644 --- a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts +++ b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts @@ -305,6 +305,41 @@ describe('listTable init test', () => { frozenTable.release(); }); + test('listTable frame border should match short content with frozen rows', () => { + const optionWithShortFrozenRows = { + ...option, + columns: columns.slice(0, 5).map(column => ({ + ...column, + width: 180 + })), + frozenColCount: 0, + rightFrozenColCount: 0, + frozenRowCount: 3, + bottomFrozenRowCount: 1, + container: createDiv(), + records: records.slice(0, 6), + widthMode: 'standard', + theme: { + frameStyle: { + borderLineWidth: [1, 1, 1, 1], + borderColor: 'red' + } + } + }; + optionWithShortFrozenRows.container.style.position = 'relative'; + optionWithShortFrozenRows.container.style.width = '900px'; + optionWithShortFrozenRows.container.style.height = '500px'; + + const frozenTable = new ListTable(optionWithShortFrozenRows); + const { scenegraph } = frozenTable; + const contentBottom = scenegraph.bottomFrozenGroup.attribute.y + scenegraph.bottomFrozenGroup.attribute.height; + + expect(scenegraph.tableGroup.attribute.height).toBe(contentBottom); + expect(scenegraph.tableGroup.border.attribute.height).toBe(contentBottom + 1); + + frozenTable.release(); + }); + test('listTable should support decreasing rightFrozenColCount by setter with row series number', () => { const optionWithRightFrozen = { ...option, diff --git a/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts new file mode 100644 index 0000000000..3e41f3a705 --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts @@ -0,0 +1,83 @@ +import { ListTable, data } from '../../src'; +import { createDiv, removeDom } from '../dom'; + +(global as any).__VERSION__ = 'none'; + +describe('ListTable customRender with lazy dataSource', () => { + let containerDom: HTMLElement; + + beforeEach(() => { + containerDom = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '300px'; + containerDom.style.height = '200px'; + }); + + afterEach(() => { + removeDom(containerDom); + }); + + test('does not load all lazy records when customRender reads value during auto size computation', () => { + const loadedIndexes = new Set(); + let computationBodyValueCount = 0; + const recordsLength = 1000; + const lazyDataSource = new data.CachedDataSource({ + get(index: number) { + loadedIndexes.add(index); + return { + icon: `https://example.com/${index}.svg`, + name: `name-${index}` + }; + }, + length: recordsLength + }); + + const table = new ListTable(containerDom, { + dataSource: lazyDataSource, + columns: [ + { + field: 'icon', + title: 'Icon', + width: 'auto' + }, + { + field: 'name', + title: 'Name', + width: 120 + } + ], + heightMode: 'autoHeight', + limitMaxAutoWidth: 600, + customRender(args) { + const { col, row, value, forComputation } = args; + if (row === 0 || col !== 0) { + return null; + } + if (forComputation && value !== undefined) { + computationBodyValueCount++; + } + return { + renderDefault: false, + expectedHeight: 40, + expectedWidth: 120, + elements: [ + { + type: 'image', + src: value, + width: 20, + height: 20, + x: 35, + y: 10 + } + ] + }; + } + }); + + expect(computationBodyValueCount).toBe(0); + expect(loadedIndexes.has(recordsLength - 1)).toBe(false); + expect(loadedIndexes.size).toBeLessThan(recordsLength); + + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts b/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts new file mode 100644 index 0000000000..630ac75045 --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts @@ -0,0 +1,139 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv, removeDom } from '../dom'; +import { handleTextStick } from '../../src/scenegraph/stick-text'; + +global.__VERSION__ = 'none'; + +const columns = [ + { + field: 'category', + title: '类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + }, + { + field: 'subcategory', + title: '子类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + } +]; + +function createRecords(count: number, mergeStartRow: number, mergeEndRow: number) { + return Array.from({ length: count }, (_, index) => ({ + category: index + 1 >= mergeStartRow && index + 1 <= mergeEndRow ? '办公用品' : `类别 ${index}`, + subcategory: index < Math.min(883, count) ? '装订机' : '美术' + })); +} + +function createTable(mergeStartRow: number, mergeEndRow: number, minSingleRowHeight?: number) { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '400px'; + containerDom.style.height = '500px'; + + const table = new ListTable({ + container: containerDom, + records: createRecords(1000, mergeStartRow, mergeEndRow), + columns, + widthMode: 'standard', + heightMode: 'autoHeight', + autoWrapText: false, + showHeader: true, + customConfig: { + minSingleRowHeight + } + }); + + return { table, containerDom }; +} + +describe('listTable large merge auto row height', () => { + test('keeps a default minimum row height for large merged cells', () => { + const { table, containerDom } = createTable(1, 1000); + + expect(table.getRowHeight(1)).toBeGreaterThanOrEqual(2); + + table.release(); + removeDom(containerDom); + }); + + test('respects positive custom minSingleRowHeight for large merged cells', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + expect(table.getRowHeight(1)).toBeGreaterThanOrEqual(5); + + table.release(); + removeDom(containerDom); + }); + + test('handles merged cells that do not start from the first body row', () => { + const { table, containerDom } = createTable(10, 900, 4); + + expect(table.getRowHeight(10)).toBeGreaterThanOrEqual(4); + + table.release(); + removeDom(containerDom); + }); + + test('keeps text stick visible while scrolling large merged cells', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + table.setScrollTop(3000); + handleTextStick(table); + const { rowStart, rowEnd } = table.scenegraph.proxy; + const visibleText = []; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(0, row); + const text = cellGroup.getChildByName('text', true); + if (text.globalAABBBounds.y1 >= 0 && text.globalAABBBounds.y2 <= 500) { + visibleText.push(text); + } + } + + expect(visibleText.length).toBeGreaterThan(0); + + table.release(); + removeDom(containerDom); + }); + + test('keeps maintained rows covering viewport after deep scrolling tiny rows', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + table.setScrollTop(4500); + const { rowStart, rowEnd } = table.scenegraph.proxy; + let maxCellBottom = -Infinity; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(0, row); + maxCellBottom = Math.max(maxCellBottom, cellGroup.globalAABBBounds.y2); + } + + expect(maxCellBottom).toBeGreaterThanOrEqual(table.scenegraph.tableGroup.attribute.height); + + table.release(); + removeDom(containerDom); + }); +}); diff --git a/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts b/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts new file mode 100644 index 0000000000..37e5eb5581 --- /dev/null +++ b/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts @@ -0,0 +1,100 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const MERGE_START_ROW = 1; +const MERGE_END_ROW = 1000; + +const records = Array.from({ length: 1000 }, (_, index) => ({ + category: index + 1 >= MERGE_START_ROW && index + 1 <= MERGE_END_ROW ? '办公用品' : `类别 ${index}`, + subcategory: index < 883 ? '装订机' : '美术' +})); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + document.getElementById('issue3752Toolbar')?.remove(); + container.style.width = '720px'; + container.style.height = '480px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue3752Toolbar'; + toolbar.style.cssText = 'height: 44px; font-size: 12px; display: flex; gap: 12px; align-items: center;'; + toolbar.innerHTML = ''; + container.before(toolbar); + + const option: VTable.ListTableConstructorOptions = { + container, + records, + columns: [ + { + field: 'category', + title: '类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + }, + { + field: 'subcategory', + title: '子类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + } + ], + widthMode: 'standard', + heightMode: 'autoHeight', + autoWrapText: false, + showHeader: true, + customConfig: { + minSingleRowHeight: 5 + } + }; + + const tableInstance = new VTable.ListTable(option); + + const check = () => { + const rowHeight = tableInstance.getRowHeight(MERGE_START_ROW); + const { rowStart, rowEnd } = tableInstance.scenegraph.proxy; + let maxCellBottom = -Infinity; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = tableInstance.scenegraph.getCell(0, row); + maxCellBottom = Math.max(maxCellBottom, cellGroup.globalAABBBounds.y2); + } + const tableHeight = tableInstance.scenegraph.tableGroup.attribute.height; + const pass = rowHeight >= 5 && maxCellBottom >= tableHeight; + const state = document.getElementById('issue3752State')!; + const roundedBottom = Math.round(maxCellBottom); + const roundedTableHeight = Math.round(tableHeight); + state.textContent = + `${pass ? 'PASS' : 'FAIL'} | rowHeight=${rowHeight} | scrollTop=${tableInstance.getScrollTop()}` + + ` | rows=${rowStart}-${rowEnd} | bottom=${roundedBottom}/${roundedTableHeight}`; + return { pass, rowHeight, scrollTop: tableInstance.getScrollTop(), rowStart, rowEnd, maxCellBottom, tableHeight }; + }; + + document.getElementById('issue3752Check')!.addEventListener('click', check); + setTimeout(check, 0); + + (window as any).tableInstance = tableInstance; + (window as any).issue3752Check = check; +} + +createTable(); diff --git a/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts new file mode 100644 index 0000000000..7a5a6a92c4 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts @@ -0,0 +1,120 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const RECORD_COUNT = 5000; + +function createIconDataUrl(index: number) { + const color = index % 2 === 0 ? '#1664ff' : '#00a870'; + const svg = ` + + ${index % 10} + `; + + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +function createRecord(index: number) { + return { + icon: createIconDataUrl(index), + name: `name-${index}`, + desc: `row ${index}` + }; +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '800px'; + container.style.height = '500px'; + + const status = document.createElement('div'); + status.style.cssText = 'height: 40px; line-height: 20px; font-size: 13px; color: #333;'; + container.parentElement?.insertBefore(status, container); + + const loadedIndexes = new Set(); + let computationValueCount = 0; + let customRenderCallCount = 0; + + const updateStatus = () => { + const loadedRows = loadedIndexes.size; + const lastRowLoaded = loadedIndexes.has(RECORD_COUNT - 1); + status.innerHTML = [ + `loaded rows: ${loadedRows}/${RECORD_COUNT}, last row loaded: ${lastRowLoaded}`, + `customRender calls: ${customRenderCallCount}, computation value count: ${computationValueCount}` + ].join('
'); + }; + + const dataSource = new VTable.data.CachedDataSource({ + get(index: number) { + loadedIndexes.add(index); + return createRecord(index); + }, + length: RECORD_COUNT + }); + + const option: VTable.ListTableConstructorOptions = { + container, + dataSource, + columns: [ + { + field: 'icon', + title: 'Icon', + width: 'auto' + }, + { + field: 'name', + title: 'Name', + width: 160 + }, + { + field: 'desc', + title: 'Description', + width: 220 + } + ], + heightMode: 'autoHeight', + limitMaxAutoWidth: 600, + customRender(args) { + const { col, row, value, forComputation } = args; + customRenderCallCount++; + + if (row === 0 || col !== 0) { + return null; + } + + if (forComputation && value !== undefined) { + computationValueCount++; + } + + return { + renderDefault: false, + expectedHeight: 40, + expectedWidth: 120, + elements: [ + { + type: 'image', + src: value, + width: 20, + height: 20, + x: 35, + y: 10 + } + ] + }; + } + }; + + const tableInstance = new VTable.ListTable(option); + (window as any).tableInstance = tableInstance; + (window as any).issue4964Status = { + loadedIndexes, + get loadedRows() { + return loadedIndexes.size; + }, + get computationValueCount() { + return computationValueCount; + } + }; + + updateStatus(); + setTimeout(updateStatus, 0); +} diff --git a/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts b/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts new file mode 100644 index 0000000000..902d03d89a --- /dev/null +++ b/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts @@ -0,0 +1,62 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const records = [ + { orderId: 'A001', product: 'Laptop', price: 1200 }, + { orderId: 'A002', product: 'Keyboard', price: 120 }, + { orderId: 'A003', product: 'Mouse', price: 80 } + ]; + + const columns: VTable.ColumnsDefine = [ + { + field: 'orderId', + title: 'Order ID', + width: 160 + }, + { + field: 'product', + title: 'Product', + width: 180 + }, + { + field: 'price', + title: 'Price', + width: 120 + } + ]; + + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, { + records, + columns, + defaultRowHeight: 40, + tooltip: { + renderMode: 'html' + } + }); + + tableInstance.on('mouseenter_cell', args => { + const { col, row } = args; + if (col !== 0 || row < tableInstance.columnHeaderLevelCount) { + return; + } + + const rect = tableInstance.getVisibleCellRangeRelativeRect({ col, row }); + tableInstance.showTooltip(col, row, { + content: `showTooltip appearDelay=1000ms: ${tableInstance.getCellValue(col, row)}`, + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + className: 'defineTooltip', + appearDelay: 1000, + disappearDelay: 100, + style: { + bgColor: '#202328', + color: '#fff', + padding: [8, 12], + arrowMark: true + } + }); + }); + + (window as any).tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts b/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts new file mode 100644 index 0000000000..96edd50209 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts @@ -0,0 +1,47 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const generateRecords = (count: number) => + Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Name ${index + 1}`, + city: ['Beijing', 'Shanghai', 'Shenzhen', 'Hangzhou', 'Chengdu', 'Wuhan'][index % 6], + sales: 1000 + index * 100, + profit: 120 + index * 10 + })); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + container.style.width = '900px'; + container.style.height = '500px'; + + const columns: VTable.ColumnsDefine = [ + { field: 'id', title: 'ID', width: 120 }, + { field: 'name', title: 'Name', width: 180 }, + { field: 'city', title: 'City', width: 180 }, + { field: 'sales', title: 'Sales', width: 180 }, + { field: 'profit', title: 'Profit', width: 180 } + ]; + + const option: VTable.ListTableConstructorOptions = { + container, + records: generateRecords(6), + frozenRowCount: 3, + bottomFrozenRowCount: 1, + columns, + widthMode: 'standard', + theme: { + frameStyle: { + borderLineWidth: [1, 1, 1, 1], + borderColor: 'red' + } + } + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts b/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts new file mode 100644 index 0000000000..3e7f827f36 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts @@ -0,0 +1,137 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const TOOLBAR_ID = 'issue5278Toolbar'; +const STATUS_ID = 'issue5278Status'; + +const baseColumns = [ + 'Order ID', + 'Customer ID', + 'Product Name', + 'Category', + 'Sub-Category', + 'Region', + 'City', + 'Order Date', + 'Quantity', + 'Sales', + 'Profit' +]; + +const records = Array.from({ length: 6 }, (_, index) => { + const record: Record = { + 'Order ID': `CA-2026-${1000 + index}`, + 'Customer ID': `C-${100 + index}`, + 'Product Name': ['Bookcase', 'Chair', 'Table', 'Phone', 'Binder', 'Storage'][index], + Category: ['Furniture', 'Technology', 'Office Supplies'][index % 3], + 'Sub-Category': ['Bookcases', 'Chairs', 'Tables', 'Phones', 'Binders', 'Storage'][index], + Region: ['East', 'West', 'Central'][index % 3], + City: ['New York', 'Seattle', 'Chicago', 'Boston', 'Austin', 'Denver'][index], + 'Order Date': `2026-01-${String(index + 1).padStart(2, '0')}`, + Quantity: index + 1, + Sales: 100 + index * 20, + Profit: 10 + index * 5 + }; + + for (let col = 0; col < 300; col++) { + record[`Sales${col}`] = 1000 + index * 300 + col; + } + + return record; +}); + +const columns: VTable.ColumnsDefine = [ + ...baseColumns.map(field => ({ + field, + title: field, + width: 'auto' + })), + ...Array.from({ length: 300 }, (_, index) => ({ + field: `Sales${index}`, + title: `Sales${index}`, + width: 120 + })) +]; + +const setStatus = (message: string) => { + const status = document.getElementById(STATUS_ID); + if (status) { + status.textContent = message; + } +}; + +export function createTable() { + document.getElementById(TOOLBAR_ID)?.remove(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '900px'; + container.style.height = '420px'; + + const toolbar = document.createElement('div'); + toolbar.id = TOOLBAR_ID; + toolbar.style.cssText = ['display: flex', 'gap: 8px', 'align-items: center', 'height: 40px', 'font-size: 12px'].join( + ';' + ); + + const runButton = document.createElement('button'); + runButton.id = 'issue5278Run'; + runButton.textContent = 'scroll right + setRecords([])'; + const resetButton = document.createElement('button'); + resetButton.id = 'issue5278Reset'; + resetButton.textContent = 'reset records'; + const status = document.createElement('strong'); + status.id = STATUS_ID; + + toolbar.append(runButton, resetButton, status); + container.before(toolbar); + + const tableInstance = new VTable.ListTable(container, { + records: records.slice(), + frozenRowCount: 3, + bottomFrozenRowCount: 1, + frozenColCount: 2, + rightFrozenColCount: 2, + columns, + widthMode: 'standard', + rowSeriesNumber: { + width: 50, + format: () => '', + cellType: 'checkbox', + headerType: 'checkbox' + } + }); + + const scrollToRight = () => { + tableInstance.setScrollLeft(100000); + }; + + const run = () => { + scrollToRight(); + tableInstance.setRecords([]); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }; + + const reset = () => { + tableInstance.setRecords(records.slice()); + scrollToRight(); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }; + + runButton.addEventListener('click', run); + resetButton.addEventListener('click', reset); + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + document.getElementById(TOOLBAR_ID)?.remove(); + release(); + }; + + (window as any).tableInstance = tableInstance; + (window as any).issue5278Run = run; + (window as any).issue5278Reset = reset; + + requestAnimationFrame(() => { + scrollToRight(); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }); +} diff --git a/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts b/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts new file mode 100644 index 0000000000..dcb188cd2b --- /dev/null +++ b/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts @@ -0,0 +1,171 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const CHECKBOX_FIELD = 'task'; + +function getRecordByPath(records: any[], path: number[]) { + let current: any = records; + for (let i = 0; i < path.length; i++) { + current = current?.[path[i]]; + if (i < path.length - 1) { + current = current?.children; + } + } + return current; +} + +function getTaskText(record: any): string { + return typeof record?.task === 'object' ? record.task.text : record?.task; +} + +function getChecked(table: VTable.ListTable, path: number[]) { + return table.stateManager.checkedState.get(path.toString())?.[CHECKBOX_FIELD]; +} + +function createRecords() { + return [ + { + task: 'Project A', + owner: 'Alice', + status: 'checked before move', + hierarchyState: VTable.TYPES.HierarchyState.expand, + children: [ + { + task: 'Task A-1', + owner: 'Bob', + status: 'checked before move' + }, + { + task: 'Task A-2', + owner: 'Cindy', + status: 'unchecked' + } + ] + }, + { + task: 'Project B', + owner: 'David', + status: 'unchecked' + }, + { + task: 'Project C', + owner: 'Emily', + status: 'unchecked' + } + ]; +} + +export function createTable() { + const records = createRecords(); + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.width = '1000px'; + container.style.height = '520px'; + } + + const option: VTable.ListTableConstructorOptions = { + container, + records, + rowSeriesNumber: { + title: '', + dragOrder: true, + width: 46 + }, + columns: [ + { + field: CHECKBOX_FIELD, + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox', + width: 260 + }, + { field: 'owner', title: 'Owner', width: 120 }, + { field: 'status', title: 'Status', width: 220 } + ], + defaultRowHeight: 38, + hierarchyIndent: 20, + hierarchyExpandLevel: 2, + enableCheckboxCascade: false, + enableHeaderCheckboxCascade: false, + theme: VTable.themes.BRIGHT + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; + + tableInstance.setCellCheckboxStateByRecordIndex(0, CHECKBOX_FIELD, true); + tableInstance.setCellCheckboxStateByRecordIndex([0, 0], CHECKBOX_FIELD, true); + + const toolbar = document.createElement('div'); + toolbar.style.cssText = [ + 'position:absolute', + 'top:8px', + 'left:8px', + 'right:8px', + 'z-index:10', + 'display:flex', + 'align-items:flex-start', + 'gap:8px', + 'font:12px sans-serif' + ].join(';'); + + const moveButton = document.createElement('button'); + moveButton.textContent = 'Move Project A after Project C'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset'; + + const status = document.createElement('pre'); + status.style.cssText = [ + 'margin:0', + 'padding:8px 10px', + 'background:rgba(255,255,255,0.92)', + 'border:1px solid #d0d7de', + 'border-radius:4px', + 'line-height:1.5', + 'min-width:520px' + ].join(';'); + + const renderStatus = (label: string) => { + const data = tableInstance.records as any[]; + const projectAAt2 = getTaskText(getRecordByPath(data, [2])) === 'Project A'; + const taskA1At20 = getTaskText(getRecordByPath(data, [2, 0])) === 'Task A-1'; + const projectAChecked = getChecked(tableInstance, [2]) === true; + const taskA1Checked = getChecked(tableInstance, [2, 0]) === true; + const staleChildState = tableInstance.stateManager.checkedState.get('0,0')?.[CHECKBOX_FIELD] === true; + + status.textContent = [ + label, + `record order: ${data.map(record => getTaskText(record)).join(' -> ')}`, + `Project A at [2]: ${projectAAt2 ? 'yes' : 'no'}, checked: ${projectAChecked ? 'yes' : 'no'}`, + `Task A-1 at [2,0]: ${taskA1At20 ? 'yes' : 'no'}, checked: ${taskA1Checked ? 'yes' : 'no'}`, + `stale checkedState at old [0,0]: ${staleChildState ? 'yes' : 'no'}`, + `result: ${projectAAt2 && taskA1At20 && projectAChecked && taskA1Checked && !staleChildState ? 'fixed' : 'repro'}` + ].join('\n'); + }; + + moveButton.onclick = () => { + tableInstance.changeHeaderPosition({ + source: { col: 0, row: 1 }, + target: { col: 0, row: 5 }, + movingColumnOrRow: 'row' + }); + renderStatus('after moving Project A from [0] to [2]'); + }; + + resetButton.onclick = () => { + tableInstance.release(); + toolbar.remove(); + createTable(); + }; + + toolbar.appendChild(moveButton); + toolbar.appendChild(resetButton); + toolbar.appendChild(status); + document.body.appendChild(toolbar); + + renderStatus('before move: Project A and Task A-1 are checked'); + + return tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6fcdf4ccf7..6a43a63cee 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -62,6 +62,18 @@ export const menus = [ path: 'debug', name: 'issue-5213-row-series-number-aggregation' }, + { + path: 'debug', + name: 'issue-5278-set-records-header-blank' + }, + { + path: 'debug', + name: 'issue-5277-frozen-row-border' + }, + { + path: 'debug', + name: 'issue-3752-large-merge-auto-height' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' @@ -78,6 +90,10 @@ export const menus = [ path: 'debug', name: 'issue-4904-frozen-row-gap' }, + { + path: 'debug', + name: 'issue-4964-custom-render-async-value' + }, { path: 'debug', name: 'issue-4798-sort-icon-visible-time' @@ -86,6 +102,10 @@ export const menus = [ path: 'debug', name: 'issue-celltype-function-undefined' }, + { + path: 'debug', + name: 'issue-5112-tooltip-appear-delay' + }, { path: 'debug', name: 'header-frame-border-null-color' @@ -147,6 +167,10 @@ export const menus = [ path: 'list', name: 'issue-5211-tree-checkbox-record-index' }, + { + path: 'list', + name: 'issue-3975-tree-checkbox-row-move' + }, { path: 'list', name: 'list-tree-20000' @@ -628,6 +652,10 @@ export const menus = [ path: 'type', name: 'video' }, + { + path: 'type', + name: 'audio' + }, { path: 'type', name: 'progress-bar' diff --git a/packages/vtable/examples/type/audio.ts b/packages/vtable/examples/type/audio.ts new file mode 100644 index 0000000000..da11e4b2db --- /dev/null +++ b/packages/vtable/examples/type/audio.ts @@ -0,0 +1,220 @@ +import * as VTable from '../../src'; + +const ListTable = VTable.ListTable; +const CONTAINER_ID = 'vTable'; + +const audioUrls = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; +const TOOLBAR_ID = 'audio-demo-theme-toolbar'; + +type DemoTheme = 'light' | 'dark'; + +const themeMap = { + light: VTable.themes.DEFAULT.extends({ + underlayBackgroundColor: '#ffffff', + defaultStyle: { + bgColor: '#ffffff', + color: '#111827', + borderColor: '#E5E7EB' + }, + headerStyle: { + bgColor: '#F3F6F9', + color: '#111827', + borderColor: '#E5E7EB' + }, + bodyStyle: { + bgColor: '#ffffff', + color: '#111827', + borderColor: '#E5E7EB' + }, + frameStyle: { + borderColor: '#E5E7EB', + borderLineWidth: 1 + }, + selectionStyle: { + cellBorderColor: '#2563EB', + cellBorderLineWidth: 3, + cellBgColor: 'rgba(37, 99, 235, 0.08)' + } + }), + dark: VTable.themes.DEFAULT.extends({ + underlayBackgroundColor: '#111827', + defaultStyle: { + bgColor: '#111827', + color: '#E5E7EB', + borderColor: '#374151' + }, + headerStyle: { + bgColor: '#1F2937', + color: '#F9FAFB', + borderColor: '#374151' + }, + bodyStyle: { + bgColor: '#111827', + color: '#E5E7EB', + borderColor: '#374151' + }, + frameStyle: { + borderColor: '#374151', + borderLineWidth: 1 + }, + selectionStyle: { + cellBorderColor: '#60A5FA', + cellBorderLineWidth: 3, + cellBgColor: 'rgba(96, 165, 250, 0.18)' + } + }) +}; + +function applyPageTheme(theme: DemoTheme) { + const isDark = theme === 'dark'; + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.background = isDark ? '#111827' : '#ffffff'; + container.parentElement?.style.setProperty('background', isDark ? '#0F172A' : '#ffffff'); + } +} + +function clearPageTheme() { + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.background = ''; + container.parentElement?.style.removeProperty('background'); + } +} + +function createToolbar(instance: VTable.ListTable, currentTheme: DemoTheme) { + document.getElementById(TOOLBAR_ID)?.remove(); + + const toolbar = document.createElement('div'); + toolbar.id = TOOLBAR_ID; + toolbar.style.display = 'flex'; + toolbar.style.gap = '8px'; + toolbar.style.alignItems = 'center'; + toolbar.style.margin = '8px 0 12px 30px'; + toolbar.style.fontFamily = 'sans-serif'; + + const setActiveButton = (theme: DemoTheme) => { + toolbar.querySelectorAll('button').forEach(button => { + const active = button.dataset.theme === theme; + button.style.background = active ? '#2563EB' : '#F3F4F6'; + button.style.color = active ? '#FFFFFF' : '#111827'; + button.style.borderColor = active ? '#2563EB' : '#D1D5DB'; + }); + }; + + const switchTheme = (theme: DemoTheme) => { + currentTheme = theme; + applyPageTheme(theme); + instance.updateTheme(themeMap[theme]); + setActiveButton(currentTheme); + }; + + [ + { theme: 'light' as DemoTheme, label: '亮色主题' }, + { theme: 'dark' as DemoTheme, label: '暗黑主题' } + ].forEach(item => { + const button = document.createElement('button'); + button.dataset.theme = item.theme; + button.textContent = item.label; + button.style.height = '32px'; + button.style.padding = '0 14px'; + button.style.border = '1px solid #D1D5DB'; + button.style.borderRadius = '8px'; + button.style.cursor = 'pointer'; + button.style.fontSize = '14px'; + button.onclick = () => switchTheme(item.theme); + toolbar.appendChild(button); + }); + + const root = document.getElementById(CONTAINER_ID); + root?.parentNode?.insertBefore(toolbar, root); + setActiveButton(currentTheme); +} + +export function createTable() { + const currentTheme: DemoTheme = 'light'; + applyPageTheme(currentTheme); + + const records = [ + { + id: 1, + name: 'audio cell', + type: 'cellType: audio', + audio: audioUrls[0] + }, + { + id: 2, + name: 'video fallback', + type: 'cellType: video + audio url', + audio: audioUrls[1] + }, + { + id: 3, + name: 'audio with query', + type: 'audio url with query', + audio: `${audioUrls[2]}?from=vtable-demo` + } + ]; + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + columns: [ + { + field: 'id', + title: 'ID', + width: 80 + }, + { + field: 'name', + title: 'Name', + width: 180 + }, + { + field: 'type', + title: 'Case', + width: 220 + }, + { + field: 'audio', + title: 'Audio', + cellType: 'audio', + width: 180, + style: { + padding: 8 + } + }, + { + field: 'audio', + title: 'Video Cell Fallback', + cellType: 'video', + width: 220, + keepAspectRatio: true, + style: { + padding: 8 + } + } + ], + records, + widthMode: 'standard', + theme: themeMap[currentTheme], + hover: { + highlightMode: 'cross' + } + }; + + const instance = new ListTable(option); + createToolbar(instance, currentTheme); + + const release = instance.release.bind(instance); + instance.release = () => { + document.getElementById(TOOLBAR_ID)?.remove(); + clearPageTheme(); + release(); + }; + + window.tableInstance = instance; +} diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 8a2db09c06..db7f9bb259 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "canvas table width high performance", "keywords": [ "grid", @@ -52,7 +52,7 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.7", "@visactor/vrender": "1.1.4", "@visactor/vrender-core": "1.1.4", "@visactor/vrender-kits": "1.1.4", @@ -84,6 +84,7 @@ "vite": "3.2.6", "typescript": "4.9.5", "@babel/core": "7.20.12", + "@babel/plugin-transform-react-jsx": "7.28.6", "@babel/preset-env": "7.20.2", "@types/chai": "4.2.22", "@types/jest": "^26.0.0", diff --git a/packages/vtable/src/ListTable-all.ts b/packages/vtable/src/ListTable-all.ts index 3ead7052d3..7e6a56e09a 100644 --- a/packages/vtable/src/ListTable-all.ts +++ b/packages/vtable/src/ListTable-all.ts @@ -11,6 +11,7 @@ import { import { registerCustomCellStylePlugin } from './plugins/custom-cell-style'; import { registerListTreeStickCellPlugin } from './plugins/list-tree-stick-cell'; import { + registerAudioCell, registerButtonCell, registerChartCell, registerCheckboxCell, @@ -32,6 +33,7 @@ registerTooltip(); registerListTreeStickCellPlugin(); registerCustomCellStylePlugin(); registerAnimation(); +registerAudioCell(); registerChartCell(); registerCheckboxCell(); registerImageCell(); diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 4c56dd313c..11ce44f6c0 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1626,6 +1626,8 @@ export class ListTable extends BaseTable implements ListTableAPI { } const time = typeof window !== 'undefined' ? window.performance.now() : 0; const oldHoverState = { col: this.stateManager.hover.cellPos.col, row: this.stateManager.hover.cellPos.row }; + const oldScrollLeft = this.stateManager.scroll.horizontalBarPos; + const oldScrollTop = this.stateManager.scroll.verticalBarPos; // 清空单元格内容 this.scenegraph.clearCells(); @@ -1671,6 +1673,8 @@ export class ListTable extends BaseTable implements ListTableAPI { // this.internalProps.frozenColCount = this.options.frozenColCount || this.rowHeaderLevelCount; // 生成单元格场景树 this.clearCellStyleCache(); + this.stateManager.scroll.horizontalBarPos = 0; + this.stateManager.scroll.verticalBarPos = 0; this.scenegraph.createSceneGraph(); this.stateManager.updateHoverPos(oldHoverState.col, oldHoverState.row); @@ -1691,6 +1695,12 @@ export class ListTable extends BaseTable implements ListTableAPI { }); this.scenegraph.resize(); + if (oldScrollLeft) { + this.stateManager.setScrollLeft(oldScrollLeft, undefined, false); + } + if (oldScrollTop) { + this.stateManager.setScrollTop(oldScrollTop, undefined, false); + } if (this.options.emptyTip) { if (this.internalProps.emptyTip) { diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index 0fdb4dc221..dce0beee77 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -64,6 +64,7 @@ import { registerAnimation } from './components'; import { + registerAudioCell, registerChartCell, registerCheckboxCell, registerImageCell, @@ -87,6 +88,7 @@ registerMenu(); registerTitle(); registerTooltip(); registerAnimation(); +registerAudioCell(); registerChartCell(); registerCheckboxCell(); registerImageCell(); diff --git a/packages/vtable/src/PivotTable-all.ts b/packages/vtable/src/PivotTable-all.ts index 1caf13a88b..ccd2a5ea59 100644 --- a/packages/vtable/src/PivotTable-all.ts +++ b/packages/vtable/src/PivotTable-all.ts @@ -10,6 +10,7 @@ import { } from './components'; import { registerCustomCellStylePlugin } from './plugins/custom-cell-style'; import { + registerAudioCell, registerChartCell, registerCheckboxCell, registerImageCell, @@ -28,6 +29,7 @@ registerTitle(); registerTooltip(); registerCustomCellStylePlugin(); registerAnimation(); +registerAudioCell(); registerCheckboxCell(); registerImageCell(); registerProgressBarCell(); diff --git a/packages/vtable/src/body-helper/body-helper.ts b/packages/vtable/src/body-helper/body-helper.ts index 9a5ab4e6b9..cae03bda40 100644 --- a/packages/vtable/src/body-helper/body-helper.ts +++ b/packages/vtable/src/body-helper/body-helper.ts @@ -97,6 +97,7 @@ export class BodyHelper { | 'link' | 'image' | 'video' + | 'audio' | 'chart' | 'sparkline' | 'progressbar' @@ -110,8 +111,8 @@ export class BodyHelper { case 'text': return TextStyle; case 'image': - return ImageStyle; case 'video': + case 'audio': return ImageStyle; case 'link': return TextStyle; diff --git a/packages/vtable/src/components/tooltip/TooltipHandler.ts b/packages/vtable/src/components/tooltip/TooltipHandler.ts index af4c3660eb..b9bbd080b9 100644 --- a/packages/vtable/src/components/tooltip/TooltipHandler.ts +++ b/packages/vtable/src/components/tooltip/TooltipHandler.ts @@ -22,6 +22,12 @@ type AttachInfo = { range: CellRange; tooltipOptions: TooltipOptions; }; +type PendingInfo = { + id: ReturnType; + col: number; + row: number; + tooltipOptions: TooltipOptions; +}; export interface ITooltipHandler { new (table: BaseTableAPI, confine: boolean): TooltipHandler; } @@ -30,6 +36,7 @@ export class TooltipHandler { private _table: BaseTableAPI; private _tooltipInstances?: { [type: string]: BaseTooltip }; private _attachInfo?: AttachInfo | null; + private _pendingInfo?: PendingInfo | null; private confine?: boolean; //弹出框是否需要限定在canvas区域 constructor(table: BaseTableAPI, confine: boolean) { this._table = table; @@ -38,14 +45,39 @@ export class TooltipHandler { this.confine = confine; } release(): void { + this._clearPendingBind(); const tooltipInstances = this._tooltipInstances; for (const k in tooltipInstances) { tooltipInstances[k]?.release?.(); } delete this._tooltipInstances; this._attachInfo = null; + this._pendingInfo = null; } _bindToCell(col: number, row: number, tooltipOptions?: TooltipOptions): void { + this._clearPendingBind(); + const appearDelay = tooltipOptions?.appearDelay; + if (tooltipOptions && appearDelay && appearDelay > 0) { + this._unbindAttachedTooltip(); + const id = setTimeout(() => { + this._pendingInfo = null; + this._bindToCellImmediately(col, row, tooltipOptions); + }, appearDelay); + this._pendingInfo = { + id, + col, + row, + tooltipOptions + }; + return; + } + this._bindToCellImmediately(col, row, tooltipOptions); + } + _bindToCellImmediately(col: number, row: number, tooltipOptions?: TooltipOptions): void { + if (!tooltipOptions) { + this._unbindAttachedTooltip(); + return; + } const info = this._attachInfo; const instance = this._getTooltipInstanceInfo(col, row); if (info && (!instance || info.instance !== instance)) { @@ -62,6 +94,21 @@ export class TooltipHandler { this._attachInfo = { range, instance, tooltipOptions }; } } + _clearPendingBind(): void { + if (this._pendingInfo) { + clearTimeout(this._pendingInfo.id); + this._pendingInfo = null; + } + } + _unbindAttachedTooltip(): void { + const info = this._attachInfo; + if (!info) { + return; + } + const { instance } = info; + instance?.unbindTooltipElement(); + this._attachInfo = null; + } _move(col: number, row: number, tooltipOptions: TooltipOptions): void { const info = this._attachInfo; if (!info || !cellInRange(info.range, col, row)) { @@ -89,15 +136,13 @@ export class TooltipHandler { instance?.locateTooltipElement(col, row, position, referencePosition, this.confine); } _unbindFromCell(): void { - const info = this._attachInfo; - if (!info) { - return; - } - const { instance } = info; - instance?.unbindTooltipElement(); - this._attachInfo = null; + this._clearPendingBind(); + this._unbindAttachedTooltip(); } _isBindCell(col: number, row: number): boolean { + if (this._pendingInfo && this._pendingInfo.col === col && this._pendingInfo.row === row) { + return true; + } const info = this._attachInfo; if (!info) { return false; @@ -245,7 +290,10 @@ export class TooltipHandler { return instance; } isBinded(tooltipOptions: TooltipOptions) { - if (JSON.stringify(tooltipOptions) === JSON.stringify(this._attachInfo?.tooltipOptions)) { + if ( + JSON.stringify(tooltipOptions) === JSON.stringify(this._attachInfo?.tooltipOptions) || + JSON.stringify(tooltipOptions) === JSON.stringify(this._pendingInfo?.tooltipOptions) + ) { return true; } return false; diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 31c4ed898c..25c3d24752 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -3604,11 +3604,16 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } if ( - !(this as any).transpose && + this.isListTable() && + !(this as unknown as ListTableAPI).transpose && (this.isSeriesNumberInBody(args.source.col, args.source.row) || args.movingColumnOrRow === 'row') ) { + const listTable = this as unknown as ListTableAPI; + const sourceRecordPath = listTable.getRecordIndexByCell(args.source.col, moveContext.sourceIndex); + const targetRecordPath = listTable.getRecordIndexByCell(args.target.col, moveContext.targetIndex); this.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); - this.stateManager.changeCheckboxAndRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + this.stateManager.changeCheckboxOrder(sourceRecordPath, targetRecordPath); + this.stateManager.changeRadioOrder(sourceRecordPath, targetRecordPath); } if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts index 7011c481b0..074c4972f2 100644 --- a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts +++ b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts @@ -68,6 +68,10 @@ export interface TableEvents { * 画布右键事件 */ CONTEXTMENU_CANVAS: 'contextmenu_canvas'; + /** + * 右键菜单项点击事件 + */ + CONTEXT_MENU_CLICK: 'context_menu_click'; /** * 列宽调整事件 */ @@ -287,6 +291,7 @@ export const TABLE_EVENT_TYPE: TableEvents = { MOUSELEAVE_CELL: 'mouseleave_cell', CONTEXTMENU_CELL: 'contextmenu_cell', CONTEXTMENU_CANVAS: 'contextmenu_canvas', + CONTEXT_MENU_CLICK: 'context_menu_click', RESIZE_COLUMN: 'resize_column', RESIZE_COLUMN_END: 'resize_column_end', RESIZE_ROW: 'resize_row', diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 6639c16c9e..2634896777 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -4,12 +4,252 @@ import { Env } from '../tools/env'; import { regUrl } from '../tools/global'; import type { LinkColumnDefine, MousePointerCellEvent } from '../ts-types'; import type { BaseTableAPI, HeaderData } from '../ts-types/base-table'; -import type { IImageColumnBodyDefine } from '../ts-types/list-table/define/image-define'; +import type { IAudioColumnBodyDefine, IImageColumnBodyDefine } from '../ts-types/list-table/define/image-define'; import { getOrApply } from '../tools/helper'; +import { isAudioUrl } from '../tools/media'; + +const MEDIA_PREVIEW_MANAGER_KEY = '__vtable_media_preview_manager__'; + +interface MediaPreviewManager { + closePreview: (() => void) | null; + registered: boolean; + close: () => void; + open: (closePreview: () => void) => void; + release: () => void; + ensureRegistered: () => void; +} + +function formatAudioTime(time: number) { + if (!Number.isFinite(time) || time < 0) { + return '0:00'; + } + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function updateAudioProgressStyle(progress: HTMLInputElement, value: number) { + progress.style.background = `linear-gradient( + to right, + #2563EB 0%, + #2563EB ${value}%, + #D1D5DB ${value}%, + #D1D5DB 100% + )`; +} + +function ensureAudioPreviewStyle() { + const styleId = 'vtable-audio-preview-style'; + if (document.getElementById(styleId)) { + return; + } + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .vtable-audio-preview-progress { + -webkit-appearance: none; + appearance: none; + } + .vtable-audio-preview-progress::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: transparent; + } + .vtable-audio-preview-progress::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-top: -4px; + border-radius: 50%; + border: 0; + background: #2563EB; + } + .vtable-audio-preview-progress::-moz-range-track { + height: 4px; + border-radius: 999px; + background: transparent; + } + .vtable-audio-preview-progress::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 0; + background: #2563EB; + } + `; + document.head.appendChild(style); +} + +function createAudioPreview(cellValue: string) { + ensureAudioPreviewStyle(); + + const audio = document.createElement('audio'); + audio.src = cellValue; + audio.preload = 'auto'; + let released = false; + + const wrapper = document.createElement('div'); + wrapper.style.width = '80%'; + wrapper.style.maxWidth = '640px'; + wrapper.style.height = '54px'; + wrapper.style.boxSizing = 'border-box'; + wrapper.style.padding = '0 16px'; + wrapper.style.borderRadius = '27px'; + wrapper.style.background = '#F8FAFC'; + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.gap = '10px'; + wrapper.style.boxShadow = '0 8px 24px rgba(15, 23, 42, 0.16)'; + + const playButton = document.createElement('button'); + playButton.type = 'button'; + playButton.textContent = '▶'; + playButton.style.width = '34px'; + playButton.style.height = '34px'; + playButton.style.border = '1px solid #CBD5E1'; + playButton.style.borderRadius = '8px'; + playButton.style.background = '#EFF6FF'; + playButton.style.color = '#0F172A'; + playButton.style.cursor = 'pointer'; + playButton.style.flex = '0 0 auto'; + playButton.style.fontSize = '14px'; + + const timeLabel = document.createElement('span'); + timeLabel.textContent = '0:00 / 0:00'; + timeLabel.style.color = '#0F172A'; + timeLabel.style.font = '14px sans-serif'; + timeLabel.style.whiteSpace = 'nowrap'; + + const progress = document.createElement('input'); + progress.className = 'vtable-audio-preview-progress'; + progress.type = 'range'; + progress.min = '0'; + progress.max = '100'; + progress.value = '0'; + progress.step = '0.1'; + progress.style.flex = '1 1 auto'; + progress.style.height = '4px'; + progress.style.borderRadius = '999px'; + progress.style.outline = 'none'; + progress.style.cursor = 'pointer'; + progress.style.accentColor = '#2563EB'; + updateAudioProgressStyle(progress, 0); + + const updateProgress = () => { + const duration = Number.isFinite(audio.duration) ? audio.duration : 0; + const currentTime = audio.currentTime || 0; + const value = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; + progress.disabled = duration <= 0; + progress.style.cursor = duration > 0 ? 'pointer' : 'default'; + progress.value = `${value}`; + timeLabel.textContent = `${formatAudioTime(currentTime)} / ${formatAudioTime(duration)}`; + updateAudioProgressStyle(progress, value); + }; + + playButton.addEventListener('click', e => { + e.stopPropagation(); + if (audio.paused) { + const playPromise = audio.play(); + if (playPromise) { + playPromise.catch(error => { + if (released || error?.name === 'AbortError') { + return; + } + playButton.textContent = '▶'; + }); + } + } else { + audio.pause(); + } + }); + + progress.addEventListener('click', e => { + e.stopPropagation(); + }); + + progress.addEventListener('input', () => { + const duration = Number.isFinite(audio.duration) ? audio.duration : 0; + const value = Number(progress.value); + if (duration > 0 && Number.isFinite(value)) { + audio.currentTime = (duration * value) / 100; + } + updateProgress(); + }); + + audio.addEventListener('loadedmetadata', updateProgress); + audio.addEventListener('durationchange', updateProgress); + audio.addEventListener('timeupdate', updateProgress); + audio.addEventListener('play', () => { + playButton.textContent = '❚❚'; + }); + audio.addEventListener('pause', () => { + playButton.textContent = '▶'; + }); + audio.addEventListener('ended', () => { + audio.currentTime = 0; + playButton.textContent = '▶'; + updateProgress(); + }); + + wrapper.appendChild(playButton); + wrapper.appendChild(timeLabel); + wrapper.appendChild(progress); + wrapper.appendChild(audio); + + return { + element: wrapper, + destroy: () => { + released = true; + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + }; +} + +function createMediaPreviewManager(table: BaseTableAPI): MediaPreviewManager { + const manager: MediaPreviewManager = { + closePreview: null, + registered: false, + close() { + const closePreview = manager.closePreview; + manager.closePreview = null; + closePreview?.(); + }, + open(closePreview: () => void) { + manager.close(); + manager.closePreview = closePreview; + }, + release() { + manager.close(); + manager.registered = false; + }, + ensureRegistered() { + if (manager.registered) { + return; + } + table.addReleaseObj(manager); + manager.registered = true; + } + }; + return manager; +} + +function getMediaPreviewManager(table: BaseTableAPI): MediaPreviewManager { + const tableWithManager = table as any; + if (!tableWithManager[MEDIA_PREVIEW_MANAGER_KEY]) { + tableWithManager[MEDIA_PREVIEW_MANAGER_KEY] = createMediaPreviewManager(table); + } + const manager = tableWithManager[MEDIA_PREVIEW_MANAGER_KEY] as MediaPreviewManager; + manager.ensureRegistered(); + return manager; +} export function bindMediaClick(table: BaseTableAPI): void { if (Env.mode === 'browser') { // table.hasMedia = false; + getMediaPreviewManager(table); table.on(TABLE_EVENT_TYPE.CLICK_CELL, (e: MousePointerCellEvent) => { //如果目前是在某个icon上,如收起展开按钮 则不进行其他点击逻辑 @@ -99,6 +339,7 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + const previewManager = getMediaPreviewManager(table); // 开启蒙版 const overlay = document.createElement('div'); @@ -114,9 +355,16 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.overflow = 'hidden'; overlay.style.zIndex = '9999'; + const closeOverlay = () => { + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + previewManager.open(closeOverlay); + overlay.addEventListener('click', e => { if (e.target === overlay) { - document.body.removeChild(overlay); + previewManager.close(); } }); // overlay.addEventListener('pointermove', (e) => { @@ -136,12 +384,13 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.appendChild(image); document.body.appendChild(overlay); - } else if (cellType === 'video') { - // 点击视频,弹出播放窗口 - const { clickToPreview } = columnDefine as IImageColumnBodyDefine; + } else if (cellType === 'audio' || (cellType === 'video' && isAudioUrl(cellValue))) { + // 点击音频,弹出播放窗口 + const { clickToPreview } = columnDefine as IAudioColumnBodyDefine; if (clickToPreview === false) { return; } + const previewManager = getMediaPreviewManager(table); // 开启蒙版 const overlay = document.createElement('div'); @@ -157,21 +406,80 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.overflow = 'hidden'; overlay.style.zIndex = '9999'; + const audioPreview = createAudioPreview(cellValue); + const closeOverlay = () => { + audioPreview.destroy(); + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + previewManager.open(closeOverlay); + overlay.addEventListener('click', e => { if (e.target === overlay) { - document.body.removeChild(overlay); + previewManager.close(); } }); - // 创建视频 + // 创建音频 + overlay.appendChild(audioPreview.element); + + document.body.appendChild(overlay); + } else if (cellType === 'video') { + // 点击视频,弹出播放窗口 + const { clickToPreview } = columnDefine as IImageColumnBodyDefine; + if (clickToPreview === false) { + return; + } + const previewManager = getMediaPreviewManager(table); + + // 开启蒙版 + const overlay = document.createElement('div'); + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.backgroundColor = 'rgba(30, 30, 30, 0.4)'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.overflow = 'hidden'; + overlay.style.zIndex = '9999'; + const video = document.createElement('video'); video.src = cellValue; video.style.maxWidth = '80%'; video.style.maxHeight = '80%'; video.setAttribute('preload', 'auto'); video.setAttribute('controls', 'true'); + + const closeOverlay = () => { + try { + video.pause(); + } catch (err) { + // ignore media cleanup errors + } + video.removeAttribute('src'); + try { + video.load(); + } catch (err) { + // ignore media cleanup errors + } + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + previewManager.open(closeOverlay); + overlay.appendChild(video); + overlay.addEventListener('click', e => { + if (e.target === overlay) { + previewManager.close(); + } + }); + document.body.appendChild(overlay); } }); diff --git a/packages/vtable/src/header-helper/header-helper.ts b/packages/vtable/src/header-helper/header-helper.ts index 4c883780f4..94f2cd06eb 100644 --- a/packages/vtable/src/header-helper/header-helper.ts +++ b/packages/vtable/src/header-helper/header-helper.ts @@ -461,8 +461,8 @@ export class HeaderHelper { case 'text': return TextHeaderStyle; case 'image': - return ImageStyle; case 'video': + case 'audio': return ImageStyle; case 'link': return TextHeaderStyle; diff --git a/packages/vtable/src/index.ts b/packages/vtable/src/index.ts index f834a4543a..3d02a5bade 100644 --- a/packages/vtable/src/index.ts +++ b/packages/vtable/src/index.ts @@ -16,6 +16,7 @@ import type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, @@ -99,6 +100,7 @@ export { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index b443c0b3c3..bc7d557538 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -24,6 +24,8 @@ import type { CreateProgressBarCell } from './cell-type/progress-bar-cell'; import type { CreateSparkLineCellGroup } from './cell-type/spark-line-cell'; import type { CreateTextCellGroup } from './cell-type/text-cell'; import type { CreateVideoCellGroup } from './cell-type/video-cell'; +import type { CreateAudioCellGroup } from './cell-type/audio-cell'; +import { removeCellMediaChildren } from './cell-type/media-cell-helper'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; import { getCellCornerRadius, getStyleTheme } from '../../core/tableHelper'; import { getOrApply, isPromise } from '../../tools/helper'; @@ -42,9 +44,28 @@ import { getCellBorderStrokeWidth } from '../utils/cell-border-stroke-width'; import type { CreateSwitchCellGroup } from './cell-type/switch-cell'; import type { CreateButtonCellGroup } from './cell-type/button-cell'; +const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; + +export function nextPromiseCellUpdateToken(cellGroup: Group): number { + const token = ((cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] ?? 0) + 1; + (cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] = token; + return token; +} + +export function isPromiseCellUpdateCurrent( + table: BaseTableAPI, + col: number, + row: number, + cellGroup: Group, + token: number +): boolean { + const currentCellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + return currentCellGroup === cellGroup && (cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] === token; +} + export function createCell( type: ColumnTypeOption, - value: string, + value: any, define: ColumnDefine, table: BaseTableAPI, col: number, @@ -63,18 +84,24 @@ export function createCell( customResult?: { elementsGroup?: VGroup; renderDefault: boolean; - } + }, + cellValue?: any ): Group { let isAsync = false; let cellGroup: Group; + const hasCellValue = arguments.length >= 19; + let renderValue = hasCellValue ? cellValue : value; if (isPromise(value)) { if (table.scenegraph.highPerformanceGetCell(col, row).role !== 'cell') { // avoid nouse async create cell return cellGroup; } - value = table.getCellValue(col, row); + if (!hasCellValue) { + renderValue = table.getCellValue(col, row); + } isAsync = true; } + value = renderValue; // let bgColorFunc: Function; // // 判断是否有mapping 遍历dataset中mappingRules // if ((table.internalProps as PivotTableProtected)?.dataConfig?.mappingRules && !table.isHeader(col, row)) { @@ -113,7 +140,8 @@ export function createCell( define as CheckboxColumnDefine, range, isAsync, - true + true, + value ); } else if (type === 'text' || type === 'link' || customResult) { // customMerge&customLayout cell as text cell @@ -249,7 +277,28 @@ export function createCell( table, cellTheme, range, - isAsync + isAsync, + value + ); + } else if (type === 'audio') { + const createAudioCellGroup = Factory.getFunction('createAudioCellGroup') as CreateAudioCellGroup; + cellGroup = createAudioCellGroup( + columnGroup, + 0, + y, + col, + row, + cellWidth, + cellHeight, + padding, + textAlign, + textBaseline, + mayHaveIcon, + table, + cellTheme, + range, + isAsync, + value ); } else if (type === 'video') { // 创建视频单元格 @@ -271,7 +320,8 @@ export function createCell( table, cellTheme, range, - isAsync + isAsync, + value ); } else if (type === 'chart') { const chartInstance = table.internalProps.layoutMap.getChartInstance(col, row); @@ -369,7 +419,8 @@ export function createCell( padding, table, cellTheme, - isAsync + isAsync, + value ); } else if (type === 'checkbox') { const isAggregation = @@ -418,7 +469,8 @@ export function createCell( define as CheckboxColumnDefine, range, isAsync, - false + false, + value ); } } else if (type === 'radio') { @@ -465,7 +517,9 @@ export function createCell( table, cellTheme, define as RadioColumnDefine, - range + range, + isAsync, + value ); } } else if (type === 'switch') { @@ -488,7 +542,8 @@ export function createCell( cellTheme, define as SwitchColumnDefine, range, - isAsync + isAsync, + value ); } else if (type === 'button') { const createButtonCellGroup = Factory.getFunction('createButtonCellGroup') as CreateButtonCellGroup; @@ -510,7 +565,8 @@ export function createCell( cellTheme, define as ButtonColumnDefine, range, - isAsync + isAsync, + value ); } @@ -852,7 +908,9 @@ export function updateCell( // deal with promise data if (isPromise(value)) { + const updateToken = nextPromiseCellUpdateToken(oldCellGroup); // clear cell content sync + removeCellMediaChildren(oldCellGroup); oldCellGroup.removeAllChild(); // update cell content async @@ -861,7 +919,6 @@ export function updateCell( table, callUpdateCellContentForPromiseValue.bind(null, { type, - value, define, table, col, @@ -877,7 +934,9 @@ export function updateCell( addNew, range, customResult, - customStyle + customStyle, + promiseValue: value, + updateToken }) ); } else { @@ -933,7 +992,7 @@ export function updateCell( function updateCellContent( type: ColumnTypeOption, - value: string, + value: any, define: ColumnDefine, table: BaseTableAPI, col: number, @@ -952,11 +1011,10 @@ function updateCellContent( customResult?: { elementsGroup?: VGroup; renderDefault: boolean; - } + }, + cellValue?: any ) { - if (isPromise(value)) { - value = table.getCellValue(col, row); - } + const hasCellValue = arguments.length >= 18; //解决报错 getCellByCache递归调用 死循环问题 if (!addNew && (oldCellGroup.row !== row || oldCellGroup.col !== col)) { return null; @@ -973,7 +1031,7 @@ function updateCellContent( } } } - const newCellGroup = createCell( + const createCellArgs: Parameters = [ type, value, define, @@ -994,11 +1052,16 @@ function updateCellContent( cellTheme, range, customResult - ); - if (!addNew && oldCellGroup.parent) { + ]; + if (hasCellValue) { + createCellArgs.push(cellValue); + } + const newCellGroup = createCell(...createCellArgs); + if (!addNew && oldCellGroup.parent && newCellGroup !== oldCellGroup) { // update cell oldCellGroup.parent.insertAfter(newCellGroup, oldCellGroup); oldCellGroup.parent.removeChild(oldCellGroup); + removeCellMediaChildren(oldCellGroup); oldCellGroup.release(true); // update cache @@ -1038,10 +1101,9 @@ function canUseFastUpdate( } return false; } -function callUpdateCellContentForPromiseValue(updateCellArgs: any) { +function callUpdateCellContentForPromiseValue(updateCellArgs: any, resolvedValue: any) { const { type, - value, define, table, col, @@ -1056,8 +1118,13 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any) { addNew, range, customResult, - customStyle + customStyle, + promiseValue, + updateToken } = updateCellArgs; + if (!isPromiseCellUpdateCurrent(table, col, row, oldCellGroup, updateToken)) { + return; + } const cellStyle = customStyle || table._getCellStyle(range ? range.start.col : col, range ? range.start.row : row); const cellTheme = getStyleTheme( cellStyle, @@ -1070,7 +1137,7 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any) { cellTheme.group.cornerRadius = getCellCornerRadius(col, row, table); updateCellContent( type, - value, + promiseValue, define, table, col, @@ -1086,7 +1153,8 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any) { addNew, cellTheme, range, - customResult + customResult, + resolvedValue ); } export function dealWithMergeCellSize( diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts new file mode 100644 index 0000000000..bbb42271b8 --- /dev/null +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -0,0 +1,179 @@ +/* eslint-disable no-undef */ +import type { Cursor, IImage, IThemeSpec } from '@src/vrender'; +import { createImage } from '@src/vrender'; +import { Group } from '../../graphic/group'; +import { calcStartPosition } from '../../utils/cell-pos'; +import { getFunctionalProp } from '../../utils/get-prop'; +import { isValid } from '@visactor/vutils'; +import type { BaseTableAPI } from '../../../ts-types/base-table'; +import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; +import { getQuadProps } from '../../utils/padding'; +import type { CellRange } from '../../../ts-types'; +import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { markCellMedia, removeCellMediaChildren } from './media-cell-helper'; + +export const audioIconSvg = + ''; + +export function createAudioCellGroup( + columnGroup: Group, + xOrigin: number, + yOrigin: number, + col: number, + row: number, + width: number, + height: number, + padding: [number, number, number, number], + textAlign: CanvasTextAlign, + textBaseline: CanvasTextBaseline, + mayHaveIcon: boolean, + table: BaseTableAPI, + cellTheme: IThemeSpec, + range: CellRange | undefined, + isAsync: boolean, + _cellValue?: any +) { + const headerStyle = table._getCellStyle(col, row); + const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); + if (table.options.customConfig?.imageMargin) { + padding = getQuadProps(table.options.customConfig?.imageMargin); + } else if (isValid(functionalPadding)) { + padding = functionalPadding; + } + if (cellTheme?.text?.textAlign) { + textAlign = cellTheme.text.textAlign; + } + if (cellTheme?.text?.textBaseline) { + textBaseline = cellTheme.text.textBaseline; + } + + const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); + let cellGroup: Group; + if (isAsync) { + cellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + if (cellGroup && cellGroup.role === 'cell') { + cellGroup.setAttributes({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + } + } + if (!cellGroup || cellGroup.role !== 'cell') { + cellGroup = new Group({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + cellGroup.role = 'cell'; + cellGroup.col = col; + cellGroup.row = row; + cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + } + + let cellIcons; + if (mayHaveIcon) { + let iconCol = col; + let iconRow = row; + if (range) { + iconCol = range.start.col; + iconRow = range.start.row; + } + cellIcons = table.getCellIcons(iconCol, iconRow); + } + + let cellLeftIconWidth = 0; + let cellRightIconWidth = 0; + if (Array.isArray(cellIcons) && cellIcons.length !== 0) { + const { leftIconWidth, rightIconWidth, absoluteRightIconWidth } = dealWithIconLayout( + cellIcons, + cellGroup, + range, + table + ); + + cellLeftIconWidth = leftIconWidth; + cellRightIconWidth = rightIconWidth; + + cellGroup.forEachChildren((child: any) => { + if (child.role === 'icon-left') { + child.setAttribute('x', child.attribute.x + padding[3]); + } else if (child.role === 'icon-right') { + child.setAttribute('x', child.attribute.x + width - rightIconWidth - padding[1]); + } else if (child.role === 'icon-absolute-right') { + child.setAttribute('x', child.attribute.x + width - absoluteRightIconWidth - padding[1]); + } + }); + + cellGroup.forEachChildren((child: any) => { + if (textBaseline === 'middle') { + child.setAttribute('y', (height - child.AABBBounds.height()) / 2); + } else if (textBaseline === 'bottom') { + child.setAttribute('y', height - child.AABBBounds.height() - padding[2]); + } else { + child.setAttribute('y', padding[0]); + } + }); + } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; + + const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); + const availableHeight = Math.max(1, height - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition( + cellLeftIconWidth, + 0, + width - cellLeftIconWidth - cellRightIconWidth, + height, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); + removeCellMediaChildren(cellGroup); + const image: IImage = markCellMedia( + createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }), + 'image' + ); + image.name = 'image'; + image.keepAspectRatio = true; + (image as any).isAudioIcon = true; + image.textAlign = textAlign; + image.textBaseline = textBaseline; + cellGroup.appendChild(image); + + return cellGroup; +} + +export type CreateAudioCellGroup = typeof createAudioCellGroup; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts index 09bb63974f..d7b632307e 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts @@ -28,8 +28,10 @@ export function createButtonCellGroup( cellTheme: IThemeSpec, define: ButtonColumnDefine, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 19 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -139,7 +141,8 @@ export function createButtonCellGroup( padding, cellTheme, define, - table + table, + value ); if (buttonComponent) { cellGroup.appendChild(buttonComponent); @@ -180,7 +183,8 @@ function createButton( padding: number[], cellTheme: IThemeSpec, define: ButtonColumnDefine, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as ButtonStyle; const buttonColor = getProp('buttonColor', style, col, row, table); @@ -195,7 +199,7 @@ function createButton( const buttonDisableBorderColor = getProp('buttonDisableBorderColor', style, col, row, table); const buttonTextDisableColor = getProp('buttonTextDisableColor', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); const hierarchyOffset = getHierarchyOffset(col, row, table); const cellStyle = table._getCellStyle(col, row) as ButtonStyleOption; // to be fixed diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts index 27383d378b..4b777e1a21 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts @@ -87,7 +87,7 @@ export function createChartCellGroup( } cellGroup.AABBBounds.width(); // TODO 需要底层VRender修改 // chart - if ((isNoChartDataRenderNothing && Array.isArray(table.getCellValue(col, row))) || !isNoChartDataRenderNothing) { + if ((isNoChartDataRenderNothing && Array.isArray(dataValue)) || !isNoChartDataRenderNothing) { const chartGroup = new Chart(isShareChartSpec, { stroke: false, x: padding[3], @@ -102,7 +102,7 @@ export function createChartCellGroup( height: height - padding[2] - padding[0], chartInstance, dataId, - data: table.getCellValue(col, row) || [], + data: dataValue || [], cellPadding: padding, dpr: table.internalProps.pixelRatio, detectPickChartItem: table.options.customConfig?.detectPickChartItem, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts index dcf7aeff55..6b58ebda73 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts @@ -42,8 +42,10 @@ export function createCheckboxCellGroup( define: CheckboxColumnDefine, range: CellRange | undefined, isAsync: boolean, - isCheckboxTree: boolean + isCheckboxTree: boolean, + cellValue?: any ) { + const value = arguments.length >= 20 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -163,7 +165,8 @@ export function createCheckboxCellGroup( cellTheme, define, table, - isCheckboxTree + isCheckboxTree, + value ); // 目前只支持展示折叠或者展开icons @@ -304,7 +307,8 @@ function createCheckbox( cellTheme: IThemeSpec, define: CheckboxColumnDefine, table: BaseTableAPI, - isCheckboxTree: boolean + isCheckboxTree: boolean, + cellValue: any ) { const style = table._getCellStyle(col, row) as CheckboxStyle; const size = getProp('size', style, col, row, table); @@ -319,7 +323,7 @@ function createCheckbox( const checkIconImage = getProp('checkIconImage', style, col, row, table); const indeterminateIconImage = getProp('indeterminateIconImage', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); let isChecked; let isDisabled; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 3e9a570e0c..bf6b69f247 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -13,6 +13,25 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { + getCellMediaChildren, + getCellMediaImage, + getCellMediaPlayIcon, + markCellMedia, + removeCellMediaChildren +} from './media-cell-helper'; + +function hasCellMedia(cellGroup: Group): boolean { + return getCellMediaChildren(cellGroup).length > 0; +} + +function getResizeImage(cellGroup: Group): Image | undefined { + return hasCellMedia(cellGroup) ? (getCellMediaImage(cellGroup) as Image) : undefined; +} + +function getResizePlayIcon(cellGroup: Group): Image | undefined { + return hasCellMedia(cellGroup) ? (getCellMediaPlayIcon(cellGroup) as Image) : undefined; +} export function createImageCellGroup( columnGroup: Group, @@ -31,7 +50,8 @@ export function createImageCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); @@ -145,21 +165,24 @@ export function createImageCellGroup( child.setAttribute('y', padding[0]); } }); - - (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; - (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // image - const value = table.getCellValue(col, row); - const image: IImage = createImage({ - x: padding[3], - y: padding[0], - width: width - padding[1] - padding[3] - iconWidth, - height: height - padding[0] - padding[2], - image: value, - cursor: 'pointer' as Cursor - }); + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); + removeCellMediaChildren(cellGroup); + const image: IImage = markCellMedia( + createImage({ + x: padding[3], + y: padding[0], + width: width - padding[1] - padding[3] - iconWidth, + height: height - padding[0] - padding[2], + image: value, + cursor: 'pointer' as Cursor + }), + 'image' + ); image.name = 'image'; image.keepAspectRatio = keepAspectRatio; image.textAlign = textAlign; @@ -313,7 +336,7 @@ export function updateImageCellContentWhileResize( deltaY: number, table: BaseTableAPI ) { - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); if (!image) { return; } @@ -321,7 +344,7 @@ export function updateImageCellContentWhileResize( (typeof image.attribute.image !== 'string' && image.attribute.image) || image.resources?.get(image.attribute.image as string).data; - if (!originImage) { + if (!originImage && !(image as any).isAudioIcon) { return; } @@ -339,12 +362,40 @@ export function updateImageCellContentWhileResize( const colStart = cellGroup.mergeStartCol ?? cellGroup.col; const rowStart = cellGroup.mergeStartRow ?? cellGroup.row; const colEnd = cellGroup.mergeEndCol ?? cellGroup.col; - const rowEnd = cellGroup.mergeEndCol ?? cellGroup.row; + const rowEnd = cellGroup.mergeEndRow ?? cellGroup.row; const leftIconWidth = (cellGroup as any)._cellLeftIconWidth ?? 0; const rightIconWidth = (cellGroup as any)._cellRightIconWidth ?? 0; - if ((image as any).keepAspectRatio || isDamagePic(image)) { + if ((image as any).isAudioIcon) { + const availableWidth = Math.max(1, cellWidth - padding[1] - padding[3] - leftIconWidth - rightIconWidth); + const availableHeight = Math.max(1, cellHeight - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition( + leftIconWidth, + 0, + cellWidth - leftIconWidth - rightIconWidth, + cellHeight, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); + + for (let col = colStart; col <= colEnd; col++) { + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(col, row); + const image = getResizeImage(cellGroup); + image?.setAttributes({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize + }); + } + } + } else if ((image as any).keepAspectRatio || isDamagePic(image)) { const { width: imageWidth, height: imageHeight } = calcKeepAspectRatioSize( originImage.width || (originImage as any).videoWidth, originImage.height || (originImage as any).videoHeight, @@ -371,7 +422,7 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); image?.setAttributes({ x: pos.x, y: pos.y, @@ -384,7 +435,7 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); image?.setAttributes({ x: leftIconWidth + padding[3], y: padding[0], @@ -398,7 +449,7 @@ export function updateImageCellContentWhileResize( } // update video play icon - const playIcon = cellGroup.getChildByName('play-icon'); + const playIcon = getResizePlayIcon(cellGroup); if (playIcon) { const left = 0; const top = 0; @@ -414,8 +465,8 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const playIcon = cellGroup.getChildByName('play-icon') as Image; - playIcon.setAttributes({ + const playIcon = getResizePlayIcon(cellGroup); + playIcon?.setAttributes({ x: anchorX - iconSize / 2, y: anchorY - iconSize / 2, width: iconSize, @@ -492,14 +543,14 @@ export function updateImageDxDy( for (let row = startRow; row <= endRow; row++) { const cellGroup = table.scenegraph.getCell(col, row); if (cellGroup) { - const image = cellGroup.getChildByName('image'); + const image = getResizeImage(cellGroup); if (image) { image.setAttributes({ dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), dy: -table.getRowsHeight(cellGroup.mergeStartRow, row - 1) }); } - const playIcon = cellGroup.getChildByName('play-icon'); + const playIcon = getResizePlayIcon(cellGroup); if (playIcon) { playIcon.setAttributes({ dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts index 221d4f0bd0..a9790c3a87 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts @@ -6,6 +6,7 @@ import { createRadioCellGroup } from './radio-cell'; import { createSwitchCellGroup } from './switch-cell'; import { createSparkLineCellGroup } from './spark-line-cell'; import { createVideoCellGroup } from './video-cell'; +import { createAudioCellGroup } from './audio-cell'; import { createCellGroup as createTextCellGroup } from './text-cell'; import { createProgressBarCell } from './progress-bar-cell'; import { getAxisDomainRangeAndLabels } from '../../../layout/chart-helper/get-axis-domain'; @@ -51,3 +52,7 @@ export const registerTextCell = () => { export const registerVideoCell = () => { Factory.registerFunction('createVideoCellGroup', createVideoCellGroup); }; + +export const registerAudioCell = () => { + Factory.registerFunction('createAudioCellGroup', createAudioCellGroup); +}; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts new file mode 100644 index 0000000000..6ad3f31e8e --- /dev/null +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts @@ -0,0 +1,76 @@ +import type { IImage } from '@src/vrender'; +import type { Group } from '../../graphic/group'; + +export const CELL_MEDIA_KEY = '__vtable_cell_media__'; +export const CELL_MEDIA_KIND_KEY = '__vtable_cell_media_kind__'; +export type CellMediaKind = 'image' | 'play-icon'; + +export function markCellMedia(graphic: T, kind: CellMediaKind): T { + (graphic as any)[CELL_MEDIA_KEY] = true; + (graphic as any)[CELL_MEDIA_KIND_KEY] = kind; + return graphic; +} + +export function isCellMedia(graphic: any): boolean { + return graphic?.[CELL_MEDIA_KEY] === true; +} + +export function getCellMediaChildren(cellGroup: Group): any[] { + const mediaChildren: any[] = []; + cellGroup.forEachChildren((child: any) => { + if (isCellMedia(child)) { + mediaChildren.push(child); + } + }); + return mediaChildren; +} + +function getCellMediaByKind(cellGroup: Group, kind: CellMediaKind): any { + let graphic: any; + cellGroup.forEachChildren((child: any) => { + if (isCellMedia(child) && child[CELL_MEDIA_KIND_KEY] === kind) { + graphic = child; + return true; + } + return false; + }); + return graphic; +} + +export function getCellMediaImage(cellGroup: Group): IImage | undefined { + return getCellMediaByKind(cellGroup, 'image'); +} + +export function getCellMediaPlayIcon(cellGroup: Group): any { + return getCellMediaByKind(cellGroup, 'play-icon'); +} + +export function releaseVideoResource(video: HTMLVideoElement): void { + try { + video.pause(); + } catch (err) { + // ignore media cleanup errors + } + video.removeAttribute('src'); + try { + video.load(); + } catch (err) { + // ignore media cleanup errors + } +} + +export function releaseGraphicMediaResource(graphic: any): void { + const source = graphic?.attribute?.image; + if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) { + releaseVideoResource(source); + } +} + +export function removeCellMediaChildren(cellGroup: Group): void { + const mediaChildren = getCellMediaChildren(cellGroup); + mediaChildren.forEach(child => { + releaseGraphicMediaResource(child); + cellGroup.removeChild(child); + child.release?.(); + }); +} diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts index a7012394cc..5dff0a01d8 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts @@ -27,38 +27,64 @@ export function createRadioCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, define: RadioColumnDefine, - range: CellRange + range: CellRange, + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); - cellGroup = new Group({ - x: xOrigin, - y: yOrigin, - width, - height, + if (isAsync) { + cellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + if (cellGroup && cellGroup.role === 'cell') { + cellGroup.setAttributes({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth: strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + } + } + if (!cellGroup || cellGroup.role !== 'cell') { + cellGroup = new Group({ + x: xOrigin, + y: yOrigin, + width, + height, - // 背景相关,cell背景由cellGroup绘制 - lineWidth: cellTheme?.group?.lineWidth ?? undefined, - fill: cellTheme?.group?.fill ?? undefined, - stroke: cellTheme?.group?.stroke ?? undefined, + // 背景相关,cell背景由cellGroup绘制 + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, - strokeArrayWidth: strokeArrayWidth, - strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, - cursor: (cellTheme?.group as any)?.cursor ?? undefined, - lineDash: cellTheme?.group?.lineDash ?? undefined, + strokeArrayWidth: strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, - lineCap: 'butt', + lineCap: 'butt', - clip: true, + clip: true, - cornerRadius: cellTheme.group.cornerRadius - } as any); - cellGroup.role = 'cell'; - cellGroup.col = col; - cellGroup.row = row; - // columnGroup?.addChild(cellGroup); - cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + cornerRadius: cellTheme.group.cornerRadius + } as any); + cellGroup.role = 'cell'; + cellGroup.col = col; + cellGroup.row = row; + // columnGroup?.addChild(cellGroup); + cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + } } // radio @@ -73,7 +99,8 @@ export function createRadioCellGroup( define, cellGroup, range, - table + table, + value ); // align in cell @@ -113,7 +140,8 @@ function createRadio( define: RadioColumnDefine, cellGroup: Group, range: CellRange, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as RadioStyle; let size = getProp('size', style, col, row, table); @@ -139,7 +167,7 @@ function createRadio( innerRadius = Math.round((outerRadius / 7) * 3); } - const value = table.getCellValue(col, row) as + const value = cellValue as | string | boolean | string[] diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts index 259d642314..9b520f05d4 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts @@ -23,8 +23,10 @@ export function createSparkLineCellGroup( padding: number[], table: BaseTableAPI, cellTheme: IThemeSpec, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 13 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -77,7 +79,7 @@ export function createSparkLineCellGroup( } // chart - const chartGroup = createSparkLine(col, row, width, height, padding, table); + const chartGroup = createSparkLine(col, row, width, height, padding, table, value); if (chartGroup) { cellGroup.appendChild(chartGroup); } @@ -93,14 +95,15 @@ function createSparkLine( width: number, height: number, padding: number[], - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ): Group | undefined { //获取场景树对象,根据当前单元格位置更改其位置 //待定 TODO group需要设置shape属性吗 let sparklineSpec: SparklineSpec; let chartGroup: Group; const chartSpecRaw = (table.internalProps.layoutMap.getBody(col, row) as ColumnData).sparklineSpec; - const dataValue = table.getCellValue(col, row) as unknown as any[]; + const dataValue = cellValue as unknown as any[]; if (!Array.isArray(dataValue)) { return undefined; @@ -120,7 +123,7 @@ function createSparkLine( col, row, dataValue: table.getCellOriginValue(col, row) || '', - value: table.getCellValue(col, row) || '', + value: dataValue || '', rect: table.getCellRangeRelativeRect(table.getCellRange(col, row)), table }; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts index 705a4739fa..120ab4199a 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts @@ -28,8 +28,10 @@ export function createSwitchCellGroup( cellTheme: IThemeSpec, define: SwitchColumnDefine, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 19 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -139,7 +141,8 @@ export function createSwitchCellGroup( padding, cellTheme, define, - table + table, + value ); if (switchComponent) { cellGroup.appendChild(switchComponent); @@ -180,7 +183,8 @@ function createSwitch( padding: number[], cellTheme: IThemeSpec, define: SwitchColumnDefine, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as SwitchStyle; @@ -194,7 +198,7 @@ function createSwitch( const disableUncheckedFill = getProp('disableUncheckedFill', style, col, row, table); const circleFill = getProp('circleFill', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); let isChecked: boolean; let isDisabled; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index ac058bd6d6..c9fe0e95ce 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -6,13 +6,7 @@ import { Group } from '../../graphic/group'; import { calcKeepAspectRatioSize } from '../../utils/keep-aspect-ratio'; import { Icon } from '../../graphic/icon'; import { calcStartPosition } from '../../utils/cell-pos'; -import { - _adjustWidthHeight, - getCellRange, - isDamagePic, - updateAutoSizingAndKeepAspectRatio, - updateImageDxDy -} from './image-cell'; +import { _adjustWidthHeight, getCellRange, isDamagePic, updateAutoSizingAndKeepAspectRatio } from './image-cell'; import { getFunctionalProp, getProp } from '../../utils/get-prop'; import { isValid } from '@visactor/vutils'; import type { BaseTableAPI } from '../../../ts-types/base-table'; @@ -20,20 +14,33 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { getQuadProps } from '../../utils/padding'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { isAudioUrl } from '../../../tools/media'; +import { audioIconSvg } from './audio-cell'; +import { + getCellMediaImage, + isCellMedia, + markCellMedia, + removeCellMediaChildren, + releaseVideoResource +} from './media-cell-helper'; const regedIcons = icons.get(); -function releaseVideoResource(video: HTMLVideoElement): void { - try { - video.pause(); - } catch (err) { - // ignore media cleanup errors - } - video.removeAttribute('src'); - try { - video.load(); - } catch (err) { - // ignore media cleanup errors +function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number, endRow: number, table: BaseTableAPI) { + for (let col = startCol; col <= endCol; col++) { + for (let row = startRow; row <= endRow; row++) { + const cellGroup = table.scenegraph.getCell(col, row); + if (cellGroup) { + cellGroup.forEachChildren((child: any) => { + if (isCellMedia(child)) { + child.setAttributes({ + dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), + dy: -table.getRowsHeight(cellGroup.mergeStartRow, row - 1) + }); + } + }); + } + } } } @@ -51,6 +58,8 @@ function snapshotVideoFirstFrame(video: HTMLVideoElement, image: IImage, table: const displayWidth = image.attribute.width; const displayHeight = image.attribute.height; if ( + video.videoWidth <= 0 || + video.videoHeight <= 0 || typeof displayWidth !== 'number' || typeof displayHeight !== 'number' || displayWidth <= 0 || @@ -123,7 +132,8 @@ export function createVideoCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); @@ -237,13 +247,47 @@ export function createVideoCellGroup( child.setAttribute('y', padding[0]); } }); - - (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; - (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // video - const value = table.getCellValue(col, row); + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); + removeCellMediaChildren(cellGroup); + if (isAudioUrl(value)) { + const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); + const availableHeight = Math.max(1, height - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition( + cellLeftIconWidth, + 0, + width - cellLeftIconWidth - cellRightIconWidth, + height, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); + const image: IImage = markCellMedia( + createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }), + 'image' + ); + image.name = 'image'; + image.keepAspectRatio = true; + (image as any).isAudioIcon = true; + image.textAlign = textAlign; + image.textBaseline = textBaseline; + cellGroup.appendChild(image); + return cellGroup; + } const video = document.createElement('video'); video.muted = true; video.playsInline = true; @@ -265,7 +309,7 @@ export function createVideoCellGroup( clearVideoLoadTimer(); releaseVideoResource(video); }; - const isCurrentImage = (): boolean => cellGroup.getChildByName('image', true) === image; + const isCurrentImage = (): boolean => getCellMediaImage(cellGroup) === image; const setVideoDamageImage = (): void => { const regedIcons = icons.get(); const damageIcon = regedIcons.video_damage_pic || regedIcons.damage_pic; @@ -308,7 +352,7 @@ export function createVideoCellGroup( }); if (isMerge) { - updateImageDxDy( + updateVideoMediaDxDy( cellGroup.mergeStartCol, cellGroup.mergeEndCol, cellGroup.mergeStartRow, @@ -343,6 +387,10 @@ export function createVideoCellGroup( if (!scenegraph) { return; } + if (video.videoWidth <= 0 || video.videoHeight <= 0) { + handleVideoLoadFail(); + return; + } if (imageAutoSizing) { _adjustWidthHeight(col, row, video.videoWidth, video.videoHeight, scenegraph, padding, cellGroup); } @@ -388,7 +436,7 @@ export function createVideoCellGroup( } if (isMerge) { - updateImageDxDy( + updateVideoMediaDxDy( cellGroup.mergeStartCol, cellGroup.mergeEndCol, cellGroup.mergeStartRow, @@ -413,8 +461,7 @@ export function createVideoCellGroup( top + (height > image.attribute.height ? image.attribute.y - top + image.attribute.height / 2 : height / 2); // get dx dy of image graphic for merge cell - const imageGraphic = cellGroup.getChildByName('image', true); - const { dx, dy } = imageGraphic.attribute; + const { dx, dy } = image.attribute; const playIcon: Icon = new Icon({ x: anchorX - iconSize / 2, @@ -426,6 +473,7 @@ export function createVideoCellGroup( dx, dy }); + markCellMedia(playIcon, 'play-icon'); playIcon.name = 'play-icon'; cellGroup.appendChild(playIcon); if (shouldSnapshot && snapshotVideoFirstFrame(video, image, table)) { @@ -437,14 +485,17 @@ export function createVideoCellGroup( video.addEventListener('error', handleVideoLoadFail); video.addEventListener('abort', handleVideoLoadFail); - const image: IImage = createImage({ - x: padding[3], - y: padding[0], - width: width - padding[1] - padding[3], - height: height - padding[2] - padding[0], - image: video as any, - cursor: 'pointer' as Cursor - }); + const image: IImage = markCellMedia( + createImage({ + x: padding[3], + y: padding[0], + width: Math.max(1, width - padding[1] - padding[3]), + height: Math.max(1, height - padding[2] - padding[0]), + image: video as any, + cursor: 'pointer' as Cursor + }), + 'image' + ); image.name = 'image'; image.keepAspectRatio = keepAspectRatio; image.textAlign = textAlign; diff --git a/packages/vtable/src/scenegraph/group-creater/column-helper.ts b/packages/vtable/src/scenegraph/group-creater/column-helper.ts index 9896dd5fc1..cda6ccb850 100644 --- a/packages/vtable/src/scenegraph/group-creater/column-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/column-helper.ts @@ -11,7 +11,13 @@ import type { import { Group } from '../graphic/group'; import { getProp, getRawProp } from '../utils/get-prop'; import type { MergeMap } from '../scenegraph'; -import { createCell, dealWithMergeCellSize, resizeCellGroup } from './cell-helper'; +import { + createCell, + dealWithMergeCellSize, + isPromiseCellUpdateCurrent, + nextPromiseCellUpdateToken, + resizeCellGroup +} from './cell-helper'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; import { getCellCornerRadius, getStyleTheme } from '../../core/tableHelper'; import { isPromise } from '../../tools/helper'; @@ -202,13 +208,13 @@ export function createComplexColumn( const type = isVtableMerge || isCustomMerge ? 'text' : table.getCellType(col, row); // deal with promise data if (isPromise(value)) { - createEmptyCellGroup(col, row, 0, y, cellWidth, cellHeight, columnGroup); + const cellGroup = createEmptyCellGroup(col, row, 0, y, cellWidth, cellHeight, columnGroup); + const updateToken = nextPromiseCellUpdateToken(cellGroup); dealPromiseData( value, table, callCreateCellForPromiseValue.bind(null, { type, - value, define, table, col, @@ -223,7 +229,10 @@ export function createComplexColumn( cellLocation, range, customResult, - defaultRowHeight + defaultRowHeight, + promiseValue: value, + oldCellGroup: cellGroup, + updateToken }) ); columnGroup.updateColumnRowNumber(row); @@ -336,13 +345,12 @@ export function getColumnGroupTheme( columnTheme.group.height = 0; return { theme: columnTheme, hasFunctionPros }; } -function callCreateCellForPromiseValue(createCellArgs: any) { +function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) { let padding; let textAlign; let textBaseline; const { type, - value, define, table, col, @@ -357,8 +365,14 @@ function callCreateCellForPromiseValue(createCellArgs: any) { customStyle, range, customResult, - defaultRowHeight + defaultRowHeight, + promiseValue, + oldCellGroup, + updateToken } = createCellArgs; + if (!isPromiseCellUpdateCurrent(table, col, row, oldCellGroup, updateToken)) { + return; + } const cellStyle = customStyle || table._getCellStyle(range ? range.start.col : col, range ? range.start.row : row); const cellTheme = getStyleTheme( cellStyle, @@ -386,7 +400,7 @@ function callCreateCellForPromiseValue(createCellArgs: any) { } createCell( type, - value, + promiseValue, define, table, col, @@ -402,7 +416,8 @@ function callCreateCellForPromiseValue(createCellArgs: any) { mayHaveIcon, cellTheme, range, - customResult + customResult, + resolvedValue ); } function dealMerge(range: CellRange, mergeMap: MergeMap, table: BaseTableAPI, forceUpdate: boolean) { @@ -452,4 +467,5 @@ function createEmptyCellGroup( cellGroup.col = col; cellGroup.row = row; columnGroup.addChild(cellGroup); + return cellGroup; } diff --git a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts index eba40766d9..7d9a931d2e 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts @@ -69,14 +69,15 @@ export class SceneProxy { constructor(table: BaseTableAPI) { this.table = table; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); if (this.table.isPivotChart()) { // this.rowLimit = 100; // this.colLimit = 100; - this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / table.defaultRowHeight)); + this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / defaultRowHeight)); this.colLimit = Math.max(100, Math.ceil((table.tableNoFrameWidth * 2) / table.defaultColWidth)); } else if (this.table.isAutoRowHeight(table.columnHeaderLevelCount)) { // this.rowLimit = 100; - this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / table.defaultRowHeight)); + this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / defaultRowHeight)); } else if (this.table.widthMode === 'autoWidth') { // this.colLimit = 100; this.colLimit = Math.max(100, Math.ceil((table.tableNoFrameWidth * 2) / table.defaultColWidth)); @@ -155,7 +156,7 @@ export class SceneProxy { this.totalRow = this.bodyTopRow + totalActualBodyRowCount - 1; // 目标渐进完成的row this.rowStart = this.bodyTopRow; this.rowEnd = this.totalRow; // temp for first screen, will replace in createGroupForFirstScreen() - const defaultRowHeight = this.table.defaultRowHeight; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); // const defaultRowHeight = getDefaultWidth(this.table); this.taskRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight) * 1; @@ -206,13 +207,21 @@ export class SceneProxy { this.screenColCount = Math.ceil(this.table.tableNoFrameWidth / defaultColWidth); this.firstScreenColLimit = this.bodyLeftCol + Math.min(this.colLimit, Math.ceil(widthLimit / defaultColWidth)); - const defaultRowHeight = this.table.defaultRowHeight; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); this.taskRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight) * 1; const heightLimit = this.table.tableNoFrameHeight * 5; this.screenRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight); this.firstScreenRowLimit = this.bodyTopRow + Math.min(this.rowLimit, Math.ceil(heightLimit / defaultRowHeight)); } + getEstimatedRowHeightForProxy() { + const minSingleRowHeight = this.table.options.customConfig?.minSingleRowHeight; + if (this.table.heightMode === 'autoHeight') { + return isNumber(minSingleRowHeight) && minSingleRowHeight > 0 ? minSingleRowHeight : 2; + } + return this.table.defaultRowHeight; + } + createGroupForFirstScreen( cornerHeaderGroup: Group, colHeaderGroup: Group, diff --git a/packages/vtable/src/scenegraph/icon/icon-update.ts b/packages/vtable/src/scenegraph/icon/icon-update.ts index 050f71df9a..b0fcc26291 100644 --- a/packages/vtable/src/scenegraph/icon/icon-update.ts +++ b/packages/vtable/src/scenegraph/icon/icon-update.ts @@ -195,6 +195,7 @@ export function setIconHoverStyle(baseIcon: Icon, col: number, row: number, cell }, placement: baseIcon.tooltip.placement }, + appearDelay: baseIcon.tooltip.appearDelay, disappearDelay: baseIcon.tooltip.disappearDelay, style: Object.assign({}, scene.table.internalProps.theme?.tooltipStyle, baseIcon.tooltip?.style) }; diff --git a/packages/vtable/src/scenegraph/layout/compute-col-width.ts b/packages/vtable/src/scenegraph/layout/compute-col-width.ts index a2f83fbade..b7940eefe0 100644 --- a/packages/vtable/src/scenegraph/layout/compute-col-width.ts +++ b/packages/vtable/src/scenegraph/layout/compute-col-width.ts @@ -436,11 +436,12 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) cellRange = table.getCellRange(col, row); spanCol = cellRange.end.col - cellRange.start.col + 1; } + const skipCellValue = shouldSkipCustomRenderCellValueForComputation(col, row, table); const arg = { col: cellRange?.start.col ?? col, row: cellRange?.start.row ?? row, - dataValue: table.getCellOriginValue(col, row), - value: table.getCellValue(col, row), + dataValue: skipCellValue ? undefined : table.getCellOriginValue(col, row), + value: skipCellValue ? undefined : table.getCellValue(col, row), rect: getCellRect(col, row, table), table, originCol: col, @@ -490,6 +491,14 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) return undefined; } +function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { + return ( + table.isListTable() && + !table.isHeader(col, row) && + !(table.internalProps.dataSource as any)?.dataSourceObj?.records + ); +} + /** * @description: 计算指标相关列宽 * @param {number} col diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index dade83a49b..7ae9074b67 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -640,11 +640,12 @@ function computeCustomRenderHeight(col: number, row: number, table: BaseTableAPI cellRange = table.getCellRange(col, row); spanRow = cellRange.end.row - cellRange.start.row + 1; } + const skipCellValue = shouldSkipCustomRenderCellValueForComputation(col, row, table); const arg = { col: cellRange?.start.col ?? col, row: cellRange?.start.row ?? row, - dataValue: table.getCellOriginValue(col, row), - value: table.getCellValue(col, row), + dataValue: skipCellValue ? undefined : table.getCellOriginValue(col, row), + value: skipCellValue ? undefined : table.getCellValue(col, row), rect: getCellRect(col, row, table), table, originCol: col, @@ -685,13 +686,19 @@ function computeCustomRenderHeight(col: number, row: number, table: BaseTableAPI height += padding[0] + padding[2]; } return { - height: height / spanRow, + height: getMergedCellSingleRowHeight(height / spanRow, spanRow, table), renderDefault }; } return undefined; } +function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { + return ( + table.isListTable() && !table.isHeader(col, row) && !(table.internalProps.dataSource as any)?.dataSourceObj?.records + ); +} + /** * @description: compute text height * @param {number} col @@ -925,7 +932,20 @@ function computeTextHeight(col: number, row: number, cellType: ColumnTypeOption, } } } - return (Math.max(maxHeight, iconHeight) + padding[0] + padding[2]) / spanRow; + return getMergedCellSingleRowHeight( + (Math.max(maxHeight, iconHeight) + padding[0] + padding[2]) / spanRow, + spanRow, + table + ); +} + +function getMergedCellSingleRowHeight(height: number, spanRow: number, table: BaseTableAPI) { + if (spanRow <= 1) { + return height; + } + const configuredMinHeight = table.options.customConfig?.minSingleRowHeight; + const minSingleRowHeight = isNumber(configuredMinHeight) && configuredMinHeight > 0 ? configuredMinHeight : 2; + return Math.max(height, minSingleRowHeight); } function getCellRect(col: number, row: number, table: BaseTableAPI) { diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index dc20649851..55a70bc19b 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -189,7 +189,7 @@ export function updateCellHeight( getStyleTheme(headerStyle, scene.table, col, row, getProp).theme, false ); - } else if (type === 'image' || type === 'video') { + } else if (type === 'image' || type === 'video' || type === 'audio') { updateImageCellContentWhileResize(cell, col, row, 0, detaY, scene.table); } else if (cell.firstChild?.name === 'axis') { (cell.firstChild as any)?.originAxis.resize(cell.attribute.width, cell.attribute.height); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 14dd4b9f32..ac06b5a68e 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -330,7 +330,7 @@ function updateCellWidth( getStyleTheme(headerStyle, scene.table, col, row, getProp).theme, false ); - } else if (type === 'image' || type === 'video') { + } else if (type === 'image' || type === 'video' || type === 'audio') { // // 只更新背景边框 // const rect = cell.firstChild as Rect; // rect.setAttribute('width', cell.attribute.width); diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index f3dc9a32a8..fb43b590b6 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -1363,78 +1363,6 @@ export class Scenegraph { height: tableHeight } as any); - if (this.tableGroup.border) { - const rectAttributes = this.tableGroup.border?.attribute; - let borderTop; - let borderRight; - let borderBottom; - let borderLeft; - if ((rectAttributes as any)?.strokeArrayWidth) { - borderTop = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[0] - : (rectAttributes.lineWidth as number) ?? 0; - borderRight = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[1] - : (rectAttributes.lineWidth as number) ?? 0; - borderBottom = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[2] - : (rectAttributes.lineWidth as number) ?? 0; - borderLeft = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[3] - : (rectAttributes.lineWidth as number) ?? 0; - } else { - borderTop = (rectAttributes?.lineWidth as number) ?? 0; - borderRight = (rectAttributes?.lineWidth as number) ?? 0; - borderBottom = (rectAttributes?.lineWidth as number) ?? 0; - borderLeft = (rectAttributes?.lineWidth as number) ?? 0; - } - if (this.tableGroup.border.type === 'rect') { - if (this.table.theme.frameStyle?.innerBorder) { - this.tableGroup.border.setAttributes({ - x: this.table.tableX + borderLeft / 2, - y: this.table.tableY + borderTop / 2, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - } else { - this.tableGroup.border.setAttributes({ - x: this.table.tableX - borderLeft / 2, - y: this.table.tableY - borderTop / 2, - width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, - height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 - }); - } - } else if (this.tableGroup.border.type === 'group') { - if (this.table.theme.frameStyle?.innerBorder) { - this.tableGroup.border.setAttributes({ - x: this.table.tableX + borderLeft / 2, - y: this.table.tableY + borderTop / 2, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - (this.tableGroup.border.firstChild as IRect)?.setAttributes({ - x: 0, - y: 0, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - } else { - this.tableGroup.border.setAttributes({ - x: this.table.tableX - borderLeft / 2, - y: this.table.tableY - borderTop / 2, - width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, - height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 - }); - (this.tableGroup.border.firstChild as IRect)?.setAttributes({ - x: borderLeft / 2, - y: borderTop / 2, - width: this.tableGroup.attribute.width, - height: this.tableGroup.attribute.height - }); - } - } - } - const hasFrozenCols = this.table.frozenColCount > 0; const hasRightFrozenCols = this.table.rightFrozenColCount > 0; const hasFrozenRows = this.table.frozenRowCount > 0; @@ -1510,10 +1438,106 @@ export class Scenegraph { }); } + if (hasBottomFrozenRows && !this.table.containerFit?.height) { + const actualContentBottom = Math.max( + this.colHeaderGroup.attribute.y + this.colHeaderGroup.attribute.height, + this.cornerHeaderGroup.attribute.y + this.cornerHeaderGroup.attribute.height, + this.rightTopCornerGroup.attribute.y + this.rightTopCornerGroup.attribute.height, + this.rowHeaderGroup.attribute.y + this.rowHeaderGroup.attribute.height, + this.bodyGroup.attribute.y + this.bodyGroup.attribute.height, + this.rightFrozenGroup.attribute.y + this.rightFrozenGroup.attribute.height, + this.leftBottomCornerGroup.attribute.y + this.leftBottomCornerGroup.attribute.height, + this.bottomFrozenGroup.attribute.y + this.bottomFrozenGroup.attribute.height, + this.rightBottomCornerGroup.attribute.y + this.rightBottomCornerGroup.attribute.height + ); + + if (actualContentBottom > 0 && actualContentBottom < this.tableGroup.attribute.height) { + this.tableGroup.setAttribute('height', actualContentBottom); + } + } + + this.updateTableGroupBorder(); + // update dom container size this.updateDomContainer(); } + updateTableGroupBorder() { + if (!this.tableGroup.border) { + return; + } + + const rectAttributes = this.tableGroup.border?.attribute; + let borderTop; + let borderRight; + let borderBottom; + let borderLeft; + if ((rectAttributes as any)?.strokeArrayWidth) { + borderTop = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[0] + : (rectAttributes.lineWidth as number) ?? 0; + borderRight = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[1] + : (rectAttributes.lineWidth as number) ?? 0; + borderBottom = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[2] + : (rectAttributes.lineWidth as number) ?? 0; + borderLeft = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[3] + : (rectAttributes.lineWidth as number) ?? 0; + } else { + borderTop = (rectAttributes?.lineWidth as number) ?? 0; + borderRight = (rectAttributes?.lineWidth as number) ?? 0; + borderBottom = (rectAttributes?.lineWidth as number) ?? 0; + borderLeft = (rectAttributes?.lineWidth as number) ?? 0; + } + if (this.tableGroup.border.type === 'rect') { + if (this.table.theme.frameStyle?.innerBorder) { + this.tableGroup.border.setAttributes({ + x: this.table.tableX + borderLeft / 2, + y: this.table.tableY + borderTop / 2, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + } else { + this.tableGroup.border.setAttributes({ + x: this.table.tableX - borderLeft / 2, + y: this.table.tableY - borderTop / 2, + width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, + height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 + }); + } + } else if (this.tableGroup.border.type === 'group') { + if (this.table.theme.frameStyle?.innerBorder) { + this.tableGroup.border.setAttributes({ + x: this.table.tableX + borderLeft / 2, + y: this.table.tableY + borderTop / 2, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + (this.tableGroup.border.firstChild as IRect)?.setAttributes({ + x: 0, + y: 0, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + } else { + this.tableGroup.border.setAttributes({ + x: this.table.tableX - borderLeft / 2, + y: this.table.tableY - borderTop / 2, + width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, + height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 + }); + (this.tableGroup.border.firstChild as IRect)?.setAttributes({ + x: borderLeft / 2, + y: borderTop / 2, + width: this.tableGroup.attribute.width, + height: this.tableGroup.attribute.height + }); + } + } + } + updateRowHeight(row: number, detaY: number, skipTableHeightMap?: boolean) { detaY = Math.round(detaY); updateRowHeight(this, row, detaY, skipTableHeightMap); @@ -2318,7 +2342,7 @@ export class Scenegraph { const type = isVtableMerge ? 'text' : this.table.getCellType(col, row); const cellGroup = this.getCell(col, row); - if (type === 'image' || type === 'video') { + if (type === 'image' || type === 'video' || type === 'audio') { updateImageCellContentWhileResize(cellGroup, col, row, 0, 0, this.table); } } diff --git a/packages/vtable/src/scenegraph/stick-text/index.ts b/packages/vtable/src/scenegraph/stick-text/index.ts index 3c09560f7a..094c8b7133 100644 --- a/packages/vtable/src/scenegraph/stick-text/index.ts +++ b/packages/vtable/src/scenegraph/stick-text/index.ts @@ -1,11 +1,19 @@ import type { IGraphic, Image, Text } from '@src/vrender'; import type { BaseTableAPI } from '../../ts-types/base-table'; import type { Group } from '../graphic/group'; -import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; import type { ITextStyleOption, StickCell } from '../../ts-types'; import { isNumber, min } from '@visactor/vutils'; import { getCellMergeRange } from '../../tools/merge-range'; +let stickTextUpdateId = 0; + +type VisibleCellRange = { + colStart: number; + colEnd: number; + rowStart: number; + rowEnd: number; +}; + export function resetTextStick(table: BaseTableAPI) { // reset const { changedCells } = table.internalProps.stick; // changedCells only cache one time @@ -24,6 +32,8 @@ export function resetTextStick(table: BaseTableAPI) { export function handleTextStick(table: BaseTableAPI) { const changedCells = resetTextStick(table); + const horizontalUpdateId = ++stickTextUpdateId; + const verticalUpdateId = ++stickTextUpdateId; const { scrollTop, scrollLeft, frozenRowCount, frozenColCount } = table; const frozenRowsHeight = table.getFrozenRowsHeight(); @@ -61,7 +71,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameWidth - table.getRightFrozenColsWidth() + table.tableX, changedCells, style?.textStickBaseOnAlign, - table + table, + horizontalUpdateId, + { colStart, colEnd, rowStart: row, rowEnd: row } ); } }); @@ -74,11 +86,7 @@ export function handleTextStick(table: BaseTableAPI) { } [rowStart, rowEnd].forEach((row: number) => { const style = table._getCellStyle(col, row); - if ( - style?.textStick && - // (table.internalProps.layoutMap as PivotHeaderLayoutMap).rowHierarchyType !== 'tree' && - style?.textStick !== 'horizontal' - ) { + if (style?.textStick && style?.textStick !== 'horizontal') { const cellGroup = table.scenegraph.getCell(col, row); // adjust cell vertical adjustCellContentVerticalLayout( @@ -87,7 +95,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameHeight - table.getBottomFrozenRowsHeight() + table.tableY, changedCells, style?.textStickBaseOnAlign, - table + table, + verticalUpdateId, + { colStart: col, colEnd: col, rowStart, rowEnd } ); } }); @@ -109,7 +119,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameHeight - table.getBottomFrozenRowsHeight() + table.tableY, changedCells, style?.textStickBaseOnAlign, - table + table, + verticalUpdateId, + { colStart: col, colEnd: col, rowStart, rowEnd } ); } }); @@ -129,7 +141,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameWidth - table.getRightFrozenColsWidth() + table.tableX, changedCells, style?.textStickBaseOnAlign, - table + table, + horizontalUpdateId, + { colStart, colEnd, rowStart: row, rowEnd: row } ); } }); @@ -147,27 +161,13 @@ function adjustCellContentVerticalLayout( maxTop: number, changedCells: Map, textStickBaseOnAlign: boolean | undefined, - table: BaseTableAPI + table: BaseTableAPI, + updateId: number, + visibleRange: VisibleCellRange ) { - if ( - isNumber(cellGroup.mergeStartCol) && - isNumber(cellGroup.mergeStartRow) && - isNumber(cellGroup.mergeEndCol) && - isNumber(cellGroup.mergeEndRow) - ) { - const { colStart, colEnd, rowStart, rowEnd } = getCellMergeRange(cellGroup, table.scenegraph); - for (let col = colStart; col <= colEnd; col++) { - for (let row = rowStart; row <= rowEnd; row++) { - const singleCellGroup = table.scenegraph.getCell(col, row); - if (singleCellGroup.role !== 'cell') { - continue; - } - dealVertical(singleCellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); - } - } - } else { - dealVertical(cellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); - } + dealMergedCellGroups(cellGroup, table, visibleRange, updateId, 'verticalStickUpdateId', singleCellGroup => { + dealVertical(singleCellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); + }); } function dealVertical( @@ -260,7 +260,22 @@ function adjustCellContentHorizontalLayout( maxLeft: number, changedCells: Map, textStickBaseOnAlign: boolean | undefined, - table: BaseTableAPI + table: BaseTableAPI, + updateId: number, + visibleRange: VisibleCellRange +) { + dealMergedCellGroups(cellGroup, table, visibleRange, updateId, 'horizontalStickUpdateId', singleCellGroup => { + dealHorizontal(singleCellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + }); +} + +function dealMergedCellGroups( + cellGroup: Group, + table: BaseTableAPI, + visibleRange: VisibleCellRange, + updateId: number, + updateIdKey: 'verticalStickUpdateId' | 'horizontalStickUpdateId', + dealCell: (cellGroup: Group) => void ) { if ( isNumber(cellGroup.mergeStartCol) && @@ -269,17 +284,27 @@ function adjustCellContentHorizontalLayout( isNumber(cellGroup.mergeEndRow) ) { const { colStart, colEnd, rowStart, rowEnd } = getCellMergeRange(cellGroup, table.scenegraph); - for (let col = colStart; col <= colEnd; col++) { - for (let row = rowStart; row <= rowEnd; row++) { + const visibleColStart = Math.max(colStart, visibleRange.colStart); + const visibleColEnd = Math.min(colEnd, visibleRange.colEnd); + const visibleRowStart = Math.max(rowStart, visibleRange.rowStart); + const visibleRowEnd = Math.min(rowEnd, visibleRange.rowEnd); + + for (let col = visibleColStart; col <= visibleColEnd; col++) { + for (let row = visibleRowStart; row <= visibleRowEnd; row++) { const singleCellGroup = table.scenegraph.getCell(col, row); - if (singleCellGroup.role !== 'cell') { + if (singleCellGroup.role !== 'cell' || (singleCellGroup as any)[updateIdKey] === updateId) { continue; } - dealHorizontal(singleCellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + (singleCellGroup as any)[updateIdKey] = updateId; + dealCell(singleCellGroup); } } } else { - dealHorizontal(cellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + if ((cellGroup as any)[updateIdKey] === updateId) { + return; + } + (cellGroup as any)[updateIdKey] = updateId; + dealCell(cellGroup); } } diff --git a/packages/vtable/src/scenegraph/utils/deal-promise-data.ts b/packages/vtable/src/scenegraph/utils/deal-promise-data.ts index 93ad8503d2..8058feff88 100644 --- a/packages/vtable/src/scenegraph/utils/deal-promise-data.ts +++ b/packages/vtable/src/scenegraph/utils/deal-promise-data.ts @@ -1,9 +1,9 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; -export function dealPromiseData(dataPromise: Promise, tabel: BaseTableAPI, callback: () => void) { +export function dealPromiseData(dataPromise: Promise, tabel: BaseTableAPI, callback: (value: T) => void) { dataPromise - .then(() => { - callback(); + .then(value => { + callback(value); tabel.scenegraph.updateNextFrame(); }) .catch((err: Error) => { diff --git a/packages/vtable/src/state/cell-move/index.ts b/packages/vtable/src/state/cell-move/index.ts index 04658a7cd0..43e0c69ea5 100644 --- a/packages/vtable/src/state/cell-move/index.ts +++ b/packages/vtable/src/state/cell-move/index.ts @@ -232,6 +232,7 @@ export function endMoveCol(state: StateManager): boolean { } } if ( + state.table.isListTable() && !(state.table as ListTable).transpose && ((state.table.internalProps.layoutMap as SimpleHeaderLayoutMap).isSeriesNumberInBody( state.columnMove.colSource, @@ -239,8 +240,11 @@ export function endMoveCol(state: StateManager): boolean { ) || state.columnMove.movingColumnOrRow === 'row') ) { + const sourceRecordPath = (state.table as ListTable).getRecordIndexByCell(0, moveContext.sourceIndex); + const targetRecordPath = (state.table as ListTable).getRecordIndexByCell(0, moveContext.targetIndex); state.table.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); - state.changeCheckboxAndRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + state.changeCheckboxOrder(sourceRecordPath, targetRecordPath); + state.changeRadioOrder(sourceRecordPath, targetRecordPath); } // clear columns width and rows height cache if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/state/checkbox/checkbox.ts b/packages/vtable/src/state/checkbox/checkbox.ts index 5b4da0ea5e..5d544c78b7 100644 --- a/packages/vtable/src/state/checkbox/checkbox.ts +++ b/packages/vtable/src/state/checkbox/checkbox.ts @@ -356,68 +356,90 @@ export function setCellCheckboxStateByAttribute( } } -export function changeCheckboxOrder(sourceIndex: number, targetIndex: number, state: StateManager) { - const { checkedState, table } = state; - let source; - let target; - if (table.internalProps.transpose) { - sourceIndex = table.getRecordShowIndexByCell(sourceIndex, 0); - targetIndex = table.getRecordShowIndexByCell(targetIndex, 0); - } else { - // sourceIndex = table.getRecordShowIndexByCell(0, sourceIndex); - // targetIndex = table.getRecordShowIndexByCell(0, targetIndex); +type RecordPath = number | number[]; + +function normalizeRecordPath(recordPath: RecordPath): number[] { + return isArray(recordPath) ? recordPath : [recordPath]; +} + +function parseCheckboxStateKey(key: string | number): number[] { + return isNumber(key) ? [key] : key.split(',').map(item => Number(item)); +} - source = table.isPivotTable() ? undefined : (table as any).getRecordIndexByCell(0, sourceIndex); - target = table.isPivotTable() ? undefined : (table as any).getRecordIndexByCell(0, targetIndex); +function stringifyRecordPath(recordPath: number[]): string { + return recordPath.toString(); +} + +function isSameRecordPath(source: number[], target: number[]): boolean { + return source.length === target.length && source.every((item, index) => item === target[index]); +} + +function isSameParentPath(source: number[], target: number[]): boolean { + if (source.length !== target.length) { + return false; } + return source.slice(0, -1).every((item, index) => item === target[index]); +} - if (isNumber(source) && isNumber(target)) { - sourceIndex = source; - targetIndex = target; - if (sourceIndex > targetIndex) { - const sourceRecord = checkedState.get(sourceIndex.toString()); - for (let i = sourceIndex; i > targetIndex; i--) { - // checkedState[i] = checkedState[i - 1]; - checkedState.set(i.toString(), checkedState.get((i - 1).toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(targetIndex.toString(), sourceRecord); - } else if (sourceIndex < targetIndex) { - const sourceRecord = checkedState.get(sourceIndex.toString()); - for (let i = sourceIndex; i < targetIndex; i++) { - // checkedState[i] = checkedState[i + 1]; - checkedState.set(i.toString(), checkedState.get((i + 1).toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(targetIndex.toString(), sourceRecord); - } - } else if (isArray(source) && isArray(target)) { - sourceIndex = source[source.length - 1]; - targetIndex = target[target.length - 1]; - if (sourceIndex > targetIndex) { - const sourceRecord = checkedState.get(source.toString()); - for (let i = sourceIndex; i > targetIndex; i--) { - const now = [...source]; - now[now.length - 1] = i; - const last = [...source]; - last[last.length - 1] = i - 1; - checkedState.set(now.toString(), checkedState.get(last.toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(target.toString(), sourceRecord); - } else if (sourceIndex < targetIndex) { - const sourceRecord = checkedState.get(source.toString()); - for (let i = sourceIndex; i < targetIndex; i++) { - const now = [...source]; - now[now.length - 1] = i; - const next = [...source]; - next[next.length - 1] = i + 1; - checkedState.set(now.toString(), checkedState.get(next.toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(target.toString(), sourceRecord); - } +function isDescendantPath(path: number[], ancestor: number[]): boolean { + return ancestor.length <= path.length && ancestor.every((item, index) => item === path[index]); +} + +function getMovedRecordPath(path: number[], source: number[], target: number[]): number[] { + return target.concat(path.slice(source.length)); +} + +function getShiftedSiblingPath(path: number[], parent: number[], sourceIndex: number, targetIndex: number): number[] { + if (path.length <= parent.length || !isDescendantPath(path.slice(0, parent.length), parent)) { + return path; + } + + const siblingIndex = path[parent.length]; + if (sourceIndex < targetIndex && siblingIndex > sourceIndex && siblingIndex <= targetIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex - 1; + return shifted; + } + if (sourceIndex > targetIndex && siblingIndex >= targetIndex && siblingIndex < sourceIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex + 1; + return shifted; + } + return path; +} + +export function changeCheckboxOrder(sourceRecordPath: RecordPath, targetRecordPath: RecordPath, state: StateManager) { + const { checkedState } = state; + const sourcePath = normalizeRecordPath(sourceRecordPath); + const targetPath = normalizeRecordPath(targetRecordPath); + + // DataSource.changeOrder only reorders records under the same parent; cross-parent drops are ignored here. + if (isSameRecordPath(sourcePath, targetPath) || !isSameParentPath(sourcePath, targetPath)) { + return; } + + const parentPath = sourcePath.slice(0, -1); + const sourceIndex = sourcePath[sourcePath.length - 1]; + const targetIndex = targetPath[targetPath.length - 1]; + const nextCheckedState = new Map>(); + + checkedState.forEach((value, key) => { + const keyPath = parseCheckboxStateKey(key); + let nextPath: number[]; + + if (isDescendantPath(keyPath, sourcePath)) { + nextPath = getMovedRecordPath(keyPath, sourcePath, targetPath); + } else { + nextPath = getShiftedSiblingPath(keyPath, parentPath, sourceIndex, targetIndex); + } + + nextCheckedState.set(stringifyRecordPath(nextPath), value); + }); + + checkedState.clear(); + nextCheckedState.forEach((value, key) => { + checkedState.set(key, value); + }); } export function getGroupCheckboxState(table: BaseTableAPI) { diff --git a/packages/vtable/src/state/radio/radio.ts b/packages/vtable/src/state/radio/radio.ts index f721a8617c..4225a0a293 100644 --- a/packages/vtable/src/state/radio/radio.ts +++ b/packages/vtable/src/state/radio/radio.ts @@ -1,9 +1,12 @@ -import { isBoolean, isNumber, isObject, isValid } from '@visactor/vutils'; +import { isBoolean, isNumber, isObject, isString, isValid } from '@visactor/vutils'; import type { StateManager } from '../state'; import type { BaseTableAPI } from '../../ts-types/base-table'; import type { ColumnDefine } from '../../ts-types'; import type { Radio } from '@src/vrender'; +type RecordPath = number | number[]; +type RadioStateValue = boolean | number | string | number[] | Record; + export function setRadioState( col: number, row: number, @@ -139,31 +142,102 @@ export function setCellRadioState(col: number, row: number, index: number | unde } } -export function changeRadioOrder(sourceIndex: number, targetIndex: number, state: StateManager) { - const { radioState, table } = state; - if (table.internalProps.transpose) { - sourceIndex = table.getRecordShowIndexByCell(sourceIndex, 0); - targetIndex = table.getRecordShowIndexByCell(targetIndex, 0); - } else { - sourceIndex = table.getRecordShowIndexByCell(0, sourceIndex); - targetIndex = table.getRecordShowIndexByCell(0, targetIndex); +function normalizeRecordPath(recordPath: RecordPath): number[] { + return Array.isArray(recordPath) ? recordPath : [recordPath]; +} + +function parseRecordPathKey(key: string | number): number[] { + return isNumber(key) ? [key] : key.split(',').map(item => Number(item)); +} + +function isSameRecordPath(source: number[], target: number[]): boolean { + return source.length === target.length && source.every((item, index) => item === target[index]); +} + +function isSameParentPath(source: number[], target: number[]): boolean { + if (source.length !== target.length) { + return false; } - // if (sourceIndex !== targetIndex) { - // const sourceRecord = radioState[sourceIndex]; - // radioState[sourceIndex] = radioState[targetIndex]; - // radioState[targetIndex] = sourceRecord; - // } - if (sourceIndex > targetIndex) { - const sourceRecord = radioState[sourceIndex]; - for (let i = sourceIndex; i > targetIndex; i--) { - radioState[i] = radioState[i - 1]; - } - radioState[targetIndex] = sourceRecord; - } else if (sourceIndex < targetIndex) { - const sourceRecord = radioState[sourceIndex]; - for (let i = sourceIndex; i < targetIndex; i++) { - radioState[i] = radioState[i + 1]; - } - radioState[targetIndex] = sourceRecord; + return source.slice(0, -1).every((item, index) => item === target[index]); +} + +function isDescendantPath(path: number[], ancestor: number[]): boolean { + return ancestor.length <= path.length && ancestor.every((item, index) => item === path[index]); +} + +function getMovedRecordPath(path: number[], source: number[], target: number[]): number[] { + return target.concat(path.slice(source.length)); +} + +function getShiftedSiblingPath(path: number[], parent: number[], sourceIndex: number, targetIndex: number): number[] { + if (path.length <= parent.length || !isDescendantPath(path.slice(0, parent.length), parent)) { + return path; + } + + const siblingIndex = path[parent.length]; + if (sourceIndex < targetIndex && siblingIndex > sourceIndex && siblingIndex <= targetIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex - 1; + return shifted; } + if (sourceIndex > targetIndex && siblingIndex >= targetIndex && siblingIndex < sourceIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex + 1; + return shifted; + } + return path; +} + +function getChangedRecordPath(path: number[], sourcePath: number[], targetPath: number[]): number[] { + const parentPath = sourcePath.slice(0, -1); + const sourceIndex = sourcePath[sourcePath.length - 1]; + const targetIndex = targetPath[targetPath.length - 1]; + + if (isDescendantPath(path, sourcePath)) { + return getMovedRecordPath(path, sourcePath, targetPath); + } + return getShiftedSiblingPath(path, parentPath, sourceIndex, targetIndex); +} + +function getRadioStateValue(recordPath: number[]): number | number[] { + return recordPath.length === 1 ? recordPath[0] : recordPath; +} + +function getRadioStateKey(recordPath: number[]): string { + return recordPath.toString(); +} + +function changeRadioStateValue(value: RadioStateValue, sourcePath: number[], targetPath: number[]): RadioStateValue { + if (isNumber(value)) { + return getRadioStateValue(getChangedRecordPath([value], sourcePath, targetPath)); + } + if (Array.isArray(value)) { + return getRadioStateValue(getChangedRecordPath(value, sourcePath, targetPath)); + } + if (isString(value)) { + return getRadioStateKey(getChangedRecordPath(parseRecordPathKey(value), sourcePath, targetPath)); + } + if (isObject(value)) { + const nextValue: Record = {}; + Object.keys(value).forEach(key => { + const nextPath = getChangedRecordPath(parseRecordPathKey(key), sourcePath, targetPath); + nextValue[getRadioStateKey(nextPath)] = value[key]; + }); + return nextValue; + } + return value; +} + +export function changeRadioOrder(sourceRecordPath: RecordPath, targetRecordPath: RecordPath, state: StateManager) { + const { radioState } = state; + const sourcePath = normalizeRecordPath(sourceRecordPath); + const targetPath = normalizeRecordPath(targetRecordPath); + + if (isSameRecordPath(sourcePath, targetPath) || !isSameParentPath(sourcePath, targetPath)) { + return; + } + + Object.keys(radioState).forEach(field => { + radioState[field] = changeRadioStateValue(radioState[field] as RadioStateValue, sourcePath, targetPath); + }); } diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 88b5c974e2..534370b9c5 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -222,7 +222,10 @@ export class StateManager { _headerCheckFuncs: Record = {}; - radioState: Record> = {}; + radioState: Record< + string | number, + boolean | number | string | number[] | Record + > = {}; // 供滚动重置为default使用 resetInteractionState = debounce((state?: InteractionState) => { this.updateInteractionState(state ?? InteractionState.default); @@ -746,6 +749,7 @@ export class StateManager { inlineIcon.tooltip?.style, inlineIcon.attribute?.tooltip?.style ), + appearDelay: inlineIcon.attribute.tooltip.appearDelay, disappearDelay: inlineIcon.attribute.tooltip.disappearDelay }; if (!this.table.internalProps.tooltipHandler.isBinded(tooltipOptions)) { @@ -1954,12 +1958,15 @@ export class StateManager { return syncRadioState(col, row, field, radioType, indexInCell, isChecked, this); } - changeCheckboxAndRadioOrder(sourceIndex: number, targetIndex: number) { + changeCheckboxOrder(sourceRecordPath: number | number[], targetRecordPath: number | number[]) { if (this.checkedState.size) { - changeCheckboxOrder(sourceIndex, targetIndex, this); + changeCheckboxOrder(sourceRecordPath, targetRecordPath, this); } - if (this.radioState.length) { - changeRadioOrder(sourceIndex, targetIndex, this); + } + + changeRadioOrder(sourceRecordPath: number | number[], targetRecordPath: number | number[]) { + if (Object.keys(this.radioState).length) { + changeRadioOrder(sourceRecordPath, targetRecordPath, this); } } diff --git a/packages/vtable/src/tools/media.ts b/packages/vtable/src/tools/media.ts new file mode 100644 index 0000000000..86d691f914 --- /dev/null +++ b/packages/vtable/src/tools/media.ts @@ -0,0 +1,26 @@ +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'oga', 'm4a', 'aac', 'flac', 'opus']); + +export function isAudioUrl(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + const trimmedValue = value.trim(); + const queryIndex = trimmedValue.indexOf('?'); + const hashIndex = trimmedValue.indexOf('#'); + let endIndex = trimmedValue.length; + if (queryIndex >= 0) { + endIndex = queryIndex; + } + if (hashIndex >= 0 && hashIndex < endIndex) { + endIndex = hashIndex; + } + + const path = trimmedValue.slice(0, endIndex); + const dotIndex = path.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex === path.length - 1) { + return false; + } + + return AUDIO_EXTENSIONS.has(path.slice(dotIndex + 1).toLowerCase()); +} diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index f898856f68..9c27788d55 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -678,6 +678,9 @@ export interface BaseTableConstructorOptions { /** 强制计算所有行高,用于某些场景下,如vtable-gantt中,需要一次性计算所有行高 */ forceComputeAllRowHeight?: boolean; + /** 多行合并行高自动计算时单行最小行高,用在大量行合并的情况下,避免行高自动计算过小导致内容无法显示 */ + minSingleRowHeight?: number; + /** 是否取消当前单元格选中状态的判断钩子,用在table-group文件的pointertap事件中,当点击空白区域时,取消选中状态 */ cancelSelectCellHook?: (e: FederatedPointerEvent) => boolean; diff --git a/packages/vtable/src/ts-types/column/type.ts b/packages/vtable/src/ts-types/column/type.ts index 4dac54cfe5..a99dd1a665 100644 --- a/packages/vtable/src/ts-types/column/type.ts +++ b/packages/vtable/src/ts-types/column/type.ts @@ -2,6 +2,7 @@ export type ColumnTypeOption = | 'text' | 'image' | 'video' + | 'audio' | 'link' | 'chart' | 'sparkline' @@ -10,4 +11,4 @@ export type ColumnTypeOption = | 'radio' | 'switch' | 'button'; -export type HeaderTypeOption = 'text' | 'image' | 'video' | 'link' | 'checkbox'; +export type HeaderTypeOption = 'text' | 'image' | 'video' | 'audio' | 'link' | 'checkbox'; diff --git a/packages/vtable/src/ts-types/events.ts b/packages/vtable/src/ts-types/events.ts index bab6a41093..5ceee38e19 100644 --- a/packages/vtable/src/ts-types/events.ts +++ b/packages/vtable/src/ts-types/events.ts @@ -7,7 +7,7 @@ import type { ListTableConstructorOptions, PivotTableConstructorOptions } from './table-engine'; -import type { DropDownMenuEventArgs, MenuListItem, PivotInfo } from './menu'; +import type { ContextMenuClickEventArgs, DropDownMenuEventArgs, MenuListItem, PivotInfo } from './menu'; import type { IDimensionInfo, MergeCellInfo, RectProps, SortOrder } from './common'; import type { IconFuncTypeEnum, CellInfo, HierarchyState, ColumnsDefine } from '.'; @@ -84,6 +84,7 @@ export interface TableEventHandlersEventArgumentMap { mouseup_cell: MousePointerCellEvent; contextmenu_cell: MousePointerMultiCellEvent; contextmenu_canvas: MousePointerCellEvent; + context_menu_click: ContextMenuClickEventArgs; before_keydown: KeydownEvent; keydown: KeydownEvent; scroll: { @@ -395,6 +396,7 @@ export interface TableEventHandlersReturnMap { mouseup_cell: void; contextmenu_cell: void; contextmenu_canvas: void; + context_menu_click: void; before_keydown: void; keydown: void; scroll: void; diff --git a/packages/vtable/src/ts-types/icon.ts b/packages/vtable/src/ts-types/icon.ts index 29033baf90..424217d377 100644 --- a/packages/vtable/src/ts-types/icon.ts +++ b/packages/vtable/src/ts-types/icon.ts @@ -67,6 +67,9 @@ export interface IIconBase { maxWidth?: number; maxHeight?: number; }; + /** tooltip 延迟多久显示 */ + appearDelay?: number; + /** tooltip 延迟多久消失 */ disappearDelay?: number; }; /** diff --git a/packages/vtable/src/ts-types/list-table/define/image-define.ts b/packages/vtable/src/ts-types/list-table/define/image-define.ts index a9dd91c276..efcfcc06ca 100644 --- a/packages/vtable/src/ts-types/list-table/define/image-define.ts +++ b/packages/vtable/src/ts-types/list-table/define/image-define.ts @@ -27,3 +27,19 @@ export interface IImageColumnBodyDefine extends IBasicColumnBodyDefine { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioHeaderDefine extends IBasicHeaderDefine { + headerStyle?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); + headerType: 'audio'; + + /** 点击开启预览 */ + clickToPreview?: boolean; +} + +export interface IAudioColumnBodyDefine extends IBasicColumnBodyDefine { + style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); + cellType: 'audio'; + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/list-table/define/index.ts b/packages/vtable/src/ts-types/list-table/define/index.ts index 42897cf308..b802e5c2b8 100644 --- a/packages/vtable/src/ts-types/list-table/define/index.ts +++ b/packages/vtable/src/ts-types/list-table/define/index.ts @@ -2,7 +2,12 @@ import type { Either } from '../../../tools/helper'; import type { ICompositeColumnBodyDefine } from './composite-define'; import type { IChartColumnBodyDefine } from './chart-define'; import type { ICheckboxColumnBodyDefine, ICheckboxHeaderDefine } from './checkbox-define'; -import type { IImageColumnBodyDefine, IImageHeaderDefine } from './image-define'; +import type { + IAudioColumnBodyDefine, + IAudioHeaderDefine, + IImageColumnBodyDefine, + IImageHeaderDefine +} from './image-define'; import type { ILinkColumnBodyDefine, ILinkHeaderDefine } from './link-define'; import type { ITextColumnBodyDefine, ITextHeaderDefine } from './multilinetext-define'; import type { IProgressbarColumnBodyDefine } from './progressbar-define'; @@ -11,7 +16,12 @@ import type { IRadioColumnBodyDefine } from './radio-define'; import type { ISwitchColumnBodyDefine } from './switch-define'; import type { IButtonColumnBodyDefine } from './button-define'; -export type HeaderDefine = IImageHeaderDefine | ILinkHeaderDefine | ICheckboxHeaderDefine | ITextHeaderDefine; +export type HeaderDefine = + | IImageHeaderDefine + | IAudioHeaderDefine + | ILinkHeaderDefine + | ICheckboxHeaderDefine + | ITextHeaderDefine; /** * 内置列 body 类型联合 @@ -20,6 +30,7 @@ export type HeaderDefine = IImageHeaderDefine | ILinkHeaderDefine | ICheckboxHea export type BuiltinColumnBodyDefine = | ILinkColumnBodyDefine | IImageColumnBodyDefine + | IAudioColumnBodyDefine | ISparklineColumnBodyDefine | IProgressbarColumnBodyDefine | ICheckboxColumnBodyDefine @@ -54,6 +65,7 @@ export type ColumnBodyDefine = BuiltinColumnBodyDefine | CustomColumnBodyDefineM export type TextColumnDefine = ITextColumnBodyDefine & HeaderDefine; export type LinkColumnDefine = ILinkColumnBodyDefine & HeaderDefine; export type ImageColumnDefine = IImageColumnBodyDefine & HeaderDefine; +export type AudioColumnDefine = IAudioColumnBodyDefine & HeaderDefine; export type SparklineColumnDefine = ISparklineColumnBodyDefine & HeaderDefine; export type ProgressbarColumnDefine = IProgressbarColumnBodyDefine & HeaderDefine; export type CheckboxColumnDefine = ICheckboxColumnBodyDefine & HeaderDefine; @@ -69,6 +81,7 @@ export type ButtonColumnDefine = IButtonColumnBodyDefine & HeaderDefine; export type BuiltinSimpleColumnDefine = | LinkColumnDefine | ImageColumnDefine + | AudioColumnDefine | SparklineColumnDefine | ProgressbarColumnDefine | CheckboxColumnDefine diff --git a/packages/vtable/src/ts-types/list-table/layout-map/api.ts b/packages/vtable/src/ts-types/list-table/layout-map/api.ts index d63102f5ca..b4c0f598c1 100644 --- a/packages/vtable/src/ts-types/list-table/layout-map/api.ts +++ b/packages/vtable/src/ts-types/list-table/layout-map/api.ts @@ -40,6 +40,7 @@ export type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, @@ -153,7 +154,7 @@ export interface IndicatorData extends WidthData { indicatorKey: string; // fieldKey: FieldKeyDef; fieldFormat?: FieldFormat; - cellType: 'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart'; //BaseColumn; + cellType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'; //BaseColumn; chartModule?: string; chartSpec?: any | ((arg0: CustomRenderFunctionArg) => any); chartInstance?: any; @@ -191,8 +192,8 @@ export interface SeriesNumberColumnData extends WidthData { | (string | ColumnIconOption)[] | ((args: CellInfo) => undefined | string | ColumnIconOption | (string | ColumnIconOption)[]); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; - cellType: 'text' | 'link' | 'image' | 'video' | 'checkbox' | 'radio'; - headerType: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + cellType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; + headerType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; style: ColumnStyleOption | null | undefined; define: IRowSeriesNumber; isChildNode?: false; diff --git a/packages/vtable/src/ts-types/list-table/layout-map/index.ts b/packages/vtable/src/ts-types/list-table/layout-map/index.ts index 4b0cf805fb..7a5626b280 100644 --- a/packages/vtable/src/ts-types/list-table/layout-map/index.ts +++ b/packages/vtable/src/ts-types/list-table/layout-map/index.ts @@ -6,6 +6,7 @@ export type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, diff --git a/packages/vtable/src/ts-types/menu.ts b/packages/vtable/src/ts-types/menu.ts index a19dcfc0ac..1751602e1e 100644 --- a/packages/vtable/src/ts-types/menu.ts +++ b/packages/vtable/src/ts-types/menu.ts @@ -75,6 +75,19 @@ export type DropDownMenuEventArgs = { highlight: boolean; } & DropDownMenuEventInfo; +export type ContextMenuClickEventArgs = { + col: number; + row: number; + contextMenu: { + menuKey: string; + menuText: string; + rowIndex?: number; + colIndex?: number; + cellValue?: any; + inputValue?: number | string; + }; +}; + export type DropDownMenuEventInfo = { field?: FieldDef; /**format之后的值 */ diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts index 4e9903a7c2..89283cf253 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts @@ -20,3 +20,13 @@ export interface IImageDimension extends IBasicDimension { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioDimension extends IBasicDimension { + headerType: 'audio'; + headerStyle?: + | IImageStyleOption //表头可以配置吸附; + | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); //该维度层级表头部分的样式 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts index df638478ec..e5fca4629e 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts @@ -1,11 +1,11 @@ import type { ICompositeDimension } from './composite-dimension'; -import type { IImageDimension } from './image-dimension'; +import type { IAudioDimension, IImageDimension } from './image-dimension'; import type { ILinkDimension } from './link-dimension'; import type { ITextDimension } from './multilinetext-dimension'; export type IDimension = IRowDimension | IColumnDimension; -export type IRowDimension = ILinkDimension | IImageDimension | ITextDimension | ICompositeDimension; -export type IColumnDimension = Omit< - ILinkDimension | IImageDimension | ITextDimension | ICompositeDimension, - 'width' | 'minWidth' | 'maxWidth' +export type IRowDimension = ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension; +type OmitColumnWidth = T extends unknown ? Omit : never; +export type IColumnDimension = OmitColumnWidth< + ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension >; diff --git a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts index 510a7311a6..5353b5a570 100644 --- a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts +++ b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts @@ -29,3 +29,21 @@ export interface IImageColumnIndicator extends IBasicColumnIndicator { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioHeaderIndicator extends IBasicHeaderIndicator { + headerType: 'audio'; // 指标表头类型 + headerStyle?: + | IImageStyleOption //表头可以配置吸附; + | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // 指标名称在表头部分显示类型 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} + +export interface IAudioColumnIndicator extends IBasicColumnIndicator { + cellType: 'audio'; // body指标值显示类型 + style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // body部分指标值显示样式 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/pivot-table/indicator/index.ts b/packages/vtable/src/ts-types/pivot-table/indicator/index.ts index afb3744133..59cc820e22 100644 --- a/packages/vtable/src/ts-types/pivot-table/indicator/index.ts +++ b/packages/vtable/src/ts-types/pivot-table/indicator/index.ts @@ -1,7 +1,12 @@ import type { IChartColumnIndicator } from './chart-indicator'; import type { ICheckboxColumnIndicator } from './checkbox-indicator'; import type { ICompositeColumnIndicator, ICompositeHeaderIndicator } from './composite-indicator'; -import type { IImageColumnIndicator, IImageHeaderIndicator } from './image-indicator'; +import type { + IAudioColumnIndicator, + IAudioHeaderIndicator, + IImageColumnIndicator, + IImageHeaderIndicator +} from './image-indicator'; import type { ILinkColumnIndicator, ILinkHeaderIndicator } from './link-indicator'; import type { ITextColumnIndicator, ITextHeaderIndicator } from './multilinetext-indicator'; import type { IProgressbarColumnIndicator } from './progress-indicator'; @@ -10,12 +15,14 @@ import type { ISparklineColumnIndicator } from './sparkline-indicator'; export type HeaderIndicator = | ILinkHeaderIndicator | IImageHeaderIndicator + | IAudioHeaderIndicator | ITextHeaderIndicator | ICompositeHeaderIndicator; export type ColumnIndicator = | ILinkColumnIndicator | IImageColumnIndicator + | IAudioColumnIndicator | ISparklineColumnIndicator | IProgressbarColumnIndicator | ICheckboxColumnIndicator diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 00f45593ec..ac4ce2c794 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -153,8 +153,10 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - headerType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox' | 'radio'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; + /** 点击开启预览,仅在 image/video/audio 类型时生效 */ + clickToPreview?: boolean; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; @@ -184,7 +186,9 @@ export interface ColumnSeriesNumber { title?: string; field?: FieldDef; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + /** 点击开启预览,仅在 image/video/audio 类型时生效 */ + clickToPreview?: boolean; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); icon?: @@ -360,6 +364,8 @@ export interface ListTableAPI extends BaseTableAPI { internalProps: ListTableProtected; isListTable: () => true; isPivotTable: () => false; + /** 获取当前单元格对应源数据 records 的 index;树形表格返回 children 路径。 */ + getRecordIndexByCell: (col: number, row: number) => number | number[]; /** 设置单元格的value值,注意对应的是源数据的原始值,vtable实例records会做对应修改 */ changeCellValue: ( col: number, diff --git a/packages/vtable/src/ts-types/tooltip.ts b/packages/vtable/src/ts-types/tooltip.ts index 12ff04e68a..37841c6442 100644 --- a/packages/vtable/src/ts-types/tooltip.ts +++ b/packages/vtable/src/ts-types/tooltip.ts @@ -29,6 +29,8 @@ export type TooltipOptions = { maxWidth?: number; maxHeight?: number; }; + /** 设置tooltip的出现延迟时间 */ + appearDelay?: number; /** 设置tooltip的消失时间 */ disappearDelay?: number; }; diff --git a/packages/vue-vtable/__tests__/vue-stable-id.test.ts b/packages/vue-vtable/__tests__/vue-stable-id.test.ts index a2d8c2d010..14387acdbb 100644 --- a/packages/vue-vtable/__tests__/vue-stable-id.test.ts +++ b/packages/vue-vtable/__tests__/vue-stable-id.test.ts @@ -28,6 +28,8 @@ jest.mock('@visactor/vtable', () => { addEventListener() {} } return { + CUSTOM_CONTAINER_NAME: 'custom-container', + CUSTOM_MERGE_PRE_NAME: '_custom_', CustomLayout: { Group: MockGroup, Image: MockGroup, @@ -42,7 +44,8 @@ jest.mock('@visactor/vtable', () => { // Mock vue 的 isVNode / cloneVNode jest.mock('vue', () => ({ isVNode: (val: any) => val && val.__v_isVNode === true, - cloneVNode: (vnode: any, extra: any) => ({ ...vnode, ...extra }) + cloneVNode: (vnode: any, extra: any) => ({ ...vnode, ...extra }), + render: jest.fn() })); function makeGroupChild(vueProps: any = {}, typeName = 'Group') { @@ -235,3 +238,68 @@ describe('VTableVueAttributePlugin 缓存命中同步渲染', () => { expect(plugin.scheduleRender).toHaveBeenCalled(); }); }); + +describe('VTableVueAttributePlugin 顶部冻结行容器', () => { + test.each([ + ['普通列', 1, 'headerDomContainer'], + ['左冻结列', 0, 'frozenHeaderDomContainer'], + ['右冻结列', 3, 'rightFrozenHeaderDomContainer'] + ])('顶部冻结行的%s应挂载到顶部冻结容器', (_name, col, expectedContainerKey) => { + const { VTableVueAttributePlugin } = jest.requireActual('../src/components/custom/vtable-vue-attribute-plugin'); + const plugin = new VTableVueAttributePlugin(); + plugin.htmlMap = {}; + plugin.renderId = 1; + + const table = { + bodyDomContainer: document.createElement('div'), + headerDomContainer: document.createElement('div'), + frozenBodyDomContainer: document.createElement('div'), + frozenHeaderDomContainer: document.createElement('div'), + rightFrozenBodyDomContainer: document.createElement('div'), + rightFrozenHeaderDomContainer: document.createElement('div'), + bottomFrozenBodyDomContainer: document.createElement('div'), + rightFrozenBottomDomContainer: document.createElement('div'), + frozenColCount: 1, + rightFrozenColCount: 1, + frozenRowCount: 2, + bottomFrozenRowCount: 0, + colCount: 4, + rowCount: 10, + options: {} + }; + const stage = { + table, + window: { getContainer: () => document.body } + }; + const targetGroup = { + name: 'custom-container', + col, + row: 1, + stage, + parent: {} + }; + const graphic = { + attribute: { + vue: { + id: `top-frozen-${col}`, + element: { __v_isVNode: true }, + container: table.bodyDomContainer + } + }, + stage, + parent: targetGroup + }; + const wrapContainer = document.createElement('div'); + plugin.getWrapContainer = jest.fn((_stage, container) => ({ + wrapContainer, + nativeContainer: container + })); + plugin.updateStyleOfWrapContainer = jest.fn(); + + plugin.doRenderGraphic(graphic); + + const actualContainer = plugin.getWrapContainer.mock.calls[0][1]; + expect(actualContainer).toBe(table[expectedContainerKey]); + expect(actualContainer).not.toBe(table.bodyDomContainer); + }); +}); diff --git a/packages/vue-vtable/package.json b/packages/vue-vtable/package.json index 17bb510e44..c9337f18ee 100644 --- a/packages/vue-vtable/package.json +++ b/packages/vue-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vue-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The vue version of VTable", "keywords": [ "vue", @@ -59,7 +59,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts index f44dc8b665..4bc9cfc465 100644 --- a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts +++ b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts @@ -842,6 +842,12 @@ function checkFrozenContainer(graphic: IGraphic) { container = table.rightFrozenBottomDomContainer; } else if (row >= table.rowCount - table.bottomFrozenRowCount) { container = table.bottomFrozenBodyDomContainer; + } else if (row < table.frozenRowCount && col < table.frozenColCount) { + container = table.frozenHeaderDomContainer; + } else if (row < table.frozenRowCount && col >= table.colCount - table.rightFrozenColCount) { + container = table.rightFrozenHeaderDomContainer; + } else if (row < table.frozenRowCount) { + container = table.headerDomContainer; } else if (col < table.frozenColCount) { container = table.frozenBodyDomContainer; } else if (col >= table.colCount - table.rightFrozenColCount) { diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index 60f812acce..a744555a17 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.6", - "@visactor/vtable-gantt": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-plugins": "workspace:1.26.6", - "@visactor/vtable-sheet": "workspace:1.26.6" + "@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" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4",