From 6bd6d236ae8f9d02caa273093208d0f4db4196aa Mon Sep 17 00:00:00 2001 From: xueyuan Date: Fri, 10 Apr 2026 14:44:09 +0800 Subject: [PATCH 01/65] demo(business): add personnel information sheet with phone masking #2093 --- .../assets/demo/en/business/personnel-info.md | 163 ++++++++++++++++++ docs/assets/demo/menu.json | 17 ++ .../assets/demo/zh/business/personnel-info.md | 163 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 docs/assets/demo/en/business/personnel-info.md create mode 100644 docs/assets/demo/zh/business/personnel-info.md diff --git a/docs/assets/demo/en/business/personnel-info.md b/docs/assets/demo/en/business/personnel-info.md new file mode 100644 index 0000000000..58eec69c25 --- /dev/null +++ b/docs/assets/demo/en/business/personnel-info.md @@ -0,0 +1,163 @@ +--- +category: examples +group: Business +title: Personnel Information Sheet +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png +link: custom_define/custom_icon +--- + +# Personnel Information Sheet + +Display personnel information with phone numbers masked by default (showing only first 3 and last 4 digits). Click the eye icon to toggle displaying the full phone number. + +## Key Configuration + +- `fieldFormat` formats phone numbers: shows `138****5678` by default +- `VTable.register.icon` registers eye-open / eye-close icons with `visibleTime: 'always'` to always show the toggle button +- `click_cell` event listens for icon clicks, toggles per-row visibility state, and calls `updateRecords` to refresh the cell + +## Code Demo + +```javascript livedemo template=vtable +// Register "show full number" icon (eye open) +VTable.register.icon('eye-open', { + type: 'svg', + name: 'eye-open', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: 'Click to hide number', + placement: VTable.TYPES.Placement.top + }, + svg: ` + + + ` +}); + +// Register "hide number" icon (eye closed) +VTable.register.icon('eye-close', { + type: 'svg', + name: 'eye-close', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: 'Click to show number', + placement: VTable.TYPES.Placement.top + }, + svg: ` + + + + ` +}); + +// Mask phone number: keep first 3 and last 4 digits, replace the rest with **** +function maskPhone(phone) { + if (!phone || phone.length < 7) return phone; + return phone.slice(0, 3) + '****' + phone.slice(-4); +} + +// Sample personnel data +const records = [ + { id: '001', name: 'Zhang Wei', department: 'R&D', position: 'Senior Engineer', phone: '13812345678', email: 'zhangwei@company.com', location: 'Beijing' }, + { id: '002', name: 'Li Na', department: 'Product', position: 'Product Manager', phone: '13987654321', email: 'lina@company.com', location: 'Shanghai' }, + { id: '003', name: 'Wang Fang', department: 'Design', position: 'UI Designer', phone: '15012349876', email: 'wangfang@company.com', location: 'Shenzhen' }, + { id: '004', name: 'Zhao Lei', department: 'Operations', position: 'Operations Specialist', phone: '18611112222', email: 'zhaolei@company.com', location: 'Guangzhou' }, + { id: '005', name: 'Chen Jing', department: 'HR', position: 'HR Manager', phone: '13711113333', email: 'chenjing@company.com', location: 'Hangzhou' }, + { id: '006', name: 'Liu Yang', department: 'R&D', position: 'Frontend Engineer', phone: '15888889999', email: 'liuyang@company.com', location: 'Beijing' }, + { id: '007', name: 'Zhou Chao', department: 'Sales', position: 'Sales Director', phone: '13666667777', email: 'zhouchao@company.com', location: 'Chengdu' }, + { id: '008', name: 'Wu Min', department: 'Finance', position: 'Finance Supervisor', phone: '18900001111', email: 'wumin@company.com', location: 'Wuhan' }, + { id: '009', name: 'Zheng Hao', department: 'R&D', position: 'Backend Engineer', phone: '13544445555', email: 'zhenghao@company.com', location: "Xi'an" }, + { id: '010', name: 'Sun Li', department: 'Marketing', position: 'Marketing Specialist', phone: '17722223333', email: 'sunli@company.com', location: 'Nanjing' } +]; + +// Track per-row phone number visibility +const phoneVisible = {}; + +let tableInstance; + +const columns = [ + { + field: 'id', + title: 'ID', + width: 70, + style: { textAlign: 'center', color: '#666' } + }, + { + field: 'name', + title: 'Name', + width: 120 + }, + { + field: 'department', + title: 'Department', + width: 120 + }, + { + field: 'position', + title: 'Position', + width: 160 + }, + { + field: 'phone', + title: 'Phone Number', + width: 190, + // Show masked or full number based on per-row visibility state + fieldFormat(record) { + const rowId = record.id; + return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); + }, + // Show eye-open when full number is visible, eye-close when masked + icon(args) { + const rowId = args.record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + } + }, + { + field: 'email', + title: 'Email', + width: 210 + }, + { + field: 'location', + title: 'City', + width: 100, + style: { textAlign: 'center' } + } +]; + +const option = { + records, + columns, + widthMode: 'standard', + frozenColCount: 1, + theme: VTable.themes.ARCO +}; + +tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window['tableInstance'] = tableInstance; + +// Listen for icon clicks to toggle phone number visibility per row +tableInstance.on('click_cell', args => { + const { col, row, targetIcon } = args; + if (!targetIcon) return; + if (targetIcon.name === 'eye-open' || targetIcon.name === 'eye-close') { + const record = tableInstance.getCellOriginRecord(col, row); + if (!record) return; + const rowId = record.id; + // Toggle visibility for this row + phoneVisible[rowId] = !phoneVisible[rowId]; + // Refresh the row to reflect the new state + tableInstance.updateRecords([record], [row - tableInstance.columnHeaderLevelCount]); + } +}); +``` diff --git a/docs/assets/demo/menu.json b/docs/assets/demo/menu.json index 3ced3e050d..7d1ef130a5 100644 --- a/docs/assets/demo/menu.json +++ b/docs/assets/demo/menu.json @@ -1833,7 +1833,24 @@ "zh": "产品对比表", "en": "Product Compare Table" } + }, + { + "path": "personnel-info", + "title": { + "zh": "人员信息表", + "en": "Personnel Information Sheet" + }, + "meta": { + "title": "Personnel Information Sheet", + "keywords": "personnel, phone mask, privacy, icon, click", + "category": "demo", + "group": "Business", + "cover": "https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png", + "link": "'../../guide/custom_define/custom_icon'", + "option": "" + } } + ] } ] diff --git a/docs/assets/demo/zh/business/personnel-info.md b/docs/assets/demo/zh/business/personnel-info.md new file mode 100644 index 0000000000..f64e2613cc --- /dev/null +++ b/docs/assets/demo/zh/business/personnel-info.md @@ -0,0 +1,163 @@ +--- +category: examples +group: Business +title: 人员信息表 +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png +link: custom_define/custom_icon +--- + +# 人员信息表 + +展示人员信息,电话号码默认脱敏显示(部分隐藏),点击眼睛图标可切换显示完整号码。 + +## 关键配置 + +- `fieldFormat` 格式化电话号码,默认只显示前三位和后四位,中间用 `****` 替换 +- `VTable.register.icon` 注册显示/隐藏图标,通过 `visibleTime: 'always'` 常驻显示 +- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态并调用 `updateRecords` 刷新 + +## 代码演示 + +```javascript livedemo template=vtable +// 注册"显示完整号码"图标(眼睛张开) +VTable.register.icon('eye-open', { + type: 'svg', + name: 'eye-open', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: '点击隐藏号码', + placement: VTable.TYPES.Placement.top + }, + svg: ` + + + ` +}); + +// 注册"隐藏号码"图标(眼睛闭合) +VTable.register.icon('eye-close', { + type: 'svg', + name: 'eye-close', + width: 16, + height: 16, + positionType: VTable.TYPES.IconPosition.right, + marginLeft: 4, + cursor: 'pointer', + visibleTime: 'always', + tooltip: { + title: '点击显示号码', + placement: VTable.TYPES.Placement.top + }, + svg: ` + + + + ` +}); + +// 脱敏电话号码:保留前3位和后4位,中间替换为 **** +function maskPhone(phone) { + if (!phone || phone.length < 7) return phone; + return phone.slice(0, 3) + '****' + phone.slice(-4); +} + +// 模拟人员数据 +const records = [ + { id: '001', name: '张伟', department: '研发部', position: '高级工程师', phone: '13812345678', email: 'zhangwei@company.com', location: '北京' }, + { id: '002', name: '李娜', department: '产品部', position: '产品经理', phone: '13987654321', email: 'lina@company.com', location: '上海' }, + { id: '003', name: '王芳', department: '设计部', position: 'UI设计师', phone: '15012349876', email: 'wangfang@company.com', location: '深圳' }, + { id: '004', name: '赵磊', department: '运营部', position: '运营专员', phone: '18611112222', email: 'zhaolei@company.com', location: '广州' }, + { id: '005', name: '陈静', department: '人力资源部', position: 'HR经理', phone: '13711113333', email: 'chenjing@company.com', location: '杭州' }, + { id: '006', name: '刘洋', department: '研发部', position: '前端工程师', phone: '15888889999', email: 'liuyang@company.com', location: '北京' }, + { id: '007', name: '周超', department: '销售部', position: '销售总监', phone: '13666667777', email: 'zhouchao@company.com', location: '成都' }, + { id: '008', name: '吴敏', department: '财务部', position: '财务主管', phone: '18900001111', email: 'wumin@company.com', location: '武汉' }, + { id: '009', name: '郑浩', department: '研发部', position: '后端工程师', phone: '13544445555', email: 'zhenghao@company.com', location: '西安' }, + { id: '010', name: '孙丽', department: '市场部', position: '市场专员', phone: '17722223333', email: 'sunli@company.com', location: '南京' } +]; + +// 记录每行的电话号码展开状态 +const phoneVisible = {}; + +let tableInstance; + +const columns = [ + { + field: 'id', + title: '工号', + width: 70, + style: { textAlign: 'center', color: '#666' } + }, + { + field: 'name', + title: '姓名', + width: 90 + }, + { + field: 'department', + title: '部门', + width: 120 + }, + { + field: 'position', + title: '职位', + width: 130 + }, + { + field: 'phone', + title: '电话号码', + width: 180, + // 根据展开状态决定显示脱敏号码还是完整号码 + fieldFormat(record) { + const rowId = record.id; + return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); + }, + // 根据展开状态动态切换图标 + icon(args) { + const rowId = args.record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + } + }, + { + field: 'email', + title: '邮箱', + width: 200 + }, + { + field: 'location', + title: '所在城市', + width: 100, + style: { textAlign: 'center' } + } +]; + +const option = { + records, + columns, + widthMode: 'standard', + frozenColCount: 1, + theme: VTable.themes.ARCO +}; + +tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window['tableInstance'] = tableInstance; + +// 监听图标点击事件,切换电话号码的显示状态 +tableInstance.on('click_cell', args => { + const { col, row, targetIcon } = args; + if (!targetIcon) return; + if (targetIcon.name === 'eye-open' || targetIcon.name === 'eye-close') { + const record = tableInstance.getCellOriginRecord(col, row); + if (!record) return; + const rowId = record.id; + // 切换该行的显示状态 + phoneVisible[rowId] = !phoneVisible[rowId]; + // 刷新该行数据 + tableInstance.updateRecords([record], [row - tableInstance.columnHeaderLevelCount]); + } +}); +``` From 5999b3184c7fdf015e566eca267f0048936b62a1 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 20 Aug 2026 18:56:14 +0800 Subject: [PATCH 02/65] fix(vtable): refresh custom merged corner layout Recalculate custom merged corner content after resize. Keep it on one render cell to avoid clipping artifacts. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../demo/en/custom-render/complex-corner.md | 37 +- .../demo/zh/custom-render/complex-corner.md | 37 +- .../vtable/examples/custom/complex-corner.ts | 766 +++++++++--------- .../scenegraph/group-creater/cell-helper.ts | 50 +- .../group-creater/cell-type/text-cell.ts | 10 +- .../src/scenegraph/layout/update-height.ts | 31 +- .../src/scenegraph/layout/update-width.ts | 31 +- 7 files changed, 529 insertions(+), 433 deletions(-) diff --git a/docs/assets/demo/en/custom-render/complex-corner.md b/docs/assets/demo/en/custom-render/complex-corner.md index e3fd9a5d76..8d4df3ba88 100644 --- a/docs/assets/demo/en/custom-render/complex-corner.md +++ b/docs/assets/demo/en/custom-render/complex-corner.md @@ -71,9 +71,18 @@ const option = { }, corner: { titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, customLayout: args => { const { table, row, col, rect } = args; const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 80; const container = createGroup({ height, width @@ -81,12 +90,12 @@ const option = { // 定义文本内容的数组 const texts = [ - { text: 'province', fontSize: 18, x: 10, y: rect.height - 25 }, - { text: 'city', fontSize: 18, x: 105, y: rect.height - 25 }, - { text: 'data', fontSize: 18, x: rect.width - 50, y: rect.height - 35 }, - { text: 'subCategory', fontSize: 18, x: rect.width - 100, y: rect.height - 85 }, - { text: 'category', fontSize: 18, x: rect.width - 80, y: 18 }, - { text: 'indicator', fontSize: 16, x: 176, y: rect.height - 20 } + { text: 'province', fontSize: 18, x: provinceCenterX - 36, y: height - 25 }, + { text: 'city', fontSize: 18, x: cityCenterX - 14, y: height - 25 }, + { text: 'data', fontSize: 18, x: endCategoryX + 30, y: height - 35 }, + { text: 'subCategory', fontSize: 18, x: endCategoryX - 20, y: height - 85 }, + { text: 'category', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: 'indicator', fontSize: 16, x: indicatorX, y: height - 20 } ]; // 循环添加文本 @@ -105,17 +114,15 @@ const option = { // 定义线段的点 const linePoints = [ - { x: rect.left, y: rect.top }, { x: 0, y: 0 }, - { x: rect.width - 40, y: rect.height }, + { x: width - 40, y: height }, + { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, - { x: 173, y: rect.height }, + { x: provinceWidth, y: height }, { x: 0, y: 0 }, - { x: 84, y: rect.height }, + { x: width, y: categoryHeight }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 90 }, - { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 38 }, + { x: width, y: height - 38 }, { x: 0, y: 0 } ]; @@ -398,6 +405,10 @@ const option = { rightFrozenColCount: 1, dragOrder:{ dragHeaderMode: 'all' + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' } }; diff --git a/docs/assets/demo/zh/custom-render/complex-corner.md b/docs/assets/demo/zh/custom-render/complex-corner.md index 7c60a70928..95aa161557 100644 --- a/docs/assets/demo/zh/custom-render/complex-corner.md +++ b/docs/assets/demo/zh/custom-render/complex-corner.md @@ -71,9 +71,18 @@ const option = { }, corner: { titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, customLayout: args => { const { table, row, col, rect } = args; const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 50; const container = createGroup({ height, width @@ -81,12 +90,12 @@ const option = { // 定义文本内容的数组 const texts = [ - { text: '省份', fontSize: 18, x: 30, y: rect.height - 25 }, - { text: '城市', fontSize: 18, x: 105, y: rect.height - 25 }, - { text: '数据', fontSize: 18, x: rect.width - 50, y: rect.height - 35 }, - { text: '子类别', fontSize: 18, x: rect.width - 50, y: rect.height - 85 }, - { text: '类别', fontSize: 18, x: rect.width - 50, y: 18 }, - { text: '指标', fontSize: 16, x: 176, y: rect.height - 20 } + { text: '省份', fontSize: 18, x: provinceCenterX - 18, y: height - 25 }, + { text: '城市', fontSize: 18, x: cityCenterX - 18, y: height - 25 }, + { text: '数据', fontSize: 18, x: endCategoryX, y: height - 35 }, + { text: '子类别', fontSize: 18, x: endCategoryX, y: height - 85 }, + { text: '类别', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: '指标', fontSize: 16, x: indicatorX, y: height - 20 } ]; // 循环添加文本 @@ -105,17 +114,15 @@ const option = { // 定义线段的点 const linePoints = [ - { x: rect.left, y: rect.top }, { x: 0, y: 0 }, - { x: rect.width - 40, y: rect.height }, + { x: width - 40, y: height }, + { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, - { x: 173, y: rect.height }, + { x: provinceWidth, y: height }, { x: 0, y: 0 }, - { x: 84, y: rect.height }, + { x: width, y: categoryHeight }, { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 90 }, - { x: 0, y: 0 }, - { x: rect.width, y: rect.height - 38 }, + { x: width, y: height - 38 }, { x: 0, y: 0 } ]; @@ -398,6 +405,10 @@ const option = { rightFrozenColCount: 1, dragOrder: { dragHeaderMode: 'all' + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' } }; diff --git a/packages/vtable/examples/custom/complex-corner.ts b/packages/vtable/examples/custom/complex-corner.ts index 5948f97184..70c586ba30 100644 --- a/packages/vtable/examples/custom/complex-corner.ts +++ b/packages/vtable/examples/custom/complex-corner.ts @@ -1,397 +1,405 @@ import * as VTable from '../../src'; -import { bindDebugTool } from '../../src/scenegraph/debug-tool'; +import VChart from '@visactor/vchart'; const BUGSERVER_CONTAINER_ID = 'vTable'; -const VCHART_NPM_CDN = 'https://unpkg.com/@visactor/vchart@latest/build/index.min.js'; +VTable.register.chartModule('vchart', VChart); -// your code here -import('https://unpkg.com/@visactor/vchart@latest/build/index.min.js') - .then(async () => { - VTable.register.chartModule('vchart', window.VChart.default); - const dom = document.querySelector('#' + BUGSERVER_CONTAINER_ID); - dom.style.width = '800px'; - dom.style.height = '800px'; - const option = { - autoFillHeight: true, - rows: ['province', 'city'], - columns: ['category', 'sub_category'], - indicators: ['sales', 'number'], - defaultHeaderColWidth: ['auto', 'auto', 120], - defaultHeaderRowHeight: ['auto', 90], - indicatorTitle: '指标名称', - indicatorsAsCol: false, - customMergeCell: (col, row, table) => { - if (col >= 0 && col < 3 && row <= 1) { - return { - text: 'merge text', - range: { - start: { - col: 0, - row: 0 - }, - end: { - col: 2, - row: 1 - } +export function createTable() { + const dom = document.querySelector('#' + BUGSERVER_CONTAINER_ID); + dom.style.width = '800px'; + dom.style.height = '800px'; + const option = { + autoFillHeight: true, + rows: ['province', 'city'], + columns: ['category', 'sub_category'], + indicators: ['sales', 'number'], + defaultHeaderColWidth: ['auto', 'auto', 120], + defaultHeaderRowHeight: ['auto', 90], + indicatorTitle: '指标名称', + indicatorsAsCol: false, + customMergeCell: (col, row, table) => { + if (col >= 0 && col < 3 && row <= 1) { + return { + text: 'merge text', + range: { + start: { + col: 0, + row: 0 }, - style: { - bgColor: '#ECF1F5' + end: { + col: 2, + row: 1 } - }; - } - }, - dataConfig: { - totals: { - row: { - showGrandTotals: true, - showSubTotals: true, - subTotalsDimensions: ['province'], - grandTotalLabel: '行总计', - subTotalLabel: '小计' }, - column: { - showGrandTotals: true, - showSubTotals: true, - subTotalsDimensions: ['category'], - grandTotalLabel: '列总计', - subTotalLabel: '小计' + style: { + bgColor: '#ECF1F5' } + }; + } + }, + dataConfig: { + totals: { + row: { + showGrandTotals: true, + showSubTotals: true, + subTotalsDimensions: ['province'], + grandTotalLabel: '行总计', + subTotalLabel: '小计' + }, + column: { + showGrandTotals: true, + showSubTotals: true, + subTotalsDimensions: ['category'], + grandTotalLabel: '列总计', + subTotalLabel: '小计' } - }, - corner: { - merge: true, - titleOnDimension: 'row', - customLayout: (args: any) => { - const {table, row, col, rect} = args; - const {height, width} = rect ?? table.getCellRect(col, row); - const container = new VTable.CustomLayout.Group({ - height, - width, - }); - - // 定义文本内容的数组 - const texts = [ - {text: '省', fontSize: 18, x: 30, y: rect.height - 25}, - {text: '市', fontSize: 18, x: 105, y: rect.height - 25}, - {text: '数据', fontSize: 18, x: rect.width - 50, y: rect.height - 35}, - {text: '物品', fontSize: 18, x: rect.width - 50, y: rect.height - 85}, - {text: '种类', fontSize: 18, x: rect.width - 50, y: 18}, - {text: '类型', fontSize: 16, x: 176, y: rect.height - 20}, - ]; + } + }, + corner: { + merge: true, + titleOnDimension: 'row', + disableHeaderHover: true, + disableHeaderSelect: true, + customLayout: (args: any) => { + const { table, row, col, rect } = args; + const { height, width } = rect ?? table.getCellRect(col, row); + const provinceWidth = table.getColWidth(0); + const cityWidth = table.getColWidth(1); + const categoryHeight = table.getRowHeight(0); + const provinceCenterX = provinceWidth / 2; + const cityCenterX = provinceWidth + cityWidth / 2; + const indicatorX = provinceWidth + cityWidth + 6; + const endCategoryX = width - 50; + const container = new VTable.CustomLayout.Group({ + height, + width + }); - // 循环添加文本 - texts.forEach(({text, fontSize, x, y}) => { - container.addChild( - new VTable.CustomLayout.Text({ - text, - fontSize, - fontFamily: 'sans-serif', - fill: 'black', - x, - y, - }) - ); - }); + // 定义文本内容的数组 + const texts = [ + { text: '省份', fontSize: 18, x: provinceCenterX - 18, y: height - 25 }, + { text: '城市', fontSize: 18, x: cityCenterX - 18, y: height - 25 }, + { text: '数据', fontSize: 18, x: endCategoryX, y: height - 35 }, + { text: '子类别', fontSize: 18, x: endCategoryX, y: height - 85 }, + { text: '类别', fontSize: 18, x: endCategoryX, y: Math.max(18, categoryHeight / 2 - 9) }, + { text: '指标', fontSize: 16, x: indicatorX, y: height - 20 } + ]; - // 定义线段的点 - const linePoints = [ - {x: rect.left, y: rect.top}, - {x: rect.width - 40, y: rect.height}, - {x: 173, y: rect.height}, - {x: 0, y: 0}, - {x: 84, y: rect.height}, - {x: 0, y: 0}, - {x: rect.width, y: rect.height - 90}, - {x: 0, y: 0}, - {x: rect.width, y: rect.height - 38}, - {x: 0, y: 0}, - ]; - - // 添加线段 + // 循环添加文本 + texts.forEach(({ text, fontSize, x, y }) => { container.addChild( - new VTable.CustomLayout.Line({ - points: linePoints, - lineWidth: 1, - stroke: '#ccc', + new VTable.CustomLayout.Text({ + text, + fontSize, + fontFamily: 'sans-serif', + fill: 'black', + x, + y }) ); + }); - return { - rootContainer: container, - renderDefault: false, - enableCellPadding: false, - }; - } + // 定义线段的点 + const linePoints = [ + { x: 0, y: 0 }, + { x: width - 40, y: height }, + { x: provinceWidth + cityWidth + 3, y: height }, + { x: 0, y: 0 }, + { x: provinceWidth, y: height }, + { x: 0, y: 0 }, + { x: width, y: categoryHeight }, + { x: 0, y: 0 }, + { x: width, y: height - 38 }, + { x: 0, y: 0 } + ]; + + // 添加线段 + container.addChild( + new VTable.CustomLayout.Line({ + points: linePoints, + lineWidth: 1, + stroke: '#ccc' + }) + ); + + return { + rootContainer: container, + renderDefault: false, + enableCellPadding: false + }; + } + }, + records: [ + { + sales: 891, + number: 77899999, + province: '浙江省', + city: '杭州市', + category: '家具', + sub_category: '桌子' }, - records: [ - { - sales: 891, - number: 77899999, - province: '浙江省', - city: '杭州市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 792, - number: 2367, - province: '浙江省', - city: '绍兴市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 893, - number: 3877, - province: '浙江省', - city: '宁波市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 1094, - number: 4342, - province: '浙江省', - city: '舟山市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 1295, - number: 5343, - province: '浙江省', - city: '杭州市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 496, - number: 632, - province: '浙江省', - city: '绍兴市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 1097, - number: 7234, - province: '浙江省', - city: '宁波市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 998, - number: 834, - province: '浙江省', - city: '舟山市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 766, - number: 945, - province: '浙江省', - city: '杭州市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 990, - number: 1304, - province: '浙江省', - city: '绍兴市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 891, - number: 1145, - province: '浙江省', - city: '宁波市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 792, - number: 1432, - province: '浙江省', - city: '舟山市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 745, - number: 1343, - province: '浙江省', - city: '杭州市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 843, - number: 1354, - province: '浙江省', - city: '绍兴市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 895, - number: 1523, - province: '浙江省', - city: '宁波市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 965, - number: 1634, - province: '浙江省', - city: '舟山市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 776, - number: 1723, - province: '四川省', - city: '成都市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 634, - number: 1822, - province: '四川省', - city: '绵阳市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 909, - number: 1943, - province: '四川省', - city: '南充市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 399, - number: 2330, - province: '四川省', - city: '乐山市', - category: '家具', - sub_category: '桌子' - }, - { - sales: 700, - number: 2451, - province: '四川省', - city: '成都市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 689, - number: 2244, - province: '四川省', - city: '绵阳市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 500, - number: 2333, - province: '四川省', - city: '南充市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 800, - number: 2445, - province: '四川省', - city: '乐山市', - category: '家具', - sub_category: '沙发' - }, - { - sales: 1044, - number: 2335, - province: '四川省', - city: '成都市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 689, - number: 245, - province: '四川省', - city: '绵阳市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 794, - number: 2457, - province: '四川省', - city: '南充市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 566, - number: 2458, - province: '四川省', - city: '乐山市', - category: '办公用品', - sub_category: '笔' - }, - { - sales: 865, - number: 4004, - province: '四川省', - city: '成都市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 3077, - province: '四川省', - city: '绵阳市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 3551, - province: '四川省', - city: '南充市', - category: '办公用品', - sub_category: '纸张' - }, - { - sales: 999, - number: 352, - province: '四川省', - city: '乐山市', - category: '办公用品', - sub_category: '纸张' - } - ], - widthMode: 'standard', // 宽度模式:standard 标准模式; adaptive 自动填满容器 - bottomFrozenRowCount: 2, - rightFrozenColCount: 1, + { + sales: 792, + number: 2367, + province: '浙江省', + city: '绍兴市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 893, + number: 3877, + province: '浙江省', + city: '宁波市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 1094, + number: 4342, + province: '浙江省', + city: '舟山市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 1295, + number: 5343, + province: '浙江省', + city: '杭州市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 496, + number: 632, + province: '浙江省', + city: '绍兴市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 1097, + number: 7234, + province: '浙江省', + city: '宁波市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 998, + number: 834, + province: '浙江省', + city: '舟山市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 766, + number: 945, + province: '浙江省', + city: '杭州市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 990, + number: 1304, + province: '浙江省', + city: '绍兴市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 891, + number: 1145, + province: '浙江省', + city: '宁波市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 792, + number: 1432, + province: '浙江省', + city: '舟山市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 745, + number: 1343, + province: '浙江省', + city: '杭州市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 843, + number: 1354, + province: '浙江省', + city: '绍兴市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 895, + number: 1523, + province: '浙江省', + city: '宁波市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 965, + number: 1634, + province: '浙江省', + city: '舟山市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 776, + number: 1723, + province: '四川省', + city: '成都市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 634, + number: 1822, + province: '四川省', + city: '绵阳市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 909, + number: 1943, + province: '四川省', + city: '南充市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 399, + number: 2330, + province: '四川省', + city: '乐山市', + category: '家具', + sub_category: '桌子' + }, + { + sales: 700, + number: 2451, + province: '四川省', + city: '成都市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 689, + number: 2244, + province: '四川省', + city: '绵阳市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 500, + number: 2333, + province: '四川省', + city: '南充市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 800, + number: 2445, + province: '四川省', + city: '乐山市', + category: '家具', + sub_category: '沙发' + }, + { + sales: 1044, + number: 2335, + province: '四川省', + city: '成都市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 689, + number: 245, + province: '四川省', + city: '绵阳市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 794, + number: 2457, + province: '四川省', + city: '南充市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 566, + number: 2458, + province: '四川省', + city: '乐山市', + category: '办公用品', + sub_category: '笔' + }, + { + sales: 865, + number: 4004, + province: '四川省', + city: '成都市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 3077, + province: '四川省', + city: '绵阳市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 3551, + province: '四川省', + city: '南充市', + category: '办公用品', + sub_category: '纸张' + }, + { + sales: 999, + number: 352, + province: '四川省', + city: '乐山市', + category: '办公用品', + sub_category: '纸张' + } + ], + widthMode: 'standard', // 宽度模式:standard 标准模式; adaptive 自动填满容器 + bottomFrozenRowCount: 2, + rightFrozenColCount: 1, + dragOrder: { dragHeaderMode: 'all' - }; + }, + resize: { + columnResizeMode: 'all', + rowResizeMode: 'all' + } + }; - option.container = dom; - const instance = new VTable.PivotTable(option); - bindDebugTool(instance.scenegraph.stage, { customGrapicKeys: ['col', 'row'] }); - window['tableInstance'] = instance; - }) - .catch(err => { - console.error('Failed import CDN resource: ', err); - }); + option.container = dom; + const instance = new VTable.PivotTable(option); + window.tableInstance = instance; +} diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index bc7d557538..0327e021bd 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -46,6 +46,19 @@ import type { CreateButtonCellGroup } from './cell-type/button-cell'; const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; +export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { + return !!range?.isCustom && table.isCornerHeader(range.start.col, range.start.row); +} + +export function shouldRenderCornerCustomMergeContent( + col: number, + row: number, + range: CellRange | undefined, + table: BaseTableAPI +): boolean { + return !isCornerCustomMergeRange(range, table) || (col === range.end.col && row === range.end.row); +} + export function 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; @@ -592,7 +605,9 @@ function _generateCustomElementsGroup( let renderDefault = true; if (customResult) { // custom merge custom render - customElementsGroup = customResult.elementsGroup; + customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, range, table) + ? customResult.elementsGroup + : undefined; renderDefault = customResult.renderDefault; } else if (range?.isCustom && !table.isCornerHeader(col, row)) { // 判断不是角头单元格,来兼容corner中设置的customLayout @@ -623,7 +638,9 @@ function _generateCustomElementsGroup( range, table ); - customElementsGroup = customResult.elementsGroup; + customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, range, table) + ? customResult.elementsGroup + : undefined; renderDefault = customResult.renderDefault; } } @@ -1364,6 +1381,24 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr } = customMerge; if (customMergeLayout || customMergeRender) { + const rangeHeight = table.getRowHeight(row); + const rangeWidth = table.getColWidth(col); + const contentWidth = table.getColsWidth(customMergeRange.start.col, customMergeRange.end.col); + const contentHeight = table.getRowsHeight(customMergeRange.start.row, customMergeRange.end.row); + + cellGroup.contentWidth = contentWidth; + cellGroup.contentHeight = contentHeight; + + if (!shouldRenderCornerCustomMergeContent(col, row, customMergeRange, table)) { + const customContainer = cellGroup.getChildByName(CUSTOM_CONTAINER_NAME); + if (customContainer) { + cellGroup.removeChild(customContainer); + } + cellGroup.setAttribute('clip', true); + resizeCellGroup(cellGroup, rangeWidth, rangeHeight, customMergeRange, table); + return customMergeRange; + } + const customResult = dealWithCustom( customMergeLayout, customMergeRender, @@ -1386,15 +1421,10 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr cellGroup.appendChild(customElementsGroup); } - const rangeHeight = table.getRowHeight(row); - const rangeWidth = table.getColWidth(col); - - const { width: contentWidth } = cellGroup.attribute; - const { height: contentHeight } = cellGroup.attribute; - cellGroup.contentWidth = contentWidth; - cellGroup.contentHeight = contentHeight; - resizeCellGroup(cellGroup, rangeWidth, rangeHeight, customMergeRange, table); + if (isCornerCustomMergeRange(customMergeRange, table)) { + cellGroup.setAttribute('clip', false); + } return customMergeRange; } diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts index 8ce3149af2..a93eb26142 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts @@ -48,6 +48,12 @@ export function createCellGroup( range: CellRange | undefined, isAsync: boolean ): Group { + const isCornerCustomMergeContentCell = + !!customElementsGroup && + !!range?.isCustom && + table.isCornerHeader(range.start.col, range.start.row) && + col === range.end.col && + row === range.end.row; const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); if (isValid(functionalPadding)) { @@ -84,7 +90,7 @@ export function createCellGroup( cursor: (cellTheme?.group as any)?.cursor ?? undefined, lineDash: cellTheme?.group?.lineDash ?? undefined, lineCap: 'butt', - clip: true, + clip: !isCornerCustomMergeContentCell, cornerRadius: cellTheme.group.cornerRadius } as any); } @@ -106,7 +112,7 @@ export function createCellGroup( lineCap: 'butt', - clip: true, + clip: !isCornerCustomMergeContentCell, cornerRadius: cellTheme.group.cornerRadius } as any); diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 55a70bc19b..bae4956126 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -13,7 +13,12 @@ import { updateImageCellContentWhileResize } from '../group-creater/cell-type/im import { getStyleTheme } from '../../core/tableHelper'; import { isMergeCellGroup } from '../utils/is-merge-cell-group'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; -import { resizeCellGroup, getCustomCellMergeCustom } from '../group-creater/cell-helper'; +import { + resizeCellGroup, + getCustomCellMergeCustom, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../group-creater/cell-helper'; import type { IGraphic } from '@src/vrender'; import { getCellMergeRange } from '../../tools/merge-range'; import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; @@ -254,6 +259,9 @@ export function updateCellHeight( const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); let width = cell.attribute.width; let height = cell.attribute.height; + const mergeRange = isMergeCellGroup(cell) + ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) + : undefined; if (isMergeCellGroup(cell)) { width = scene.table.getColsWidth(cell.mergeStartCol, cell.mergeEndCol); height = scene.table.getRowsHeight(cell.mergeStartRow, cell.mergeEndRow); @@ -268,15 +276,12 @@ export function updateCellHeight( false, scene.table.isAutoRowHeight(row), padding, - isMergeCellGroup(cell) - ? { - start: { col: cell.mergeStartCol, row: cell.mergeStartRow }, - end: { col: cell.mergeEndCol, row: cell.mergeEndRow } - } - : undefined, + mergeRange, scene.table ); - customElementsGroup = customResult.elementsGroup; + customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table) + ? customResult.elementsGroup + : undefined; renderDefault = customResult.renderDefault; } @@ -285,6 +290,12 @@ export function updateCellHeight( } else if (customElementsGroup) { cell.appendChild(customElementsGroup); } + if (isMergeCellGroup(cell)) { + const mergeRange = scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, scene.table)) { + cell.setAttribute('clip', !shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table)); + } + } } } // if (renderDefault) { @@ -375,6 +386,10 @@ function updateMergeCellContentHeight( } } } + const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, table)) { + table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); + } } else { const style = table._getCellStyle(cellGroup.col, cellGroup.row); const padding = getQuadProps(getProp('padding', style, cellGroup.col, cellGroup.row, table)); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index ac06b5a68e..2c835d386a 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -10,7 +10,12 @@ import type { Group } from '../graphic/group'; import { updateImageCellContentWhileResize } from '../group-creater/cell-type/image-cell'; import type { CreateProgressBarCell } from '../group-creater/cell-type/progress-bar-cell'; import type { CreateSparkLineCellGroup } from '../group-creater/cell-type/spark-line-cell'; -import { resizeCellGroup, getCustomCellMergeCustom } from '../group-creater/cell-helper'; +import { + resizeCellGroup, + getCustomCellMergeCustom, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../group-creater/cell-helper'; import type { Scenegraph } from '../scenegraph'; import { getCellMergeInfo } from '../utils/get-cell-merge'; import { getProp } from '../utils/get-prop'; @@ -417,6 +422,9 @@ function updateCellWidth( const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); let width = cellGroup.attribute.width; let height = cellGroup.attribute.height; + const mergeRange = isMergeCellGroup(cellGroup) + ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) + : undefined; if (isMergeCellGroup(cellGroup)) { width = scene.table.getColsWidth(cellGroup.mergeStartCol, cellGroup.mergeEndCol); height = scene.table.getRowsHeight(cellGroup.mergeStartRow, cellGroup.mergeEndRow); @@ -431,15 +439,12 @@ function updateCellWidth( false, scene.table.isAutoRowHeight(row), padding, - isMergeCellGroup(cellGroup) - ? { - start: { col: cellGroup.mergeStartCol, row: cellGroup.mergeStartRow }, - end: { col: cellGroup.mergeEndCol, row: cellGroup.mergeEndRow } - } - : undefined, + mergeRange, scene.table ); - customElementsGroup = customResult.elementsGroup; + customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table) + ? customResult.elementsGroup + : undefined; renderDefault = customResult.renderDefault; isHeightChange = true; } @@ -449,6 +454,12 @@ function updateCellWidth( } else if (customElementsGroup) { cell.appendChild(customElementsGroup); } + if (isMergeCellGroup(cellGroup)) { + const mergeRange = scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, scene.table)) { + cell.setAttribute('clip', !shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table)); + } + } } } const cellChange = updateMergeCellContentWidth( @@ -567,6 +578,10 @@ function updateMergeCellContentWidth( isHeightChange = isHeightChange || changed; } } + const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); + if (isCornerCustomMergeRange(mergeRange, table)) { + table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); + } return isHeightChange; } From 4bbfbf2f3463ae9bbf061d4ec966d824895ab1d7 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 21 Aug 2026 10:26:18 +0800 Subject: [PATCH 03/65] docs: link v1.26.7 changelog issues Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/changelog/en/release.md | 14 +++++++------- docs/assets/changelog/zh/release.md | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 8a3c7c18e3..f5f4f054d8 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -5,17 +5,17 @@ **🆕 New Features** - **@visactor/vtable**: support tooltip appear delay -- **@visactor/vtable**: add audio cell type support (GitHub #5271) +- **@visactor/vtable**: add audio cell type support [#5271](https://github.com/VisActor/VTable/issues/5271) **🐛 Bug Fixes** -- **@visactor/vtable**: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752) -- **@visactor/vtable**: preserve tree checkbox state after row drag reorder (GitHub #3975) -- **@visactor/vtable**: add context menu click event typing (GitHub #4655) -- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- **@visactor/vtable**: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows [#3752](https://github.com/VisActor/VTable/issues/3752) +- **@visactor/vtable**: preserve tree checkbox state after row drag reorder [#3975](https://github.com/VisActor/VTable/issues/3975) +- **@visactor/vtable**: add context menu click event typing [#4655](https://github.com/VisActor/VTable/issues/4655) +- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation [#4964](https://github.com/VisActor/VTable/issues/4964) - **@visactor/vtable**: render vue components in top frozen rows -- **@visactor/vtable**: align frame border with short frozen rows (GitHub #5277) -- **@visactor/vtable**: keep headers after setRecords at scroll edge (GitHub #5278) +- **@visactor/vtable**: align frame border with short frozen rows [#5277](https://github.com/VisActor/VTable/issues/5277) +- **@visactor/vtable**: keep headers after setRecords at scroll edge [#5278](https://github.com/VisActor/VTable/issues/5278) **Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 7c8059ff82..6664a95646 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -5,17 +5,17 @@ **🆕 新增功能** - **@visactor/vtable**: 支持配置 Tooltip 展示延迟 -- **@visactor/vtable**: 新增音频单元格类型支持(GitHub #5271) +- **@visactor/vtable**: 新增音频单元格类型支持 [#5271](https://github.com/VisActor/VTable/issues/5271) **🐛 问题修复** -- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白(GitHub #3752) -- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态(GitHub #3975) -- **@visactor/vtable**: 新增右键菜单点击事件类型定义(GitHub #4655) -- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录(GitHub #4964) +- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白 [#3752](https://github.com/VisActor/VTable/issues/3752) +- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态 [#3975](https://github.com/VisActor/VTable/issues/3975) +- **@visactor/vtable**: 新增右键菜单点击事件类型定义 [#4655](https://github.com/VisActor/VTable/issues/4655) +- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录 [#4964](https://github.com/VisActor/VTable/issues/4964) - **@visactor/vtable**: 支持在顶部冻结行中渲染 Vue 组件 -- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐(GitHub #5277) -- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示(GitHub #5278) +- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐 [#5277](https://github.com/VisActor/VTable/issues/5277) +- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示 [#5278](https://github.com/VisActor/VTable/issues/5278) **完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 From ec25f7d7e3c1dd60c14ae07790a37390adb9ed3d Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 21 Aug 2026 11:08:56 +0800 Subject: [PATCH 04/65] fix(vtable): harden custom merged corner rendering Avoid resize crashes and constrain custom merged corner content to a single clipped container. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../scenegraph/group-creater/cell-helper.ts | 40 +++++++-------- .../group-creater/cell-type/text-cell.ts | 14 ++++-- .../src/scenegraph/layout/update-height.ts | 25 +++++----- .../src/scenegraph/layout/update-width.ts | 25 +++++----- .../scenegraph/utils/corner-custom-merge.ts | 49 +++++++++++++++++++ 5 files changed, 106 insertions(+), 47 deletions(-) create mode 100644 packages/vtable/src/scenegraph/utils/corner-custom-merge.ts diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 0327e021bd..e897e949ff 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -43,22 +43,14 @@ import { onBeforeAttributeUpdateForInvertHighlight } from '../../plugins/invert- import { getCellBorderStrokeWidth } from '../utils/cell-border-stroke-width'; import type { CreateSwitchCellGroup } from './cell-type/switch-cell'; import type { CreateButtonCellGroup } from './cell-type/button-cell'; +import { + createCornerCustomMergeContainer, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; -export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { - return !!range?.isCustom && table.isCornerHeader(range.start.col, range.start.row); -} - -export function shouldRenderCornerCustomMergeContent( - col: number, - row: number, - range: CellRange | undefined, - table: BaseTableAPI -): boolean { - return !isCornerCustomMergeRange(range, table) || (col === range.end.col && row === range.end.row); -} - export function 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; @@ -605,10 +597,11 @@ function _generateCustomElementsGroup( let renderDefault = true; if (customResult) { // custom merge custom render - customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, range, table) - ? customResult.elementsGroup + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); + customElementsGroup = shouldRenderContent + ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table) : undefined; - renderDefault = customResult.renderDefault; + renderDefault = shouldRenderContent ? customResult.renderDefault : false; } else if (range?.isCustom && !table.isCornerHeader(col, row)) { // 判断不是角头单元格,来兼容corner中设置的customLayout // custom merge && no custom render @@ -638,10 +631,11 @@ function _generateCustomElementsGroup( range, table ); - customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, range, table) - ? customResult.elementsGroup + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); + customElementsGroup = shouldRenderContent + ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table) : undefined; - renderDefault = customResult.renderDefault; + renderDefault = shouldRenderContent ? customResult.renderDefault : false; } } return { @@ -1413,7 +1407,13 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr table ); - const customElementsGroup = customResult.elementsGroup; + const customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + contentWidth, + contentHeight, + customMergeRange, + table + ); if (cellGroup.childrenCount > 0 && customElementsGroup) { cellGroup.insertBefore(customElementsGroup, cellGroup.firstChild); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts index a93eb26142..04e7f8deef 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts @@ -9,6 +9,7 @@ import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { createMark } from '../../graphic/mark'; +import { isCornerCustomMergeRange } from '../../utils/corner-custom-merge'; /** * @description: 创建单元格场景节点 @@ -49,11 +50,8 @@ export function createCellGroup( isAsync: boolean ): Group { const isCornerCustomMergeContentCell = - !!customElementsGroup && - !!range?.isCustom && - table.isCornerHeader(range.start.col, range.start.row) && - col === range.end.col && - row === range.end.row; + !!customElementsGroup && isCornerCustomMergeRange(range, table) && col === range.end.col && row === range.end.row; + // The carrier cell must allow the clipped merge container to span internal cell bounds. const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); if (isValid(functionalPadding)) { @@ -124,6 +122,12 @@ export function createCellGroup( if (customElementsGroup) { cellGroup.appendChild(customElementsGroup); } + if (isCornerCustomMergeContentCell) { + cellGroup.setAttributes({ + width: Math.max(cellGroup.attribute.width, customElementsGroup.attribute.width ?? 0), + height: Math.max(cellGroup.attribute.height, customElementsGroup.attribute.height ?? 0) + }); + } if (renderDefault) { const textStr: string = value; let icons; diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index bae4956126..4df8519720 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -13,16 +13,16 @@ import { updateImageCellContentWhileResize } from '../group-creater/cell-type/im import { getStyleTheme } from '../../core/tableHelper'; import { isMergeCellGroup } from '../utils/is-merge-cell-group'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; -import { - resizeCellGroup, - getCustomCellMergeCustom, - isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent -} from '../group-creater/cell-helper'; +import { resizeCellGroup, getCustomCellMergeCustom } from '../group-creater/cell-helper'; import type { IGraphic } from '@src/vrender'; import { getCellMergeRange } from '../../tools/merge-range'; import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; import { Factory } from '../../core/factory'; +import { + createCornerCustomMergeContainer, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, skipTableHeightMap?: boolean) { // 更新table行高存储 @@ -221,8 +221,10 @@ export function updateCellHeight( const customContainer = (mergedCell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (mergedCell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); - customContainer.removeAllChild(); - mergedCell.removeChild(customContainer); + if (customContainer) { + customContainer.removeAllChild(); + mergedCell.removeChild(customContainer); + } getCustomCellMergeCustom(col, mergeRow, mergedCell, scene.table); } } else { @@ -279,10 +281,11 @@ export function updateCellHeight( mergeRange, scene.table ); - customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table) - ? customResult.elementsGroup + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + customElementsGroup = shouldRenderContent + ? createCornerCustomMergeContainer(customResult.elementsGroup, width, height, mergeRange, scene.table) : undefined; - renderDefault = customResult.renderDefault; + renderDefault = shouldRenderContent ? customResult.renderDefault : false; } if (cell.childrenCount > 0 && customElementsGroup) { diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 2c835d386a..a86d2eeccd 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -10,18 +10,18 @@ import type { Group } from '../graphic/group'; import { updateImageCellContentWhileResize } from '../group-creater/cell-type/image-cell'; import type { CreateProgressBarCell } from '../group-creater/cell-type/progress-bar-cell'; import type { CreateSparkLineCellGroup } from '../group-creater/cell-type/spark-line-cell'; -import { - resizeCellGroup, - getCustomCellMergeCustom, - isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent -} from '../group-creater/cell-helper'; +import { resizeCellGroup, getCustomCellMergeCustom } from '../group-creater/cell-helper'; import type { Scenegraph } from '../scenegraph'; import { getCellMergeInfo } from '../utils/get-cell-merge'; import { getProp } from '../utils/get-prop'; import { isMergeCellGroup } from '../utils/is-merge-cell-group'; import { getQuadProps } from '../utils/padding'; import { updateCellContentWidth } from '../utils/text-icon-layout'; +import { + createCornerCustomMergeContainer, + isCornerCustomMergeRange, + shouldRenderCornerCustomMergeContent +} from '../utils/corner-custom-merge'; import { computeRowHeight } from './compute-row-height'; import { updateCellHeightForRow } from './update-height'; import { getHierarchyOffset } from '../utils/get-hierarchy-offset'; @@ -384,8 +384,10 @@ function updateCellWidth( const customContainer = (mergedCell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (mergedCell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); - customContainer.removeAllChild(); - mergedCell.removeChild(customContainer); + if (customContainer) { + customContainer.removeAllChild(); + mergedCell.removeChild(customContainer); + } getCustomCellMergeCustom(mergeCol, row, mergedCell, scene.table); } } else { @@ -442,10 +444,11 @@ function updateCellWidth( mergeRange, scene.table ); - customElementsGroup = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table) - ? customResult.elementsGroup + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + customElementsGroup = shouldRenderContent + ? createCornerCustomMergeContainer(customResult.elementsGroup, width, height, mergeRange, scene.table) : undefined; - renderDefault = customResult.renderDefault; + renderDefault = shouldRenderContent ? customResult.renderDefault : false; isHeightChange = true; } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts new file mode 100644 index 0000000000..0fcc2f101a --- /dev/null +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -0,0 +1,49 @@ +import type { Group as VGroup } from '@src/vrender'; +import type { CellRange } from '../../ts-types'; +import type { BaseTableAPI } from '../../ts-types/base-table'; +import { CUSTOM_CONTAINER_NAME } from '../component/custom'; +import { Group } from '../graphic/group'; + +export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { + return ( + !!range?.isCustom && + table.isCornerHeader(range.start.col, range.start.row) && + table.isCornerHeader(range.end.col, range.end.row) + ); +} + +export function shouldRenderCornerCustomMergeContent( + col: number, + row: number, + range: CellRange | undefined, + table: BaseTableAPI +): boolean { + return !isCornerCustomMergeRange(range, table) || (col === range.end.col && row === range.end.row); +} + +export function createCornerCustomMergeContainer( + customElementsGroup: VGroup | undefined, + width: number, + height: number, + range: CellRange | undefined, + table: BaseTableAPI +): VGroup | undefined { + if (!customElementsGroup || !isCornerCustomMergeRange(range, table)) { + return customElementsGroup; + } + + const customContainer = new Group({ + x: 0, + y: 0, + width, + height, + fill: false, + stroke: false, + pickable: false, + clip: true + }); + customContainer.name = CUSTOM_CONTAINER_NAME; + customContainer.appendChild(customElementsGroup); + + return customContainer as unknown as VGroup; +} From 2cf8c7d0bb0c61f97b8edb5129fdad49b023de18 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 21 Aug 2026 11:47:38 +0800 Subject: [PATCH 05/65] build: update vrender dependencies Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vtable/package.json b/packages/vtable/package.json index db7f9bb259..1f2ae411ea 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -53,11 +53,11 @@ }, "dependencies": { "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vrender": "1.1.4", - "@visactor/vrender-core": "1.1.4", - "@visactor/vrender-kits": "1.1.4", - "@visactor/vrender-components": "1.1.4", - "@visactor/vrender-animate": "1.1.4", + "@visactor/vrender": "~1.1.8", + "@visactor/vrender-core": "~1.1.8", + "@visactor/vrender-kits": "~1.1.8", + "@visactor/vrender-components": "~1.1.8", + "@visactor/vrender-animate": "~1.1.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", From 21905cae12084f168793f0bcb59da0a29dd2b781 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 21 Aug 2026 11:51:34 +0800 Subject: [PATCH 06/65] revert: move vrender dependency update off develop Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 1f2ae411ea..db7f9bb259 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -53,11 +53,11 @@ }, "dependencies": { "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vrender": "~1.1.8", - "@visactor/vrender-core": "~1.1.8", - "@visactor/vrender-kits": "~1.1.8", - "@visactor/vrender-components": "~1.1.8", - "@visactor/vrender-animate": "~1.1.8", + "@visactor/vrender": "1.1.4", + "@visactor/vrender-core": "1.1.4", + "@visactor/vrender-kits": "1.1.4", + "@visactor/vrender-components": "1.1.4", + "@visactor/vrender-animate": "1.1.4", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", From acdf2c517f08f9f501ba0bcaedc3a23dbf916630 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 21 Aug 2026 11:55:36 +0800 Subject: [PATCH 07/65] build: update vrender dependencies Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vtable/package.json b/packages/vtable/package.json index db7f9bb259..1f2ae411ea 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -53,11 +53,11 @@ }, "dependencies": { "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vrender": "1.1.4", - "@visactor/vrender-core": "1.1.4", - "@visactor/vrender-kits": "1.1.4", - "@visactor/vrender-components": "1.1.4", - "@visactor/vrender-animate": "1.1.4", + "@visactor/vrender": "~1.1.8", + "@visactor/vrender-core": "~1.1.8", + "@visactor/vrender-kits": "~1.1.8", + "@visactor/vrender-components": "~1.1.8", + "@visactor/vrender-animate": "~1.1.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", From d1873fc1b938d28bb3231d81dc88ec9631cd6fd1 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 14:42:37 +0800 Subject: [PATCH 08/65] docs: complete personnel info demo icons Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../assets/demo/en/business/personnel-info.md | 26 ++++++++++++------- docs/assets/demo/menu.json | 4 +-- .../assets/demo/zh/business/personnel-info.md | 26 ++++++++++++------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/assets/demo/en/business/personnel-info.md b/docs/assets/demo/en/business/personnel-info.md index 58eec69c25..107ce7aed8 100644 --- a/docs/assets/demo/en/business/personnel-info.md +++ b/docs/assets/demo/en/business/personnel-info.md @@ -2,7 +2,7 @@ category: examples group: Business title: Personnel Information Sheet -cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif link: custom_define/custom_icon --- @@ -19,6 +19,19 @@ Display personnel information with phone numbers masked by default (showing only ## Code Demo ```javascript livedemo template=vtable +const eyeOpenSvg = ` + + + +`; + +const eyeCloseSvg = ` + + + + +`; + // Register "show full number" icon (eye open) VTable.register.icon('eye-open', { type: 'svg', @@ -33,10 +46,7 @@ VTable.register.icon('eye-open', { title: 'Click to hide number', placement: VTable.TYPES.Placement.top }, - svg: ` - - - ` + svg: eyeOpenSvg }); // Register "hide number" icon (eye closed) @@ -53,11 +63,7 @@ VTable.register.icon('eye-close', { title: 'Click to show number', placement: VTable.TYPES.Placement.top }, - svg: ` - - - - ` + svg: eyeCloseSvg }); // Mask phone number: keep first 3 and last 4 digits, replace the rest with **** diff --git a/docs/assets/demo/menu.json b/docs/assets/demo/menu.json index 4384c31907..b9fdf73b22 100644 --- a/docs/assets/demo/menu.json +++ b/docs/assets/demo/menu.json @@ -1866,7 +1866,7 @@ "keywords": "personnel, phone mask, privacy, icon, click", "category": "demo", "group": "Business", - "cover": "https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png", + "cover": "https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif", "link": "'../../guide/custom_define/custom_icon'", "option": "" } @@ -1875,4 +1875,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/assets/demo/zh/business/personnel-info.md b/docs/assets/demo/zh/business/personnel-info.md index f64e2613cc..530af3d153 100644 --- a/docs/assets/demo/zh/business/personnel-info.md +++ b/docs/assets/demo/zh/business/personnel-info.md @@ -2,7 +2,7 @@ category: examples group: Business title: 人员信息表 -cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/list-table.png +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/personnel-info.gif link: custom_define/custom_icon --- @@ -19,6 +19,19 @@ link: custom_define/custom_icon ## 代码演示 ```javascript livedemo template=vtable +const eyeOpenSvg = ` + + + +`; + +const eyeCloseSvg = ` + + + + +`; + // 注册"显示完整号码"图标(眼睛张开) VTable.register.icon('eye-open', { type: 'svg', @@ -33,10 +46,7 @@ VTable.register.icon('eye-open', { title: '点击隐藏号码', placement: VTable.TYPES.Placement.top }, - svg: ` - - - ` + svg: eyeOpenSvg }); // 注册"隐藏号码"图标(眼睛闭合) @@ -53,11 +63,7 @@ VTable.register.icon('eye-close', { title: '点击显示号码', placement: VTable.TYPES.Placement.top }, - svg: ` - - - - ` + svg: eyeCloseSvg }); // 脱敏电话号码:保留前3位和后4位,中间替换为 **** From d33ffb7611ccdebb752e8c4a1cbc2f9d8cac9bf9 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 15:26:17 +0800 Subject: [PATCH 09/65] docs: fix personnel info eye icon state Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/demo/en/business/personnel-info.md | 18 +++++++++--------- docs/assets/demo/zh/business/personnel-info.md | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/assets/demo/en/business/personnel-info.md b/docs/assets/demo/en/business/personnel-info.md index 107ce7aed8..cfed56e7d0 100644 --- a/docs/assets/demo/en/business/personnel-info.md +++ b/docs/assets/demo/en/business/personnel-info.md @@ -14,7 +14,7 @@ Display personnel information with phone numbers masked by default (showing only - `fieldFormat` formats phone numbers: shows `138****5678` by default - `VTable.register.icon` registers eye-open / eye-close icons with `visibleTime: 'always'` to always show the toggle button -- `click_cell` event listens for icon clicks, toggles per-row visibility state, and calls `updateRecords` to refresh the cell +- `click_cell` event listens for icon clicks, toggles per-row visibility state, and recreates cells to keep the phone number and icon state in sync ## Code Demo @@ -32,7 +32,7 @@ const eyeCloseSvg = ` `; -// Register "show full number" icon (eye open) +// Register "click to show number" icon (eye open) VTable.register.icon('eye-open', { type: 'svg', name: 'eye-open', @@ -43,13 +43,13 @@ VTable.register.icon('eye-open', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: 'Click to hide number', + title: 'Click to show number', placement: VTable.TYPES.Placement.top }, svg: eyeOpenSvg }); -// Register "hide number" icon (eye closed) +// Register "click to hide number" icon (eye closed) VTable.register.icon('eye-close', { type: 'svg', name: 'eye-close', @@ -60,7 +60,7 @@ VTable.register.icon('eye-close', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: 'Click to show number', + title: 'Click to hide number', placement: VTable.TYPES.Placement.top }, svg: eyeCloseSvg @@ -122,10 +122,10 @@ const columns = [ const rowId = record.id; return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); }, - // Show eye-open when full number is visible, eye-close when masked + // Show the next action: eye-open when masked, eye-close when the full number is visible icon(args) { const rowId = args.record?.id; - return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + return phoneVisible[rowId] ? 'eye-close' : 'eye-open'; } }, { @@ -162,8 +162,8 @@ tableInstance.on('click_cell', args => { const rowId = record.id; // Toggle visibility for this row phoneVisible[rowId] = !phoneVisible[rowId]; - // Refresh the row to reflect the new state - tableInstance.updateRecords([record], [row - tableInstance.columnHeaderLevelCount]); + // Recreate cells to refresh both the phone number and icon state + tableInstance.renderWithRecreateCells(); } }); ``` diff --git a/docs/assets/demo/zh/business/personnel-info.md b/docs/assets/demo/zh/business/personnel-info.md index 530af3d153..cf179fc58f 100644 --- a/docs/assets/demo/zh/business/personnel-info.md +++ b/docs/assets/demo/zh/business/personnel-info.md @@ -14,7 +14,7 @@ link: custom_define/custom_icon - `fieldFormat` 格式化电话号码,默认只显示前三位和后四位,中间用 `****` 替换 - `VTable.register.icon` 注册显示/隐藏图标,通过 `visibleTime: 'always'` 常驻显示 -- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态并调用 `updateRecords` 刷新 +- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态并重建单元格,确保号码和图标状态同步刷新 ## 代码演示 @@ -32,7 +32,7 @@ const eyeCloseSvg = ` `; -// 注册"显示完整号码"图标(眼睛张开) +// 注册"点击显示号码"图标(眼睛张开) VTable.register.icon('eye-open', { type: 'svg', name: 'eye-open', @@ -43,13 +43,13 @@ VTable.register.icon('eye-open', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: '点击隐藏号码', + title: '点击显示号码', placement: VTable.TYPES.Placement.top }, svg: eyeOpenSvg }); -// 注册"隐藏号码"图标(眼睛闭合) +// 注册"点击隐藏号码"图标(眼睛闭合) VTable.register.icon('eye-close', { type: 'svg', name: 'eye-close', @@ -60,7 +60,7 @@ VTable.register.icon('eye-close', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: '点击显示号码', + title: '点击隐藏号码', placement: VTable.TYPES.Placement.top }, svg: eyeCloseSvg @@ -122,10 +122,10 @@ const columns = [ const rowId = record.id; return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); }, - // 根据展开状态动态切换图标 + // 根据下一步操作动态切换图标:隐藏状态显示开眼,展开状态显示闭眼 icon(args) { const rowId = args.record?.id; - return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; + return phoneVisible[rowId] ? 'eye-close' : 'eye-open'; } }, { @@ -162,8 +162,8 @@ tableInstance.on('click_cell', args => { const rowId = record.id; // 切换该行的显示状态 phoneVisible[rowId] = !phoneVisible[rowId]; - // 刷新该行数据 - tableInstance.updateRecords([record], [row - tableInstance.columnHeaderLevelCount]); + // 重建单元格以同步刷新号码和图标状态 + tableInstance.renderWithRecreateCells(); } }); ``` From 61e5d054120908d9c36f891336afa723c85bc9da Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastava Date: Mon, 24 Aug 2026 13:58:25 +0530 Subject: [PATCH 10/65] docs: fix typo defalut -> default --- docs/assets/demo/en/animation/scroll-animation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/assets/demo/en/animation/scroll-animation.md b/docs/assets/demo/en/animation/scroll-animation.md index cfd08870c8..90c4a5ae16 100644 --- a/docs/assets/demo/en/animation/scroll-animation.md +++ b/docs/assets/demo/en/animation/scroll-animation.md @@ -14,7 +14,7 @@ Scroll to the specified area of ​​the table through animation. - `animationOption` scroll animation configuration - `duration` animation duration, unit ms - - `easing` animation easing function, defalut `linear`, support `linear`, `quadIn`, `quadOut`, `quadInOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `backIn`, `backOut`, `backInOut`, `circIn`, `circOut`, `circInOut`, `bounceOut`, `bounceIn`, `bounceInOut`, `elasticIn`, `elasticOut`, `elasticInOut`, `sineIn`, `sineOut`, `sineInOut`, `expoIn`, `expoOut`, `expoInOut` + - `easing` animation easing function, default `linear`, support `linear`, `quadIn`, `quadOut`, `quadInOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `quartIn`, `quartOut`, `quartInOut`, `quintIn`, `quintOut`, `quintInOut`, `backIn`, `backOut`, `backInOut`, `circIn`, `circOut`, `circInOut`, `bounceOut`, `bounceIn`, `bounceInOut`, `elasticIn`, `elasticOut`, `elasticInOut`, `sineIn`, `sineOut`, `sineInOut`, `expoIn`, `expoOut`, `expoInOut` - `scrollToCell({col, row}, animationOption)` scroll to specified cell - `scrollToRow(row, animationOption)` scroll to specified row - `scrollToCol(col, animationOption)` scroll to specified column From 60ae1a8b39e12a139985591680da1132e6afbba8 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 16:41:55 +0800 Subject: [PATCH 11/65] docs: fix personnel info eye icon toggle Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../assets/demo/en/business/personnel-info.md | 21 +++++++++++-------- .../assets/demo/zh/business/personnel-info.md | 21 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/assets/demo/en/business/personnel-info.md b/docs/assets/demo/en/business/personnel-info.md index cfed56e7d0..c77fdebbeb 100644 --- a/docs/assets/demo/en/business/personnel-info.md +++ b/docs/assets/demo/en/business/personnel-info.md @@ -13,8 +13,8 @@ Display personnel information with phone numbers masked by default (showing only ## Key Configuration - `fieldFormat` formats phone numbers: shows `138****5678` by default -- `VTable.register.icon` registers eye-open / eye-close icons with `visibleTime: 'always'` to always show the toggle button -- `click_cell` event listens for icon clicks, toggles per-row visibility state, and recreates cells to keep the phone number and icon state in sync +- `VTable.register.icon` registers eye-open / eye-close state icons with `visibleTime: 'always'` to always show the toggle button +- `click_cell` listens for icon clicks, toggles the phone visibility for that row, clears the old tooltip, and recreates cells to keep the phone number and icon state in sync ## Code Demo @@ -32,7 +32,7 @@ const eyeCloseSvg = ` `; -// Register "click to show number" icon (eye open) +// Register "phone visible" state icon (eye open) VTable.register.icon('eye-open', { type: 'svg', name: 'eye-open', @@ -43,13 +43,13 @@ VTable.register.icon('eye-open', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: 'Click to show number', + title: 'Click to hide number', placement: VTable.TYPES.Placement.top }, svg: eyeOpenSvg }); -// Register "click to hide number" icon (eye closed) +// Register "phone hidden" state icon (eye closed) VTable.register.icon('eye-close', { type: 'svg', name: 'eye-close', @@ -60,7 +60,7 @@ VTable.register.icon('eye-close', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: 'Click to hide number', + title: 'Click to show number', placement: VTable.TYPES.Placement.top }, svg: eyeCloseSvg @@ -122,10 +122,11 @@ const columns = [ const rowId = record.id; return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); }, - // Show the next action: eye-open when masked, eye-close when the full number is visible + // Show current state: eye-close when masked, eye-open when the full number is visible icon(args) { - const rowId = args.record?.id; - return phoneVisible[rowId] ? 'eye-close' : 'eye-open'; + const record = args.table.getCellOriginRecord(args.col, args.row); + const rowId = record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; } }, { @@ -162,6 +163,8 @@ tableInstance.on('click_cell', args => { const rowId = record.id; // Toggle visibility for this row phoneVisible[rowId] = !phoneVisible[rowId]; + // Clear the current tooltip so stale icon text does not remain after toggling + tableInstance.internalProps.tooltipHandler?._unbindFromCell?.(); // Recreate cells to refresh both the phone number and icon state tableInstance.renderWithRecreateCells(); } diff --git a/docs/assets/demo/zh/business/personnel-info.md b/docs/assets/demo/zh/business/personnel-info.md index cf179fc58f..83a5689b09 100644 --- a/docs/assets/demo/zh/business/personnel-info.md +++ b/docs/assets/demo/zh/business/personnel-info.md @@ -13,8 +13,8 @@ link: custom_define/custom_icon ## 关键配置 - `fieldFormat` 格式化电话号码,默认只显示前三位和后四位,中间用 `****` 替换 -- `VTable.register.icon` 注册显示/隐藏图标,通过 `visibleTime: 'always'` 常驻显示 -- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态并重建单元格,确保号码和图标状态同步刷新 +- `VTable.register.icon` 注册显示/隐藏状态图标,通过 `visibleTime: 'always'` 常驻显示 +- `click_cell` 事件监听图标点击,切换该行的电话号码显示状态,清理旧 tooltip 并重建单元格,确保号码和图标状态同步刷新 ## 代码演示 @@ -32,7 +32,7 @@ const eyeCloseSvg = ` `; -// 注册"点击显示号码"图标(眼睛张开) +// 注册"号码已显示"状态图标(眼睛张开) VTable.register.icon('eye-open', { type: 'svg', name: 'eye-open', @@ -43,13 +43,13 @@ VTable.register.icon('eye-open', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: '点击显示号码', + title: '点击隐藏号码', placement: VTable.TYPES.Placement.top }, svg: eyeOpenSvg }); -// 注册"点击隐藏号码"图标(眼睛闭合) +// 注册"号码已隐藏"状态图标(眼睛闭合) VTable.register.icon('eye-close', { type: 'svg', name: 'eye-close', @@ -60,7 +60,7 @@ VTable.register.icon('eye-close', { cursor: 'pointer', visibleTime: 'always', tooltip: { - title: '点击隐藏号码', + title: '点击显示号码', placement: VTable.TYPES.Placement.top }, svg: eyeCloseSvg @@ -122,10 +122,11 @@ const columns = [ const rowId = record.id; return phoneVisible[rowId] ? record.phone : maskPhone(record.phone); }, - // 根据下一步操作动态切换图标:隐藏状态显示开眼,展开状态显示闭眼 + // 根据号码显示状态动态切换图标:隐藏状态显示闭眼,展开状态显示开眼 icon(args) { - const rowId = args.record?.id; - return phoneVisible[rowId] ? 'eye-close' : 'eye-open'; + const record = args.table.getCellOriginRecord(args.col, args.row); + const rowId = record?.id; + return phoneVisible[rowId] ? 'eye-open' : 'eye-close'; } }, { @@ -162,6 +163,8 @@ tableInstance.on('click_cell', args => { const rowId = record.id; // 切换该行的显示状态 phoneVisible[rowId] = !phoneVisible[rowId]; + // 清理当前 tooltip,避免点击后仍残留旧图标的提示文案 + tableInstance.internalProps.tooltipHandler?._unbindFromCell?.(); // 重建单元格以同步刷新号码和图标状态 tableInstance.renderWithRecreateCells(); } From b2833d8860a1644ac78998eec268e8d1e6bf9422 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 19:06:16 +0800 Subject: [PATCH 12/65] fix: support dynamic gantt mark line styles Allow markLine styles and line widths to recalculate during refresh. This lets zoomed timelines render proportional marker widths. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/demo/en/gantt/gantt-zoom.md | 22 ++++++ docs/assets/demo/zh/gantt/gantt-zoom.md | 22 ++++++ docs/assets/guide/en/gantt/gantt_markline.md | 63 +++++++++++++++-- docs/assets/guide/zh/gantt/gantt_markline.md | 61 ++++++++++++++-- .../option/en/common/gantt/mark-line.md | 51 +++++++++++++- .../option/zh/common/gantt/mark-line.md | 53 +++++++++++++- .../__tests__/gantt-bugserver-init.test.ts | 69 +++++++++++++++++++ .../vtable-gantt/examples/gantt/gantt-zoom.ts | 41 +++++------ packages/vtable-gantt/src/gantt-helper.ts | 28 +++++--- .../vtable-gantt/src/scenegraph/mark-line.ts | 23 ++++++- .../vtable-gantt/src/ts-types/gantt-engine.ts | 23 ++++++- 11 files changed, 406 insertions(+), 50 deletions(-) diff --git a/docs/assets/demo/en/gantt/gantt-zoom.md b/docs/assets/demo/en/gantt/gantt-zoom.md index 0f60a23f20..2966597c57 100644 --- a/docs/assets/demo/en/gantt/gantt-zoom.md +++ b/docs/assets/demo/en/gantt/gantt-zoom.md @@ -19,6 +19,7 @@ The Gantt chart smart zoom feature provides multi-level timeline display schemes - API get current zoom state: Use methods provided by `getCurrentZoomState` - API set current zoom state: Use methods provided by `setZoomPosition` - API zooming: Use zoom methods provided by `zoomScaleManager` +- `markLine.style.lineWidth`: Can be configured as a function to calculate the mark line width from the current zoomed timeline column width ## Demo @@ -729,6 +730,27 @@ const option = { lineColor: '#f1f3f4' } }, + markLine: [ + { + date: '2024-07-17', + content: 'Dynamic width', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: 'Fixed width', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], headerRowHeight: 50, rowHeight: 40, overscrollBehavior: 'none' diff --git a/docs/assets/demo/zh/gantt/gantt-zoom.md b/docs/assets/demo/zh/gantt/gantt-zoom.md index 1bc57c9eba..36ee410448 100644 --- a/docs/assets/demo/zh/gantt/gantt-zoom.md +++ b/docs/assets/demo/zh/gantt/gantt-zoom.md @@ -19,6 +19,7 @@ option: Gantt#zoomScale - API 获得当前缩放状态:使用 `getCurrentZoomState` 提供的方法 - API 设置当前缩放状态:使用 `setZoomPosition` 提供的方法 - API 缩放:使用 `zoomScaleManager` 提供的缩放方法 +- `markLine.style.lineWidth`: 可配置函数,根据当前缩放后的时间轴列宽动态计算标记线宽度 ## 代码演示 @@ -700,6 +701,27 @@ const option = { lineColor: '#f1f3f4' } }, + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: '固定宽度', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], headerRowHeight: 50, rowHeight: 40, overscrollBehavior: 'none' diff --git a/docs/assets/guide/en/gantt/gantt_markline.md b/docs/assets/guide/en/gantt/gantt_markline.md index 6324a1d1c1..f8aa562ae9 100644 --- a/docs/assets/guide/en/gantt/gantt_markline.md +++ b/docs/assets/guide/en/gantt/gantt_markline.md @@ -15,12 +15,12 @@ The configuration items of IMarkLine are as follows: ```javascript export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** The position of the mark line under the date column. The default is 'left'. */ position?: 'left' | 'right' | 'middle' | 'date'; - /** Automatically scroll the date range to include this mark line. */ + /** Automatically scroll the date range to include this mark line. */ scrollToMarkLine?: boolean; - content?: string; // markLine中内容 + content?: string; // markLine content /** The style of the content in the markLine. */ contentStyle?: { color?: string; @@ -31,6 +31,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ## Mark Line Style @@ -43,17 +57,54 @@ const ganttOptions = { { date: '2024-01-01', style: { - color: 'red', + lineColor: 'red', + lineWidth: 1, }, }, { date: '2024-01-02', style: { - color: 'blue', + lineColor: 'blue', + lineWidth: 2, }, }, ], }; +``` + +When the mark line width needs to change with timeline zooming, configure `style.lineWidth` as a function. The function is re-executed whenever the mark line refreshes, and its argument includes the current timeline column width, cell width, and milliseconds-per-pixel ratio: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + content: 'Dynamic width', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } + ] +}; +``` + +You can also configure the whole `style` as a function: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + style: ({ millisecondsPerPixel }) => ({ + lineWidth: millisecondsPerPixel > 86400000 ? 1 : 3, + lineColor: 'red' + }) + } + ] +}; +``` ## Mark Line Position @@ -110,4 +161,4 @@ const ganttOptions = { ], }; ``` -Normally, if the date '2024-01-01' is outside the display range of the Gantt chart, when the `scrollToMarkLine: true` configuration is set, the Gantt chart will automatically scroll to the mark line position. \ No newline at end of file +Normally, if the date '2024-01-01' is outside the display range of the Gantt chart, when the `scrollToMarkLine: true` configuration is set, the Gantt chart will automatically scroll to the mark line position. diff --git a/docs/assets/guide/zh/gantt/gantt_markline.md b/docs/assets/guide/zh/gantt/gantt_markline.md index 33076d685f..3bc5bcc38d 100644 --- a/docs/assets/guide/zh/gantt/gantt_markline.md +++ b/docs/assets/guide/zh/gantt/gantt_markline.md @@ -15,10 +15,10 @@ IMarkLine 配置项如下: ```javascript export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; - /** 自动将日期范围内 包括改标记线 */ + /** 自动将日期范围内包括该标记线 */ scrollToMarkLine?: boolean; content?: string; // markLine中内容 /** markLine中内容的样式 */ @@ -31,6 +31,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ## 标记线样式 @@ -43,17 +57,54 @@ const ganttOptions = { { date: '2024-01-01', style: { - color: 'red', + lineColor: 'red', + lineWidth: 1, }, }, { date: '2024-01-02', style: { - color: 'blue', + lineColor: 'blue', + lineWidth: 2, }, }, ], }; +``` + +当标记线宽度需要跟随时间轴缩放变化时,可以将 `style.lineWidth` 配置为函数。函数会在标记线刷新时重新执行,参数中包含当前时间轴列宽、单元格宽度和毫秒/像素比例等信息: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } + ] +}; +``` + +也可以将整个 `style` 配置为函数: + +```javascript +const ganttOptions = { + markLine: [ + { + date: '2024-07-17', + style: ({ millisecondsPerPixel }) => ({ + lineWidth: millisecondsPerPixel > 86400000 ? 1 : 3, + lineColor: 'red' + }) + } + ] +}; +``` ## 标记线位置 @@ -110,4 +161,4 @@ const ganttOptions = { ], }; ``` -正常如果日期 '2024-01-01'在甘特图的显示范围之外,当配置`scrollToMarkLine: true`时,甘特图会自动滚动到标记线位置。 \ No newline at end of file +正常如果日期 '2024-01-01'在甘特图的显示范围之外,当配置`scrollToMarkLine: true`时,甘特图会自动滚动到标记线位置。 diff --git a/docs/assets/option/en/common/gantt/mark-line.md b/docs/assets/option/en/common/gantt/mark-line.md index 94f36bb184..2adb936518 100644 --- a/docs/assets/option/en/common/gantt/mark-line.md +++ b/docs/assets/option/en/common/gantt/mark-line.md @@ -5,7 +5,7 @@ IMarkLine specific definition: ``` export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** The position where the mark line is displayed under the date column. Default is 'left' */ position?: 'left' | 'right' | 'middle' | 'date'; /** Automatically include the mark line within the date range */ @@ -21,6 +21,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ${prefix} date(string) @@ -29,7 +43,7 @@ Specify date Required -${prefix} style(ILineStyle) +${prefix} style(IMarkLineStyle | (args: IMarkLineStyleArgumentType) => IMarkLineStyle) Mark line style @@ -37,6 +51,39 @@ Optional {{ use: common-gantt-line-style }} +`style` can be configured as a function. The function is re-executed whenever the mark line refreshes, such as after timeline zooming, so the style can be recalculated from the latest timeline size. + +`style.lineWidth` also supports a function value, which can be used to dynamically calculate the mark line width based on the current zoom state. + +Function arguments: + +``` +export type IMarkLineStyleArgumentType = { + date: Date; // The mark line date + dateIndex: number; // The date cell index where the mark line is located + dateX: number; // The mark line x position in the timeline coordinate system + cellStartX: number; // The start x position of the date cell containing the mark line + cellWidth: number; // The current width of the date cell containing the mark line + timelineColWidth: number; // The current timeline column width after zoom or scale changes + millisecondsPerPixel: number; // Current milliseconds represented by one pixel +}; +``` + +Example: + +```javascript +markLine: [ + { + date: '2024-07-17', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } +] +``` + ${prefix} position('left' | 'right' | 'middle' | 'date') The position where the mark line is displayed under the date column. Default is 'left' diff --git a/docs/assets/option/zh/common/gantt/mark-line.md b/docs/assets/option/zh/common/gantt/mark-line.md index 6d3eccc461..2b6ad2424b 100644 --- a/docs/assets/option/zh/common/gantt/mark-line.md +++ b/docs/assets/option/zh/common/gantt/mark-line.md @@ -5,10 +5,10 @@ IMarkLine 具体定义: ``` export interface IMarkLine { date: string; - style?: ILineStyle; + style?: IMarkLineStyle | ((args: IMarkLineStyleArgumentType) => IMarkLineStyle); /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; - /** 自动将日期范围内 包括改标记线 */ + /** 自动将日期范围内包括该标记线 */ scrollToMarkLine?: boolean; content?: string; // markLine中内容 /** markLine中内容的样式 */ @@ -21,6 +21,20 @@ export interface IMarkLine { cornerRadius?: string; } } + +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; + +export type IMarkLineStyleArgumentType = { + date: Date; + dateIndex: number; + dateX: number; + cellStartX: number; + cellWidth: number; + timelineColWidth: number; + millisecondsPerPixel: number; +}; ``` ${prefix} date(string) @@ -29,7 +43,7 @@ ${prefix} date(string) 必填 -${prefix} style(ILineStyle) +${prefix} style(IMarkLineStyle | (args: IMarkLineStyleArgumentType) => IMarkLineStyle) 标记线样式 @@ -37,6 +51,39 @@ ${prefix} style(ILineStyle) {{ use: common-gantt-line-style }} +`style` 支持配置为函数,函数会在标记线刷新时重新执行,例如缩放时间轴后会基于最新时间轴尺寸重新计算样式。 + +`style.lineWidth` 也支持配置为函数,可用于根据当前缩放状态动态计算标记线宽度。 + +函数参数说明: + +``` +export type IMarkLineStyleArgumentType = { + date: Date; // 标记线对应日期 + dateIndex: number; // 标记线所在日期单元格索引 + dateX: number; // 标记线在时间轴坐标系中的 x 坐标 + cellStartX: number; // 标记线所在日期单元格起始 x 坐标 + cellWidth: number; // 标记线所在日期单元格当前宽度 + timelineColWidth: number; // 当前缩放或时间刻度下的时间轴列宽 + millisecondsPerPixel: number; // 当前每像素代表的毫秒数 +}; +``` + +示例: + +```javascript +markLine: [ + { + date: '2024-07-17', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + } +] +``` + ${prefix} position('left' | 'right' | 'middle' | 'date') 标记线显示在日期列下的位置 默认为'left' diff --git a/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts b/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts index bec18d12d3..062c97803a 100644 --- a/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts +++ b/packages/vtable-gantt/__tests__/gantt-bugserver-init.test.ts @@ -7,6 +7,23 @@ import { Gantt } from '../src/index'; import { defaultPixelRatio } from '../src/tools/pixel-ratio'; describe('bugserver gantt initialization', () => { + function findFirstMarkLineShape(gantt) { + let result; + function walk(node) { + if (!node || result) { + return; + } + if (node.type === 'line') { + result = node; + return; + } + const children = node.children || node._children; + children?.forEach?.(child => walk(child)); + } + walk(gantt.scenegraph.markLine.markLIneContainer); + return result; + } + test('initializes gantt with mixed date formats and top-level columns', () => { const container = createDiv(); container.style.width = '800px'; @@ -109,6 +126,58 @@ describe('bugserver gantt initialization', () => { container.remove(); }); + test('recomputes markLine function style when refreshed after zoom scale changes', () => { + const container = createDiv(); + container.style.width = '800px'; + container.style.height = '400px'; + const calls = []; + + const gantt = new Gantt(container, { + records: [ + { id: 1, title: 'Task 1', start: '2024-07-05', end: '2024-07-10' }, + { id: 2, title: 'Task 2', start: '2024-07-11', end: '2024-07-15' } + ], + columns: [{ field: 'title', title: 'title', width: 200 }], + taskBar: { + startDateField: 'start', + endDateField: 'end' + }, + timelineHeader: { + colWidth: 30, + scales: [ + { unit: 'week', step: 1, startOfWeek: 'sunday' }, + { unit: 'day', step: 1 } + ] + }, + minDate: '2024-07-01', + maxDate: '2024-07-31', + markLine: [ + { + date: '2024-07-17', + style: { + lineColor: 'blue', + lineWidth: ({ timelineColWidth }) => { + calls.push(timelineColWidth); + return timelineColWidth > 60 ? 6 : 2; + } + } + } + ] + }); + + expect(findFirstMarkLineShape(gantt).attribute.lineWidth).toBe(2); + + gantt.parsedOptions.timelineColWidth = 80; + gantt.scenegraph.markLine.refresh(); + + expect(findFirstMarkLineShape(gantt).attribute.lineWidth).toBe(6); + expect(calls).toContain(30); + expect(calls).toContain(80); + + gantt.release?.(); + container.remove(); + }); + test('initializes the gantt stage with the configured pixel ratio', () => { const container = createDiv(); container.style.width = '1600px'; diff --git a/packages/vtable-gantt/examples/gantt/gantt-zoom.ts b/packages/vtable-gantt/examples/gantt/gantt-zoom.ts index b436c2dce5..b68161803c 100644 --- a/packages/vtable-gantt/examples/gantt/gantt-zoom.ts +++ b/packages/vtable-gantt/examples/gantt/gantt-zoom.ts @@ -1172,26 +1172,27 @@ export function createTable() { }, // minDate: '2024-07-01', // maxDate: '2024-10-15', - // markLine: [ - // { - // date: '2024-07-17', - // style: { - // lineWidth: 1, - // lineColor: 'blue', - // lineDash: [8, 4] - // } - // }, - // { - // date: '2024-08-17', - // position: 'middle', - // // scrollToMarkLine: true, - // style: { - // lineWidth: 2, - // lineColor: 'red', - // lineDash: [8, 4] - // } - // } - // ], + markLine: [ + { + date: '2024-07-17', + content: '动态宽度', + style: { + lineWidth: ({ timelineColWidth }) => Math.max(1, Math.round(timelineColWidth / 20)), + lineColor: 'blue', + lineDash: [8, 4] + } + }, + { + date: '2024-08-17', + content: '固定宽度', + position: 'middle', + style: { + lineWidth: 2, + lineColor: 'red', + lineDash: [8, 4] + } + } + ], rowSeriesNumber: { title: '行号', dragOrder: true, diff --git a/packages/vtable-gantt/src/gantt-helper.ts b/packages/vtable-gantt/src/gantt-helper.ts index 854ce69322..b7a3dd96f5 100644 --- a/packages/vtable-gantt/src/gantt-helper.ts +++ b/packages/vtable-gantt/src/gantt-helper.ts @@ -72,30 +72,38 @@ export function generateMarkLine(markLine?: boolean | IMarkLine | IMarkLine[]): ]; } else if (Array.isArray(markLine)) { return markLine.map((item, index) => { + const style = item.style; return { ...item, date: item.date, scrollToMarkLine: item.scrollToMarkLine, position: item.position ?? 'left', - style: { - lineColor: item.style?.lineColor || 'red', - lineWidth: item.style?.lineWidth || 1, - lineDash: item.style?.lineDash - } + style: + typeof style === 'function' + ? style + : { + lineColor: style?.lineColor || 'red', + lineWidth: style?.lineWidth ?? 1, + lineDash: style?.lineDash + } }; }); } + const style = (markLine as IMarkLine).style; return [ { ...markLine, date: (markLine as IMarkLine).date, scrollToMarkLine: (markLine as IMarkLine).scrollToMarkLine ?? true, position: (markLine as IMarkLine).position ?? 'left', - style: { - lineColor: (markLine as IMarkLine).style?.lineColor || 'red', - lineWidth: (markLine as IMarkLine).style?.lineWidth || 1, - lineDash: (markLine as IMarkLine).style?.lineDash - } + style: + typeof style === 'function' + ? style + : { + lineColor: style?.lineColor || 'red', + lineWidth: style?.lineWidth ?? 1, + lineDash: style?.lineDash + } } ]; } diff --git a/packages/vtable-gantt/src/scenegraph/mark-line.ts b/packages/vtable-gantt/src/scenegraph/mark-line.ts index 660e7e97e8..e7578a4d56 100644 --- a/packages/vtable-gantt/src/scenegraph/mark-line.ts +++ b/packages/vtable-gantt/src/scenegraph/mark-line.ts @@ -1,9 +1,18 @@ import { createDateAtMidnight } from '../tools/util'; -import { DayTimes } from '../gantt-helper'; -//import type { IMarkLine } from '../ts-types'; +import type { ILineStyle, IMarkLine, IMarkLineStyleArgumentType } from '../ts-types'; import type { Scenegraph } from './scenegraph'; import { Group, createLine, Text } from '@visactor/vtable/es/vrender'; +function resolveMarkLineStyle(line: IMarkLine, args: IMarkLineStyleArgumentType): ILineStyle { + const style = typeof line.style === 'function' ? line.style(args) : line.style; + const lineWidth = typeof style?.lineWidth === 'function' ? style.lineWidth(args) : style?.lineWidth; + return { + lineColor: style?.lineColor || 'red', + lineWidth: lineWidth ?? 1, + lineDash: style?.lineDash + }; +} + export class MarkLine { _scene: Scenegraph; group: Group; @@ -53,7 +62,6 @@ export class MarkLine { const minDate = this._scene._gantt.parsedOptions.minDate; minDate && markLine.forEach(line => { - const style = line.style; const contentStyle = line.contentStyle || {}; const date = this._scene._gantt.parsedOptions.timeScaleIncludeHour ? createDateAtMidnight(line.date) @@ -76,6 +84,15 @@ export class MarkLine { } else if (line.position === 'middle') { dateX = cellStartX + cellWidth / 2; } + const style = resolveMarkLineStyle(line, { + date, + dateIndex: cellIndex, + dateX, + cellStartX, + cellWidth, + timelineColWidth: this._scene._gantt.parsedOptions.timelineColWidth, + millisecondsPerPixel: this._scene._gantt.getCurrentMillisecondsPerPixel() + }); const markLineGroup = new Group({ pickable: false, x: dateX - this.markLineContainerWidth / 2, diff --git a/packages/vtable-gantt/src/ts-types/gantt-engine.ts b/packages/vtable-gantt/src/ts-types/gantt-engine.ts index cb33f7b05d..d4d837b611 100644 --- a/packages/vtable-gantt/src/ts-types/gantt-engine.ts +++ b/packages/vtable-gantt/src/ts-types/gantt-engine.ts @@ -328,6 +328,27 @@ export type ILineStyle = { lineWidth?: number; lineDash?: number[]; }; +/** markLine style function arguments, recalculated whenever markLine is refreshed, such as after zoom changes. */ +export type IMarkLineStyleArgumentType = { + /** The normalized markLine date. */ + date: Date; + /** The date cell index where the markLine is located. */ + dateIndex: number; + /** The markLine x position in the timeline coordinate system. */ + dateX: number; + /** The start x position of the date cell containing the markLine. */ + cellStartX: number; + /** The current width of the date cell containing the markLine. */ + cellWidth: number; + /** The current timeline column width after zoom or scale changes. */ + timelineColWidth: number; + /** Current milliseconds represented by one pixel. */ + millisecondsPerPixel: number; +}; +export type IMarkLineStyle = Omit & { + lineWidth?: number | ((args: IMarkLineStyleArgumentType) => number); +}; +export type IMarkLineStyleFunction = (args: IMarkLineStyleArgumentType) => IMarkLineStyle; export type IPointStyle = { strokeColor?: string; strokeWidth?: number; @@ -345,7 +366,7 @@ export interface IMarkLine { backgroundColor?: string; cornerRadius?: number | number[]; }; - style?: ILineStyle; + style?: IMarkLineStyle | IMarkLineStyleFunction; /** 标记线显示在日期列下的位置 默认为'left' */ position?: 'left' | 'right' | 'middle' | 'date'; /** 自动将日期范围内 包括改标记线 */ From d7403e056d25285615194c33b5cf2425df800941 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Mon, 24 Aug 2026 19:46:24 +0800 Subject: [PATCH 13/65] fix: prevent image preview for null/undefined image values When image column cell value is null, undefined, or empty string, clicking the cell should not open the broken image preview dialog. Fixes #5290 --- packages/vtable/src/event/media-click.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 2634896777..d18c4d65f4 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -339,6 +339,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 From 0b473d9133af774f226539f20c66be285e5db76e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 19:49:09 +0800 Subject: [PATCH 14/65] fix: support copy fallback on http Use event clipboard data when async clipboard is unavailable. This keeps copy working in non-secure contexts. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 11 +++++ packages/vtable/src/event/event.ts | 40 ++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json new file mode 100644 index 0000000000..49a8fe711d --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: support copy through event clipboard data when async clipboard is unavailable (GitHub #5274)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index bae7c2fd28..ce9b7fdce0 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -794,6 +794,12 @@ export class EventManager { if (isValid(data)) { e.preventDefault(); + const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; + if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, e)) { + this.afterCopyData(data, isCut); + return; + } + // 确保表格元素获得焦点,避免Document is not focused错误 const element = table.getElement(); if (element && element !== document.activeElement) { @@ -863,11 +869,7 @@ export class EventManager { this.fallbackCopyToClipboard(data, e); } - table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: table.stateManager.select.ranges, - copyData: data, - isCut - }); + this.afterCopyData(data, isCut, false); } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 @@ -880,6 +882,34 @@ export class EventManager { } } + private afterCopyData(data: string, isCut: boolean, updateCopyCellBorder: boolean = true): void { + const table = this.table; + table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { + cellRange: table.stateManager.select.ranges, + copyData: data, + isCut + }); + if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { + setActiveCellRangeState(table); + table.clearSelected(); + } + } + + private setCopyDataToEventClipboard(data: string, e: KeyboardEvent): boolean { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData; + if (!clipboardData) { + return false; + } + + try { + clipboardData.setData('text/plain', data); + return true; + } catch (error) { + console.warn('事件剪贴板写入失败,使用降级方案:', error); + return false; + } + } + // 降级复制方案 private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void { try { From faaa8d91b5d4757c7cae6bf092b073fac05f9951 Mon Sep 17 00:00:00 2001 From: Wu Shuwen <108231307+dajiaohuang@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:23:22 +0800 Subject: [PATCH 15/65] fix(vtable-sheet): exclude test shim from compile --- .../fix-vtable-sheet-compile_2026-08-25-09-30.json | 11 +++++++++++ packages/vtable-sheet/src/components/vtable-sheet.ts | 2 +- packages/vtable-sheet/tsconfig.json | 5 +++-- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json diff --git a/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json new file mode 100644 index 0000000000..19d4cb2339 --- /dev/null +++ b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-sheet", + "comment": "fix: keep test-only plugin sources out of the vtable-sheet package compile", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-sheet", + "email": "108231307+dajiaohuang@users.noreply.github.com" +} diff --git a/packages/vtable-sheet/src/components/vtable-sheet.ts b/packages/vtable-sheet/src/components/vtable-sheet.ts index 4122db8b26..9a80348b22 100644 --- a/packages/vtable-sheet/src/components/vtable-sheet.ts +++ b/packages/vtable-sheet/src/components/vtable-sheet.ts @@ -13,7 +13,7 @@ import type { IColumnDefine, SheetData } from '../ts-types'; -import type { MultiSheetImportResult } from '@visactor/vtable-plugins/src/excel-import/types'; +import type { MultiSheetImportResult } from '@visactor/vtable-plugins'; import type { TableEventHandlersEventArgumentMap } from '@visactor/vtable/es/ts-types/events'; import SheetTabDragManager from '../managers/tab-drag-manager'; import { FormulaAutocomplete } from '../formula/formula-autocomplete'; diff --git a/packages/vtable-sheet/tsconfig.json b/packages/vtable-sheet/tsconfig.json index 21a3c4de00..db86a17eeb 100644 --- a/packages/vtable-sheet/tsconfig.json +++ b/packages/vtable-sheet/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@internal/ts-config/tsconfig.base.json", "include": ["src"], - "compilerOptions": { + "exclude": ["src/test-shims"], + "compilerOptions": { "types": ["jest", "offscreencanvas", "node"], "lib": ["DOM", "ESNext"], "baseUrl": "./", @@ -32,4 +33,4 @@ "module": "commonjs" } } -} \ No newline at end of file +} From 0e73de9390d3e1a4b2d81d52ef96a9398ab28a31 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 14:41:35 +0800 Subject: [PATCH 16/65] fix: refine http clipboard fallback Preserve cut ranges in the synchronous clipboard fallback path and keep text/html output aligned with the async clipboard branch. Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index ce9b7fdce0..50efcf0fd4 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -795,8 +795,9 @@ export class EventManager { e.preventDefault(); const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; - if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, e)) { - this.afterCopyData(data, isCut); + const dataHTML = this.getCopyDataHTML(data); + if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + this.afterCopyData(data, isCut, !isCut); return; } @@ -832,19 +833,6 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) if (window.ClipboardItem) { - let htmlValues = data; - if ( - table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) - table.options.keyboardOptions?.getCopyCellValue?.html - ) { - htmlValues = this.table.getCopyValue( - table.options.keyboardOptions?.getCopyCellValue.html as ( - col: number, - row: number - ) => string | number - ); - } - const dataHTML = setDataToHTML(htmlValues); await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), @@ -895,7 +883,21 @@ export class EventManager { } } - private setCopyDataToEventClipboard(data: string, e: KeyboardEvent): boolean { + private getCopyDataHTML(data: string): string { + const table = this.table; + let htmlValues = data; + if ( + table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) + table.options.keyboardOptions?.getCopyCellValue?.html + ) { + htmlValues = this.table.getCopyValue( + table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } + return setDataToHTML(htmlValues); + } + + private setCopyDataToEventClipboard(data: string, dataHTML: string, e: KeyboardEvent): boolean { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { return false; @@ -903,6 +905,7 @@ export class EventManager { try { clipboardData.setData('text/plain', data); + clipboardData.setData('text/html', dataHTML); return true; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); From 1863020e6af106a511063acda624176cd808a4dc Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 15:16:12 +0800 Subject: [PATCH 17/65] fix: handle clipboard html fallback Keep fallback paste aligned with HTML clipboard data and let plain text copy continue when custom HTML generation fails. Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 212 +++++++++++++++++------------ 1 file changed, 123 insertions(+), 89 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 50efcf0fd4..207675842f 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -832,7 +832,7 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) - if (window.ClipboardItem) { + if (window.ClipboardItem && dataHTML) { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), @@ -883,21 +883,31 @@ export class EventManager { } } - private getCopyDataHTML(data: string): string { + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; - if ( - table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) - table.options.keyboardOptions?.getCopyCellValue?.html - ) { - htmlValues = this.table.getCopyValue( - table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number - ); + try { + if ( + table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) + table.options.keyboardOptions?.getCopyCellValue?.html + ) { + htmlValues = this.table.getCopyValue( + table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } + return setDataToHTML(htmlValues); + } catch (error) { + console.warn('复制HTML数据生成失败,使用纯文本内容:', error); + try { + return setDataToHTML(data); + } catch (fallbackError) { + console.warn('复制HTML降级数据生成失败:', fallbackError); + return null; + } } - return setDataToHTML(htmlValues); } - private setCopyDataToEventClipboard(data: string, dataHTML: string, e: KeyboardEvent): boolean { + private setCopyDataToEventClipboard(data: string, dataHTML: string | null, e: KeyboardEvent): boolean { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { return false; @@ -905,7 +915,13 @@ export class EventManager { try { clipboardData.setData('text/plain', data); - clipboardData.setData('text/html', dataHTML); + if (dataHTML) { + try { + clipboardData.setData('text/html', dataHTML); + } catch (error) { + console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); + } + } return true; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); @@ -1099,7 +1115,16 @@ export class EventManager { const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard; if (clipboardData) { - const pastedData = clipboardData.getData('text') || clipboardData.getData('Text'); + const htmlData = clipboardData.getData('text/html'); + if (htmlData) { + const pasted = await this.processPastedHTML(htmlData); + if (pasted) { + return; + } + } + + const pastedData = + clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); if (pastedData) { await this.processPastedText(pastedData, col, row); return; @@ -1110,6 +1135,89 @@ export class EventManager { } } + private async processPastedHTML(pastedData: string): Promise { + // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 + const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 + // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 + const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 + const table = this.table; + const ranges = table.stateManager.select.ranges; + const selectRangeLength = ranges.length; + const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); + const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); + const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + let pasteValuesColCount = 0; + let pasteValuesRowCount = 0; + let values: (string | number)[][] = []; + + if (!pastedData || !/(标签中的内容 + const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签及其内容 + const rowValues = cellMatches.map(cellMatch => { + return ( + cellMatch[1] + .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 + .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n + // .replace(/
/g, '\n') // 替换
标签为换行符 + // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 + //将字符串中的 HTML 实体字符转换为原始的字符 + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /gi, '\t') + .replace(/ /g, ' ') + ); + // .trim(); // 去除首尾空格 + }); + values.push(rowValues); + pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); + } + + pasteValuesRowCount = values.length ?? 0; + values = this.handlePasteValues( + values, + pasteValuesRowCount, + pasteValuesColCount, + maxRow - row + 1, + maxCol - col + 1 + ); + let processedValues; + // 检查是否支持公式处理(针对vtable-sheet) + if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { + // 使用复制时记录的源位置(而不是当前的选中位置) + processedValues = table.options.keyboardOptions.processFormulaBeforePaste( + values, + this.copySourceRange.startCol, + this.copySourceRange.startRow, + col, + row + ); + } + + const changedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + processedValues ? processedValues : values, + true + ); + if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { + col, + row, + pasteData: processedValues ? processedValues : values, + changedCellResults + }); + } + return true; + } + // 处理粘贴的文本数据 private async processPastedText(pastedData: string, col: number, row: number): Promise { const table = this.table; @@ -1211,84 +1319,10 @@ export class EventManager { } } private pasteHtmlToTable(item: ClipboardItem) { - // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 - const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 - // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 - const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 - const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; item.getType('text/html').then((blob: any) => { blob.text().then(async (pastedData: any) => { - // 解析html数据 - if (pastedData && /(标签中的内容 - const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签中的内容 - const rowValues = cellMatches.map(cellMatch => { - return ( - cellMatch[1] - .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 - .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n - // .replace(/
/g, '\n') // 替换
标签为换行符 - // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 - //将字符串中的 HTML 实体字符转换为原始的字符 - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/ /gi, '\t') - .replace(/ /g, ' ') - ); - // .trim(); // 去除首尾空格 - }); - values.push(rowValues); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - } - pasteValuesRowCount = values.length ?? 0; - values = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - let processedValues; - // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 使用复制时记录的源位置(而不是当前的选中位置) - processedValues = table.options.keyboardOptions.processFormulaBeforePaste( - values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, - col, - row - ); - } - - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); - } - } else { + const pasted = await this.processPastedHTML(pastedData); + if (!pasted) { navigator.clipboard.read().then(clipboardItems => { for (const item of clipboardItems) { if (item.types.includes('text/plain')) { From 495e251d312ddf5bdc2d34d6484275ad6e75b8ed Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 16:41:13 +0800 Subject: [PATCH 18/65] fix: stabilize clipboard html paste handling Snapshot paste targets before async reads and decode copied HTML cell content through DOM parsing so entity values round-trip correctly. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 223 ++++++++++++----------------- packages/vtable/src/event/util.ts | 2 +- 2 files changed, 93 insertions(+), 132 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 207675842f..02d24e6ed1 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -38,6 +38,13 @@ import { bindDBClickAutoColumnWidthEvent } from './self-event-listener/base-tabl import { browser } from '../tools/helper'; import { clearActiveCellRangeState, setActiveCellRangeState } from '../tools/style'; +type PasteRange = { + col: number; + row: number; + maxCol: number; + maxRow: number; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -798,6 +805,12 @@ export class EventManager { const dataHTML = this.getCopyDataHTML(data); if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { this.afterCopyData(data, isCut, !isCut); + if (isCut && table.keyboardOptions?.showCopyCellBorder) { + window.setTimeout(() => { + setActiveCellRangeState(table); + table.clearSelected(); + }, 0); + } return; } @@ -1004,16 +1017,20 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteRange = this.getPasteRange(); + if (!pasteRange) { + return; + } if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(e); + this.executePaste(e, pasteRange); return; } this.checkClipboardChanged() .then(changed => { // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - this.executePaste(e); + this.executePaste(e, pasteRange); if (!changed) { this.clearCutArea(this.table as ListTableAPI); } @@ -1031,7 +1048,7 @@ export class EventManager { }) .catch(() => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - this.executePaste(e); + this.executePaste(e, pasteRange); // 执行完粘贴操作后,重置剪切状态 if (this.cutWaitPaste) { this.cutWaitPaste = false; @@ -1044,12 +1061,12 @@ export class EventManager { } }); } - private async executePaste(e: any) { + private async executePaste(e: any, pasteRange: PasteRange) { const table = this.table; if ((table as ListTableAPI).editorManager?.editingEditor) { return; } - if ((table as ListTableAPI).changeCellValues && table.stateManager.select.ranges?.length > 0) { + if ((table as ListTableAPI).changeCellValues) { try { // 优先使用现代剪贴板API if (navigator.clipboard && navigator.clipboard.read) { @@ -1061,11 +1078,14 @@ export class EventManager { for (const item of clipboardItems) { // 优先处理 html 格式数据 if (item.types.includes('text/html')) { - await this.pasteHtmlToTable(item); - handled = true; - break; - } else if (item.types.includes('text/plain')) { - await this.pasteTextToTable(item); + const pasted = await this.pasteHtmlToTable(item, pasteRange); + if (pasted) { + handled = true; + break; + } + } + if (item.types.includes('text/plain')) { + await this.pasteTextToTable(item, pasteRange); handled = true; break; } @@ -1073,21 +1093,21 @@ export class EventManager { if (!handled) { // 如果没有处理任何数据,使用降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } catch (clipboardError) { console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); // 降级到传统方法 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } else { // 不支持现代剪贴板API,使用降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } catch (error) { console.error('粘贴操作失败:', error); // 最后的降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } if (table.keyboardOptions?.showCopyCellBorder) { @@ -1095,12 +1115,24 @@ export class EventManager { } } + private getPasteRange(): PasteRange | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + const selectRangeLength = ranges.length; + const range = ranges[selectRangeLength - 1]; + return { + col: Math.min(range.start.col, range.end.col), + row: Math.min(range.start.row, range.end.row), + maxCol: Math.max(range.start.col, range.end.col), + maxRow: Math.max(range.start.row, range.end.row) + }; + } + // 降级粘贴方案 - private async fallbackPasteFromClipboard(e: any): Promise { + private async fallbackPasteFromClipboard(e: any, pasteRange: PasteRange): Promise { const table = this.table; - const ranges = table.stateManager.select.ranges; - const col = Math.min(ranges[0].start.col, ranges[0].end.col); - const row = Math.min(ranges[0].start.row, ranges[0].end.row); try { // 确保表格元素获得焦点 @@ -1117,16 +1149,20 @@ export class EventManager { if (clipboardData) { const htmlData = clipboardData.getData('text/html'); if (htmlData) { - const pasted = await this.processPastedHTML(htmlData); - if (pasted) { - return; + try { + const pasted = await this.processPastedHTML(htmlData, pasteRange); + if (pasted) { + return; + } + } catch (error) { + console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); } } const pastedData = clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); if (pastedData) { - await this.processPastedText(pastedData, col, row); + await this.processPastedText(pastedData, pasteRange); return; } } @@ -1135,18 +1171,19 @@ export class EventManager { } } - private async processPastedHTML(pastedData: string): Promise { + private decodeHTMLCellValue(cellHTML: string): string { + const template = document.createElement('template'); + template.innerHTML = cellHTML.replace(/]*>[\r\n]?/gim, '\n'); + return template.content.textContent ?? ''; + } + + private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 - const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 + const regex = /]*>([\s\S]*?)<\/tr>/gi; // for webpack3 // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 - const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 + const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; // for webpack3 const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const { col, row, maxCol, maxRow } = pasteRange; let pasteValuesColCount = 0; let pasteValuesRowCount = 0; let values: (string | number)[][] = []; @@ -1159,21 +1196,12 @@ export class EventManager { const matches = Array.from(pastedData.matchAll(regex)) as RegExpMatchArray[]; for (const match of matches) { const rowContent = match[1]; // 获取标签中的内容 - const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签及其内容 + const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取/标签及其内容 + if (!cellMatches.length) { + continue; + } const rowValues = cellMatches.map(cellMatch => { - return ( - cellMatch[1] - .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 - .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n - // .replace(/
/g, '\n') // 替换
标签为换行符 - // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 - //将字符串中的 HTML 实体字符转换为原始的字符 - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/ /gi, '\t') - .replace(/ /g, ' ') - ); + return this.decodeHTMLCellValue(cellMatch[1]); // .trim(); // 去除首尾空格 }); values.push(rowValues); @@ -1181,6 +1209,9 @@ export class EventManager { } pasteValuesRowCount = values.length ?? 0; + if (!pasteValuesRowCount || !pasteValuesColCount) { + return false; + } values = this.handlePasteValues( values, pasteValuesRowCount, @@ -1219,8 +1250,9 @@ export class EventManager { } // 处理粘贴的文本数据 - private async processPastedText(pastedData: string, col: number, row: number): Promise { + private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { const table = this.table; + const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 const values: (string | number)[][] = []; @@ -1318,95 +1350,24 @@ export class EventManager { }, 50); } } - private pasteHtmlToTable(item: ClipboardItem) { - item.getType('text/html').then((blob: any) => { - blob.text().then(async (pastedData: any) => { - const pasted = await this.processPastedHTML(pastedData); - if (!pasted) { - navigator.clipboard.read().then(clipboardItems => { - for (const item of clipboardItems) { - if (item.types.includes('text/plain')) { - item.getType('text/plain').then((blob: Blob) => { - blob.text().then(data => this._pasteValue(data)); - }); - } - } - }); - } - }); - }); - } - - private async _pasteValue(pastedData: string) { - const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; - const rows = pastedData.split('\n'); // 将数据拆分为行 - rows.forEach(function (rowCells: any, rowIndex: number) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); - }); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - }); - pasteValuesRowCount = values.length ?? 0; - values = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - let processedValues; - // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 利用复制时记录的源位置,对粘贴的数据进行公式处理 - processedValues = table.options.keyboardOptions.processFormulaBeforePaste( - values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, - col, - row - ); - } - // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); + private async pasteHtmlToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { + try { + const blob = await item.getType('text/html'); + const pastedData = await blob.text(); + const pasted = await this.processPastedHTML(pastedData, pasteRange); + if (pasted) { + return true; + } + } catch (error) { + console.warn('Paste html operation failed:', error); } + return false; } - private async pasteTextToTable(item: ClipboardItem) { + + private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange) { const table = this.table; // 如果只有 'text/plain' - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const { col, row, maxCol, maxRow } = pasteRange; try { const blob = await item.getType('text/plain'); diff --git a/packages/vtable/src/event/util.ts b/packages/vtable/src/event/util.ts index 0469ac0a13..fcc388a116 100644 --- a/packages/vtable/src/event/util.ts +++ b/packages/vtable/src/event/util.ts @@ -134,7 +134,7 @@ export function setDataToHTML(data: string) { cells.forEach(function (cell: string, cellIndex: number) { // 单元格数据处理 const parsedCellData = !cell - ? ' ' + ? '' : cell .toString() .replace(/&/g, '&') // replace & with & to prevent XSS attacks From 143266da88146942c5c1bb3268dd37b131196cc5 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 17:30:16 +0800 Subject: [PATCH 19/65] fix: normalize pasted html cell content Handle CRLF after br tags, detect table markup case-insensitively, and normalize decoded non-breaking spaces. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 02d24e6ed1..913dc8649e 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -1173,8 +1173,8 @@ export class EventManager { private decodeHTMLCellValue(cellHTML: string): string { const template = document.createElement('template'); - template.innerHTML = cellHTML.replace(/]*>[\r\n]?/gim, '\n'); - return template.content.textContent ?? ''; + template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n'); + return (template.content.textContent ?? '').replace(/\u00a0/g, ' '); } private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { @@ -1188,7 +1188,7 @@ export class EventManager { let pasteValuesRowCount = 0; let values: (string | number)[][] = []; - if (!pastedData || !/( Date: Tue, 25 Aug 2026 19:04:38 +0800 Subject: [PATCH 20/65] fix: harden clipboard cut paste state Snapshot copy and paste state before async clipboard work. Only clear cut sources after a confirmed paste write. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 2 +- packages/vtable/src/event/event.ts | 310 +++++++++++------- packages/vtable/src/tools/style.ts | 4 +- 3 files changed, 193 insertions(+), 123 deletions(-) diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json index 49a8fe711d..2145611016 100644 --- a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable", - "comment": "fix: support copy through event clipboard data when async clipboard is unavailable (GitHub #5274)", + "comment": "fix: support copy and paste through event clipboard data when the async Clipboard API is unavailable, and normalize pasted HTML cell content (GitHub #5274)", "type": "patch" } ], diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 913dc8649e..76f4618bdd 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -45,6 +45,22 @@ type PasteRange = { maxRow: number; }; +type ClipboardRange = { + start: { col: number; row: number }; + end: { col: number; row: number }; +}; + +type CopySnapshot = { + ranges: ClipboardRange[]; + copySourceRange: { startCol: number; startRow: number } | null; + cellInfos: CellInfo[][] | null; +}; + +type EventClipboardData = { + html: string; + text: string; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -776,23 +792,15 @@ export class EventManager { this._enableTableScroll = false; } - async handleCopy(e: KeyboardEvent, isCut: boolean = false) { + async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; !isCut && (this.cutWaitPaste = false); - this.copySourceRange = null; - // 记录复制时的源位置(用于公式相对引用调整) - const sourceRanges = table.stateManager.select.ranges; - if (sourceRanges && sourceRanges.length === 1) { - // 只有一个选区的时候才需要采取解析公式(和excel一致),才需要记录源位置 - const sourceRange = sourceRanges[0]; - this.copySourceRange = { - startCol: Math.min(sourceRange.start.col, sourceRange.end.col), - startRow: Math.min(sourceRange.start.row, sourceRange.end.row) - }; - } else if (!sourceRanges?.length) { + const copySnapshot = this.getCopySnapshot(isCut); + this.copySourceRange = copySnapshot?.copySourceRange ?? null; + if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 - return; + return null; } const data = this.table.getCopyValue( @@ -800,18 +808,25 @@ export class EventManager { ); if (isValid(data)) { e.preventDefault(); + let copySucceeded = false; const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; - const dataHTML = this.getCopyDataHTML(data); - if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { - this.afterCopyData(data, isCut, !isCut); + const canWriteRichClipboard = window.isSecureContext && !!navigator.clipboard?.write && !!window.ClipboardItem; + const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; + const dataHTML = + !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; + if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, !isCut); if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); }, 0); } - return; + return copySnapshot; } // 确保表格元素获得焦点,避免Document is not focused错误 @@ -845,57 +860,104 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) - if (window.ClipboardItem && dataHTML) { + if (canWriteRichClipboard && dataHTML) { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), 'text/plain': new Blob([data], { type: 'text/plain' }) }) ]); + copySucceeded = true; } else { // 降级到纯文本 await navigator.clipboard.writeText(data); + copySucceeded = true; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } } else { // 没有权限,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } } else { // 不支持现代剪贴板API,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } - this.afterCopyData(data, isCut, false); + if (!copySucceeded) { + return null; + } + + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, false); } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 - this.fallbackCopyToClipboard(data, e); + if (!this.fallbackCopyToClipboard(data)) { + return null; + } + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, false); } + } else { + return null; } if (table.keyboardOptions?.showCopyCellBorder) { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); } + return copySnapshot; } - private afterCopyData(data: string, isCut: boolean, updateCopyCellBorder: boolean = true): void { + private afterCopyData( + data: string, + isCut: boolean, + copySnapshot: CopySnapshot, + updateCopyCellBorder: boolean = true + ): void { const table = this.table; table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: table.stateManager.select.ranges, + cellRange: copySnapshot.ranges, copyData: data, isCut }); if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); } } + private getCopySnapshot(includeCellInfos: boolean): CopySnapshot | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + + const clonedRanges = ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + const sourceRange = ranges.length === 1 ? ranges[0] : null; + + return { + ranges: clonedRanges, + copySourceRange: sourceRange + ? { + startCol: Math.min(sourceRange.start.col, sourceRange.end.col), + startRow: Math.min(sourceRange.start.row, sourceRange.end.row) + } + : null, + cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null + }; + } + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; @@ -943,14 +1005,8 @@ export class EventManager { } // 降级复制方案 - private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void { + private fallbackCopyToClipboard(data: string): boolean { try { - // 尝试使用旧的 clipboardData API (在事件处理函数中直接设置) - if ((e as any).clipboardData) { - (e as any).clipboardData.setData('text/plain', data); - return; - } - // 确保当前文档有焦点 if (document.activeElement && document.activeElement !== document.body) { (document.activeElement as HTMLElement).blur(); @@ -977,6 +1033,7 @@ export class EventManager { if (!successful) { console.warn('execCommand复制返回false,可能不被支持'); } + return successful; } catch (execError) { console.warn('execCommand复制失败:', execError); } finally { @@ -985,16 +1042,17 @@ export class EventManager { } catch (error) { console.error('降级复制方案失败:', error); } + return false; } async handleCut(e: KeyboardEvent) { - this.handleCopy(e, true); + const copySnapshot = await this.handleCopy(e, true); + if (!copySnapshot) { + return; + } this.cutWaitPaste = true; - this.cutCellRange = this.table.getSelectedCellInfos(); - this.cutRanges = this.table.stateManager.select.ranges?.map(r => ({ - start: { col: r.start.col, row: r.start.row }, - end: { col: r.end.col, row: r.end.row } - })); + this.cutCellRange = copySnapshot.cellInfos; + this.cutRanges = copySnapshot.ranges; // 设置自动超时,防止剪切状态无限期保持 if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); @@ -1004,10 +1062,7 @@ export class EventManager { this.clipboardCheckTimer = window.setTimeout(() => { if (this.cutWaitPaste) { // 剪切操作超时,重置剪切状态 - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - this.clipboardCheckTimer = null; + this.resetCutState(); } }, 30000); // 30秒超时 @@ -1021,51 +1076,37 @@ export class EventManager { if (!pasteRange) { return; } + const eventClipboardData = this.getEventClipboardData(e); if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(e, pasteRange); + this.executePaste(pasteRange, eventClipboardData); return; } - this.checkClipboardChanged() - .then(changed => { + this.checkClipboardChanged(eventClipboardData) + .then(async changed => { // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - this.executePaste(e, pasteRange); - if (!changed) { + const pasted = await this.executePaste(pasteRange, eventClipboardData); + if (!changed && pasted) { this.clearCutArea(this.table as ListTableAPI); + this.resetCutState(); } - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; - } + if (changed) { + this.resetCutState(); } }) - .catch(() => { + .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - this.executePaste(e, pasteRange); - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; - } - } + await this.executePaste(pasteRange, eventClipboardData); + this.resetCutState(); }); } - private async executePaste(e: any, pasteRange: PasteRange) { + private async executePaste(pasteRange: PasteRange, eventClipboardData: EventClipboardData | null): Promise { const table = this.table; if ((table as ListTableAPI).editorManager?.editingEditor) { - return; + return false; } + let pasted = false; if ((table as ListTableAPI).changeCellValues) { try { // 优先使用现代剪贴板API @@ -1085,34 +1126,35 @@ export class EventManager { } } if (item.types.includes('text/plain')) { - await this.pasteTextToTable(item, pasteRange); - handled = true; + handled = await this.pasteTextToTable(item, pasteRange); break; } } if (!handled) { // 如果没有处理任何数据,使用降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + handled = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } + pasted = handled; } catch (clipboardError) { console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); // 降级到传统方法 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } else { // 不支持现代剪贴板API,使用降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } catch (error) { console.error('粘贴操作失败:', error); // 最后的降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } if (table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } + return pasted; } private getPasteRange(): PasteRange | null { @@ -1131,7 +1173,22 @@ export class EventManager { } // 降级粘贴方案 - private async fallbackPasteFromClipboard(e: any, pasteRange: PasteRange): Promise { + private getEventClipboardData(e: KeyboardEvent): EventClipboardData | null { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData || (window as any).clipboardData; + if (!clipboardData) { + return null; + } + return { + html: clipboardData.getData('text/html') || '', + text: clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' + }; + } + + // 降级粘贴方案 + private async fallbackPasteFromClipboard( + eventClipboardData: EventClipboardData | null, + pasteRange: PasteRange + ): Promise { const table = this.table; try { @@ -1143,38 +1200,32 @@ export class EventManager { await new Promise(resolve => setTimeout(resolve, 10)); } - // 尝试从事件对象获取剪贴板数据 - const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard; - - if (clipboardData) { - const htmlData = clipboardData.getData('text/html'); - if (htmlData) { + if (eventClipboardData) { + if (eventClipboardData.html) { try { - const pasted = await this.processPastedHTML(htmlData, pasteRange); + const pasted = await this.processPastedHTML(eventClipboardData.html, pasteRange); if (pasted) { - return; + return true; } } catch (error) { console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); } } - const pastedData = - clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); - if (pastedData) { - await this.processPastedText(pastedData, pasteRange); - return; + if (eventClipboardData.text) { + return await this.processPastedText(eventClipboardData.text, pasteRange); } } } catch (error) { console.error('降级粘贴方案失败:', error); } + return false; } private decodeHTMLCellValue(cellHTML: string): string { const template = document.createElement('template'); - template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n'); - return (template.content.textContent ?? '').replace(/\u00a0/g, ' '); + template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n').replace(/ /gi, ' '); + return template.content.textContent ?? ''; } private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { @@ -1238,19 +1289,12 @@ export class EventManager { processedValues ? processedValues : values, true ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); - } + this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); return true; } // 处理粘贴的文本数据 - private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { + private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { const table = this.table; const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 @@ -1288,13 +1332,29 @@ export class EventManager { processedValues ? processedValues : values, true ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); + return true; + } + + private firePastedDataEvent( + col: number, + row: number, + pasteData: (string | number)[][], + changedCellResults: any + ): void { + const table = this.table; + if (!table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + return; + } + try { table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { col, row, - pasteData: processedValues ? processedValues : values, + pasteData, changedCellResults }); + } catch (error) { + console.warn('PASTED_DATA listener failed:', error); } } // 清空选中区域的内容 @@ -1312,10 +1372,13 @@ export class EventManager { } // 检查剪贴板内容是否被其他应用更改 - private async checkClipboardChanged(): Promise { + private async checkClipboardChanged(eventClipboardData: EventClipboardData | null): Promise { // 如果不支持读取剪贴板,则无法检测变化 if (!navigator.clipboard || !navigator.clipboard.readText) { - return false; + if (eventClipboardData?.text) { + return eventClipboardData.text !== this.lastClipboardContent; + } + throw new Error('Clipboard readText is unavailable'); } try { @@ -1327,8 +1390,20 @@ export class EventManager { return currentContent !== this.lastClipboardContent; } catch (err) { console.warn('检查剪贴板状态失败:', err); - // 出错时假设剪贴板未变化 - return false; + throw err; + } + } + + private resetCutState(): void { + if (!this.cutWaitPaste) { + return; + } + this.cutWaitPaste = false; + this.cutCellRange = null; + this.cutRanges = null; + if (this.clipboardCheckTimer) { + clearTimeout(this.clipboardCheckTimer); + this.clipboardCheckTimer = null; } } @@ -1364,7 +1439,7 @@ export class EventManager { return false; } - private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange) { + private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { const table = this.table; // 如果只有 'text/plain' const { col, row, maxCol, maxRow } = pasteRange; @@ -1387,18 +1462,13 @@ export class EventManager { const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues, - changedCellResults - }); - } + this.firePastedDataEvent(col, row, processedValues, changedCellResults); + return true; } catch (error) { // 静默处理粘贴错误,保持原有行为 console.warn('Paste operation failed:', error); } + return false; } private parsePastedData(pastedData: string): (string | number)[][] { diff --git a/packages/vtable/src/tools/style.ts b/packages/vtable/src/tools/style.ts index 1ebaa64b43..4fa7e3da7f 100644 --- a/packages/vtable/src/tools/style.ts +++ b/packages/vtable/src/tools/style.ts @@ -39,8 +39,8 @@ export function isZeroStyle(style: number | number[]) { } // 设置复制区域的状态 类似excel的虚线框 -export function setActiveCellRangeState(table: BaseTableAPI) { - const selectRanges = table.stateManager.select.ranges; +export function setActiveCellRangeState(table: BaseTableAPI, ranges = table.stateManager.select.ranges) { + const selectRanges = ranges; const setRanges = []; for (let i = 0; i < selectRanges.length; i++) { const range = selectRanges[i]; From b43e85575d869bbad23a30af3836f03c07ebf561 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 19:48:08 +0800 Subject: [PATCH 21/65] fix: harden clipboard paste transactions Bind clipboard paste to a single data snapshot and cut operation. Only clear source cells that were not overwritten by a successful paste. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 2 +- packages/vtable/src/event/event.ts | 561 +++++++++++------- 2 files changed, 340 insertions(+), 223 deletions(-) diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json index 2145611016..5f13961b03 100644 --- a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable", - "comment": "fix: support copy and paste through event clipboard data when the async Clipboard API is unavailable, and normalize pasted HTML cell content (GitHub #5274)", + "comment": "fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274)", "type": "patch" } ], diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 76f4618bdd..dbbace2a4a 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -54,6 +54,8 @@ type CopySnapshot = { ranges: ClipboardRange[]; copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + clipboardSignature: string; }; type EventClipboardData = { @@ -61,6 +63,33 @@ type EventClipboardData = { text: string; }; +type SourceCell = { + col: number; + row: number; + recordIndex?: number | number[]; + field?: any; +}; + +type CutState = { + id: number; + ranges: ClipboardRange[]; + cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + clipboardSignature: string; + copySourceRange: { startCol: number; startRow: number } | null; +}; + +type PasteContext = { + pasteRange: PasteRange; + copySourceRange: { startCol: number; startRow: number } | null; +}; + +type PasteWriteResult = { + pasted: boolean; + changedCellResults: boolean[][]; + changedCells: SourceCell[]; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -103,7 +132,9 @@ export class EventManager { /** 剪切后等待粘贴 */ cutWaitPaste: boolean = false; private clipboardCheckTimer: number | null = null; // 剪贴板检测定时器 - private cutOperationTime: number = 0; // 记录剪切操作的时间 + private cutOperationId: number = 0; + private clipboardOperationId: number = 0; + private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; cutRanges: CellRange[] | null = null; @@ -794,9 +825,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; - !isCut && (this.cutWaitPaste = false); + const operationId = ++this.clipboardOperationId; + if (!isCut) { + this.resetCutState(); + } const copySnapshot = this.getCopySnapshot(isCut); - this.copySourceRange = copySnapshot?.copySourceRange ?? null; + this.copySourceRange = null; if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 @@ -815,11 +849,17 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; + const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { if (isCut) { this.lastClipboardContent = data; + copySnapshot.clipboardSignature = this.getClipboardSignature({ html: dataHTML ?? '', text: data }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); + if (operationId !== this.clipboardOperationId) { + return null; + } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { setActiveCellRangeState(table, copySnapshot.ranges); @@ -870,7 +910,7 @@ export class EventManager { copySucceeded = true; } else { // 降级到纯文本 - await navigator.clipboard.writeText(data); + await navigator.clipboard.writeText(plainClipboardData); copySucceeded = true; } } catch (clipboardError) { @@ -892,9 +932,17 @@ export class EventManager { } if (isCut) { - this.lastClipboardContent = data; + this.lastClipboardContent = plainClipboardData; + copySnapshot.clipboardSignature = this.getClipboardSignature({ + html: canWriteRichClipboard ? dataHTML ?? '' : '', + text: plainClipboardData + }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); + if (operationId !== this.clipboardOperationId) { + return null; + } } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 @@ -903,8 +951,13 @@ export class EventManager { } if (isCut) { this.lastClipboardContent = data; + copySnapshot.clipboardSignature = this.getClipboardSignature({ html: '', text: data }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); + if (operationId !== this.clipboardOperationId) { + return null; + } } } else { return null; @@ -923,11 +976,15 @@ export class EventManager { updateCopyCellBorder: boolean = true ): void { const table = this.table; - table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: copySnapshot.ranges, - copyData: data, - isCut - }); + try { + table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { + cellRange: this.cloneRanges(copySnapshot.ranges), + copyData: data, + isCut + }); + } catch (error) { + console.warn('COPY_DATA listener failed:', error); + } if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); @@ -954,10 +1011,60 @@ export class EventManager { startRow: Math.min(sourceRange.start.row, sourceRange.end.row) } : null, - cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null + cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, + sourceCells: this.getSourceCells(clonedRanges), + clipboardSignature: '' }; } + private cloneRanges(ranges: ClipboardRange[]): ClipboardRange[] { + return ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + } + + private getSourceCells(ranges: ClipboardRange[]): SourceCell[] { + const table = this.table as ListTableAPI; + const sourceCells: SourceCell[] = []; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + for (let row = startRow; row <= endRow; row++) { + for (let col = startCol; col <= endCol; col++) { + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = + recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + sourceCells.push({ col, row, recordIndex, field }); + } + } + } + return sourceCells; + } + + private getClipboardSignature(data: EventClipboardData | null): string { + return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; + } + + private getCopyFormulaPlainData(data: string): string { + const table = this.table; + if (table.stateManager.select.ranges.length !== 1 || !table.options.keyboardOptions?.getCopyCellValue?.html) { + return data; + } + try { + return this.table.getCopyValue( + table.options.keyboardOptions.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } catch (error) { + console.warn('复制公式纯文本数据生成失败,使用显示值:', error); + return data; + } + } + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; @@ -1050,9 +1157,18 @@ export class EventManager { if (!copySnapshot) { return; } + const cutState: CutState = { + id: ++this.cutOperationId, + ranges: this.cloneRanges(copySnapshot.ranges), + cellInfos: copySnapshot.cellInfos, + sourceCells: copySnapshot.sourceCells, + clipboardSignature: copySnapshot.clipboardSignature, + copySourceRange: copySnapshot.copySourceRange + }; this.cutWaitPaste = true; - this.cutCellRange = copySnapshot.cellInfos; - this.cutRanges = copySnapshot.ranges; + this.activeCutState = cutState; + this.cutCellRange = cutState.cellInfos; + this.cutRanges = cutState.ranges; // 设置自动超时,防止剪切状态无限期保持 if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); @@ -1066,8 +1182,7 @@ export class EventManager { } }, 30000); // 30秒超时 - // 保存剪贴板内容以便后续检测变化 - this.saveClipboardContent(); + this.lastClipboardContent = cutState.clipboardSignature; } // 执行实际的粘贴操作 @@ -1077,84 +1192,88 @@ export class EventManager { return; } const eventClipboardData = this.getEventClipboardData(e); + const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(pasteRange, eventClipboardData); + this.readClipboardDataForPaste(eventClipboardData).then(clipboardData => { + this.executePaste(clipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange + }); + }); return; } - this.checkClipboardChanged(eventClipboardData) - .then(async changed => { - // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - const pasted = await this.executePaste(pasteRange, eventClipboardData); - if (!changed && pasted) { - this.clearCutArea(this.table as ListTableAPI); - this.resetCutState(); + const cutState = this.activeCutState; + if (!cutState) { + this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange }); + return; + } + + this.readClipboardDataForPaste(eventClipboardData) + .then(async clipboardData => { + const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + const pasteResult = await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: changed ? null : cutState.copySourceRange + }); + if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { + await this.clearCutArea(cutState, pasteResult.changedCells); } - if (changed) { + if (this.activeCutState?.id === cutState.id) { this.resetCutState(); } }) .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - await this.executePaste(pasteRange, eventClipboardData); - this.resetCutState(); + await this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange + }); + if (this.activeCutState?.id === cutState.id) { + this.resetCutState(); + } }); } - private async executePaste(pasteRange: PasteRange, eventClipboardData: EventClipboardData | null): Promise { + private async executePaste( + clipboardData: EventClipboardData | null, + pasteContext: PasteContext + ): Promise { const table = this.table; + const emptyResult = this.getEmptyPasteResult(); if ((table as ListTableAPI).editorManager?.editingEditor) { - return false; + return emptyResult; } - let pasted = false; - if ((table as ListTableAPI).changeCellValues) { - try { - // 优先使用现代剪贴板API - if (navigator.clipboard && navigator.clipboard.read) { - try { - // 读取剪切板数据 - const clipboardItems = await navigator.clipboard.read(); - let handled = false; - - for (const item of clipboardItems) { - // 优先处理 html 格式数据 - if (item.types.includes('text/html')) { - const pasted = await this.pasteHtmlToTable(item, pasteRange); - if (pasted) { - handled = true; - break; - } - } - if (item.types.includes('text/plain')) { - handled = await this.pasteTextToTable(item, pasteRange); - break; + let pasteResult = emptyResult; + try { + if ((table as ListTableAPI).changeCellValues) { + try { + if (clipboardData?.html) { + try { + const htmlResult = await this.processPastedHTML(clipboardData.html, pasteContext); + if (htmlResult.pasted) { + pasteResult = htmlResult; + return pasteResult; } + } catch (error) { + console.warn('粘贴HTML数据失败:', error); + return emptyResult; } - - if (!handled) { - // 如果没有处理任何数据,使用降级方案 - handled = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); - } - pasted = handled; - } catch (clipboardError) { - console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); - // 降级到传统方法 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } - } else { - // 不支持现代剪贴板API,使用降级方案 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); + if (clipboardData?.text) { + pasteResult = await this.processPastedText(clipboardData.text, pasteContext); + return pasteResult; + } + } catch (error) { + console.error('粘贴操作失败:', error); } - } catch (error) { - console.error('粘贴操作失败:', error); - // 最后的降级方案 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); + } + return pasteResult; + } finally { + if (table.keyboardOptions?.showCopyCellBorder) { + clearActiveCellRangeState(table); } } - if (table.keyboardOptions?.showCopyCellBorder) { - clearActiveCellRangeState(table); - } - return pasted; } private getPasteRange(): PasteRange | null { @@ -1178,48 +1297,51 @@ export class EventManager { if (!clipboardData) { return null; } - return { - html: clipboardData.getData('text/html') || '', - text: clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' - }; - } - - // 降级粘贴方案 - private async fallbackPasteFromClipboard( - eventClipboardData: EventClipboardData | null, - pasteRange: PasteRange - ): Promise { - const table = this.table; - try { - // 确保表格元素获得焦点 - const element = table.getElement(); - if (element && element !== document.activeElement) { - element.focus(); - // 短暂延迟,确保焦点设置完成 - await new Promise(resolve => setTimeout(resolve, 10)); - } + return { + html: clipboardData.getData('text/html') || '', + text: + clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' + }; + } catch (error) { + console.warn('读取事件剪贴板数据失败:', error); + return null; + } + } - if (eventClipboardData) { - if (eventClipboardData.html) { - try { - const pasted = await this.processPastedHTML(eventClipboardData.html, pasteRange); - if (pasted) { - return true; - } - } catch (error) { - console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); + private async readClipboardDataForPaste( + eventClipboardData: EventClipboardData | null + ): Promise { + if (eventClipboardData?.html || eventClipboardData?.text) { + return eventClipboardData; + } + if (navigator.clipboard?.read) { + try { + const clipboardItems = await navigator.clipboard.read(); + const clipboardData: EventClipboardData = { html: '', text: '' }; + for (const item of clipboardItems) { + if (!clipboardData.html && item.types.includes('text/html')) { + clipboardData.html = await (await item.getType('text/html')).text(); + } + if (!clipboardData.text && item.types.includes('text/plain')) { + clipboardData.text = await (await item.getType('text/plain')).text(); } } - - if (eventClipboardData.text) { - return await this.processPastedText(eventClipboardData.text, pasteRange); + if (clipboardData.html || clipboardData.text) { + return clipboardData; } + } catch (error) { + console.warn('现代剪贴板API读取失败,使用事件剪贴板数据:', error); } - } catch (error) { - console.error('降级粘贴方案失败:', error); } - return false; + if (navigator.clipboard?.readText) { + try { + return { html: '', text: await navigator.clipboard.readText() }; + } catch (error) { + console.warn('剪贴板纯文本读取失败,使用事件剪贴板数据:', error); + } + } + return eventClipboardData; } private decodeHTMLCellValue(cellHTML: string): string { @@ -1228,19 +1350,20 @@ export class EventManager { return template.content.textContent ?? ''; } - private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { + private async processPastedHTML(pastedData: string, pasteContext: PasteContext): Promise { // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 const regex = /]*>([\s\S]*?)<\/tr>/gi; // for webpack3 // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; // for webpack3 const table = this.table; + const pasteRange = pasteContext.pasteRange; const { col, row, maxCol, maxRow } = pasteRange; let pasteValuesColCount = 0; let pasteValuesRowCount = 0; let values: (string | number)[][] = []; if (!pastedData || !/ { + private async processPastedText(pastedData: string, pasteContext: PasteContext): Promise { const table = this.table; + const pasteRange = pasteContext.pasteRange; const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 const values: (string | number)[][] = []; @@ -1314,26 +1439,27 @@ export class EventManager { }); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { + if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { // 利用复制时记录的源位置,对粘贴的数据进行公式处理 processedValues = table.options.keyboardOptions.processFormulaBeforePaste( values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, + pasteContext.copySourceRange.startCol, + pasteContext.copySourceRange.startRow, col, row ); } + const valuesToPaste = processedValues ? processedValues : values; + const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); - return true; + const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCells = this.getChangedCells(targetCells, changedCellResults); + if (!changedCells.length) { + return this.getEmptyPasteResult(changedCellResults); + } + this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); + return { pasted: true, changedCellResults, changedCells }; } private firePastedDataEvent( @@ -1357,48 +1483,103 @@ export class EventManager { console.warn('PASTED_DATA listener failed:', error); } } + private getEmptyPasteResult(changedCellResults: boolean[][] = []): PasteWriteResult { + return { + pasted: false, + changedCellResults, + changedCells: [] + }; + } + + private getPasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): SourceCell[][] { + const targetCells: SourceCell[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + targetCells[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + targetCells[rowIndex][colIndex] = this.getCellIdentity(pasteRange.col + colIndex, pasteRange.row + rowIndex); + } + } + return targetCells; + } + + private getChangedCells(targetCells: SourceCell[][], changedCellResults: boolean[][]): SourceCell[] { + const changedCells: SourceCell[] = []; + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (changedCellResults[rowIndex][colIndex] && targetCells[rowIndex]?.[colIndex]) { + changedCells.push(targetCells[rowIndex][colIndex]); + } + } + } + return changedCells; + } + + private getCellIdentity(col: number, row: number): SourceCell { + const table = this.table as ListTableAPI; + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field }; + } + // 清空选中区域的内容 - private clearCutArea(table: ListTableAPI): void { + private async clearCutArea(cutState: CutState, changedCells: SourceCell[]): Promise { try { - const ranges = this.cutRanges; - if (!ranges || ranges.length === 0) { - return; + const table = this.table as ListTableAPI; + const changedKeys = new Set(); + changedCells.forEach(cell => { + changedKeys.add(this.getCellKey(cell)); + changedKeys.add(this.getCoordKey(cell)); + }); + const ranges: CellRange[] = []; + for (let i = 0; i < cutState.sourceCells.length; i++) { + const sourceCell = cutState.sourceCells[i]; + if (changedKeys.has(this.getCellKey(sourceCell)) || changedKeys.has(this.getCoordKey(sourceCell))) { + continue; + } + const currentAddress = this.getCurrentCellAddress(sourceCell); + if (!currentAddress) { + continue; + } + ranges.push({ + start: { col: currentAddress.col, row: currentAddress.row }, + end: { col: currentAddress.col, row: currentAddress.row } + }); + } + if (ranges.length) { + await table.changeCellValuesByRanges(ranges, ''); } - - table.changeCellValuesByRanges(ranges, ''); } catch (error) { console.error('清空单元格内容失败', error); + throw error; } } - // 检查剪贴板内容是否被其他应用更改 - private async checkClipboardChanged(eventClipboardData: EventClipboardData | null): Promise { - // 如果不支持读取剪贴板,则无法检测变化 - if (!navigator.clipboard || !navigator.clipboard.readText) { - if (eventClipboardData?.text) { - return eventClipboardData.text !== this.lastClipboardContent; + private getCurrentCellAddress(sourceCell: SourceCell): { col: number; row: number } | null { + const table = this.table as ListTableAPI; + if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { + const address = table.getCellAddrByFieldRecord(sourceCell.field, sourceCell.recordIndex); + if (address) { + return address; } - throw new Error('Clipboard readText is unavailable'); } + return { col: sourceCell.col, row: sourceCell.row }; + } - try { - const currentContent = await navigator.clipboard.readText(); - // console.log('当前剪贴板内容:', currentContent); - // console.log('上次保存的剪贴板内容:', this.lastClipboardContent); - - // 比较当前剪贴板内容与剪切时保存的内容 - return currentContent !== this.lastClipboardContent; - } catch (err) { - console.warn('检查剪贴板状态失败:', err); - throw err; + private getCellKey(cell: SourceCell): string { + if (cell.recordIndex !== undefined && cell.field !== undefined) { + return `record:${String(cell.recordIndex)}:${String(cell.field)}`; } + return this.getCoordKey(cell); + } + + private getCoordKey(cell: SourceCell): string { + return `coord:${cell.col}:${cell.row}`; } private resetCutState(): void { - if (!this.cutWaitPaste) { - return; - } this.cutWaitPaste = false; + this.activeCutState = null; this.cutCellRange = null; this.cutRanges = null; if (this.clipboardCheckTimer) { @@ -1407,70 +1588,6 @@ export class EventManager { } } - // 保存剪贴板内容 - private saveClipboardContent(): void { - // 尝试获取剪贴板内容 - if (navigator.clipboard && navigator.clipboard.readText) { - // 延迟一点以确保剪贴板内容已更新 - setTimeout(() => { - navigator.clipboard - .readText() - .then(text => { - this.lastClipboardContent = text; - console.log('已保存剪贴板状态'); - }) - .catch(err => { - console.warn('无法读取剪贴板内容:', err); - }); - }, 50); - } - } - private async pasteHtmlToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { - try { - const blob = await item.getType('text/html'); - const pastedData = await blob.text(); - const pasted = await this.processPastedHTML(pastedData, pasteRange); - if (pasted) { - return true; - } - } catch (error) { - console.warn('Paste html operation failed:', error); - } - return false; - } - - private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { - const table = this.table; - // 如果只有 'text/plain' - const { col, row, maxCol, maxRow } = pasteRange; - - try { - const blob = await item.getType('text/plain'); - const pastedData = await blob.text(); - const values = this.parsePastedData(pastedData); - - const pasteValuesRowCount = values.length; - const pasteValuesColCount = Math.max(...values.map(row => row.length), 0); - - const processedValues = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true); - - this.firePastedDataEvent(col, row, processedValues, changedCellResults); - return true; - } catch (error) { - // 静默处理粘贴错误,保持原有行为 - console.warn('Paste operation failed:', error); - } - return false; - } - private parsePastedData(pastedData: string): (string | number)[][] { const rows = pastedData.replace(/\r(?!\n)/g, '\r\n').split('\r\n'); // 文本中的换行符格式进行统一处理 const values: (string | number)[][] = []; From fa8b80afd6d0340c9ad86c34df1f87e2312cb637 Mon Sep 17 00:00:00 2001 From: Wu Shuwen <108231307+dajiaohuang@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:51:51 +0800 Subject: [PATCH 22/65] fix: handle empty audio and video previews --- ...0-image-null-preview_2026-08-25-17-50.json | 11 ++++++ packages/vtable/__tests__/media-click.test.ts | 39 +++++++++++++++++++ packages/vtable/src/event/media-click.ts | 6 +++ 3 files changed, 56 insertions(+) create mode 100644 common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json create mode 100644 packages/vtable/__tests__/media-click.test.ts diff --git a/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json b/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json new file mode 100644 index 0000000000..f2900f0db4 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290)", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "108231307+dajiaohuang@users.noreply.github.com" +} diff --git a/packages/vtable/__tests__/media-click.test.ts b/packages/vtable/__tests__/media-click.test.ts new file mode 100644 index 0000000000..72b7c908dc --- /dev/null +++ b/packages/vtable/__tests__/media-click.test.ts @@ -0,0 +1,39 @@ +import { bindMediaClick } from '../src/event/media-click'; +import { Env, type EnvMode } from '../src/tools/env'; +import type { MousePointerCellEvent } from '../src/ts-types'; +import type { BaseTableAPI } from '../src/ts-types/base-table'; + +describe('bindMediaClick', () => { + let originalMode: EnvMode; + + beforeEach(() => { + originalMode = Env.mode; + Env.mode = 'browser'; + document.body.innerHTML = ''; + }); + + afterEach(() => { + Env.mode = originalMode; + document.body.innerHTML = ''; + }); + + test.each(['image', 'audio', 'video'] as const)('does not open a %s preview for an empty cell', cellType => { + let clickHandler: ((event: MousePointerCellEvent) => void) | undefined; + const table = { + addReleaseObj: jest.fn(), + on: jest.fn((_event: string, handler: (event: MousePointerCellEvent) => void) => { + clickHandler = handler; + }), + getCellType: jest.fn(() => cellType), + isHeader: jest.fn(() => false), + getBodyColumnDefine: jest.fn(() => ({ clickToPreview: true })), + getCellValue: jest.fn(() => null), + getCellOriginValue: jest.fn(() => null) + } as unknown as BaseTableAPI; + + bindMediaClick(table); + clickHandler?.({ col: 0, row: 0, target: { type: 'rect' } } as MousePointerCellEvent); + + expect(document.body.childElementCount).toBe(0); + }); +}); diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index d18c4d65f4..25819d5cad 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -393,6 +393,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 @@ -434,6 +437,9 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + if (!cellValue) { + return; + } const previewManager = getMediaPreviewManager(table); // 开启蒙版 From de2f2ba555558ad79ed58df1c982de91c481ac50 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 11:00:11 +0800 Subject: [PATCH 23/65] fix: guard cut cleanup Clear cut sources only after all copied source cells migrate. Co-authored-by: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 147 ++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index dbbace2a4a..c2c5e7ec8d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -55,6 +55,8 @@ type CopySnapshot = { copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceColCount: number; + sourceRowCount: number; clipboardSignature: string; }; @@ -68,6 +70,7 @@ type SourceCell = { row: number; recordIndex?: number | number[]; field?: any; + value?: any; }; type CutState = { @@ -75,6 +78,8 @@ type CutState = { ranges: ClipboardRange[]; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceColCount: number; + sourceRowCount: number; clipboardSignature: string; copySourceRange: { startCol: number; startRow: number } | null; }; @@ -82,12 +87,14 @@ type CutState = { type PasteContext = { pasteRange: PasteRange; copySourceRange: { startCol: number; startRow: number } | null; + cutState?: CutState | null; }; type PasteWriteResult = { pasted: boolean; changedCellResults: boolean[][]; changedCells: SourceCell[]; + migratedSourceCells: SourceCell[]; }; export class EventManager { @@ -843,6 +850,7 @@ export class EventManager { if (isValid(data)) { e.preventDefault(); let copySucceeded = false; + let copiedClipboardData: EventClipboardData | null = null; const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; const canWriteRichClipboard = window.isSecureContext && !!navigator.clipboard?.write && !!window.ClipboardItem; @@ -851,9 +859,10 @@ export class EventManager { !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + copiedClipboardData = { html: dataHTML ?? '', text: data }; if (isCut) { - this.lastClipboardContent = data; - copySnapshot.clipboardSignature = this.getClipboardSignature({ html: dataHTML ?? '', text: data }); + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); @@ -908,23 +917,34 @@ export class EventManager { }) ]); copySucceeded = true; + copiedClipboardData = { html: dataHTML, text: data }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); copySucceeded = true; + copiedClipboardData = { html: '', text: plainClipboardData }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } if (!copySucceeded) { @@ -932,11 +952,8 @@ export class EventManager { } if (isCut) { - this.lastClipboardContent = plainClipboardData; - copySnapshot.clipboardSignature = this.getClipboardSignature({ - html: canWriteRichClipboard ? dataHTML ?? '' : '', - text: plainClipboardData - }); + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); @@ -1002,6 +1019,8 @@ export class EventManager { end: { col: range.end.col, row: range.end.row } })); const sourceRange = ranges.length === 1 ? ranges[0] : null; + const sourceCells = this.getSourceCells(clonedRanges); + const sourceSize = this.getSourceSize(clonedRanges, sourceCells.length); return { ranges: clonedRanges, @@ -1012,7 +1031,9 @@ export class EventManager { } : null, cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, - sourceCells: this.getSourceCells(clonedRanges), + sourceCells, + sourceColCount: sourceSize.colCount, + sourceRowCount: sourceSize.rowCount, clipboardSignature: '' }; } @@ -1039,13 +1060,27 @@ export class EventManager { const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; - sourceCells.push({ col, row, recordIndex, field }); + sourceCells.push({ col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }); } } } return sourceCells; } + private getSourceSize(ranges: ClipboardRange[], sourceCellCount: number): { colCount: number; rowCount: number } { + if (ranges.length === 1) { + const range = ranges[0]; + return { + colCount: Math.abs(range.end.col - range.start.col) + 1, + rowCount: Math.abs(range.end.row - range.start.row) + 1 + }; + } + return { + colCount: sourceCellCount, + rowCount: 1 + }; + } + private getClipboardSignature(data: EventClipboardData | null): string { return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; } @@ -1162,6 +1197,8 @@ export class EventManager { ranges: this.cloneRanges(copySnapshot.ranges), cellInfos: copySnapshot.cellInfos, sourceCells: copySnapshot.sourceCells, + sourceColCount: copySnapshot.sourceColCount, + sourceRowCount: copySnapshot.sourceRowCount, clipboardSignature: copySnapshot.clipboardSignature, copySourceRange: copySnapshot.copySourceRange }; @@ -1215,12 +1252,19 @@ export class EventManager { const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; const pasteResult = await this.executePaste(clipboardData, { pasteRange, - copySourceRange: changed ? null : cutState.copySourceRange + copySourceRange: changed ? null : cutState.copySourceRange, + cutState: changed ? null : cutState }); if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - await this.clearCutArea(cutState, pasteResult.changedCells); + if (pasteResult.migratedSourceCells.length >= cutState.sourceCells.length) { + await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (this.activeCutState?.id === cutState.id) { + this.resetCutState(); + } + } + return; } - if (this.activeCutState?.id === cutState.id) { + if (changed && this.activeCutState?.id === cutState.id) { this.resetCutState(); } }) @@ -1257,7 +1301,6 @@ export class EventManager { } } catch (error) { console.warn('粘贴HTML数据失败:', error); - return emptyResult; } } if (clipboardData?.text) { @@ -1410,11 +1453,12 @@ export class EventManager { const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { return this.getEmptyPasteResult(changedCellResults); } this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); - return { pasted: true, changedCellResults, changedCells }; + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; } // 处理粘贴的文本数据 @@ -1455,11 +1499,12 @@ export class EventManager { // 保持与 navigator.clipboard.read 中的操作一致 const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { return this.getEmptyPasteResult(changedCellResults); } this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); - return { pasted: true, changedCellResults, changedCells }; + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; } private firePastedDataEvent( @@ -1487,7 +1532,8 @@ export class EventManager { return { pasted: false, changedCellResults, - changedCells: [] + changedCells: [], + migratedSourceCells: [] }; } @@ -1514,6 +1560,35 @@ export class EventManager { return changedCells; } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { + if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { + return []; + } + const migratedSourceCells: SourceCell[] = []; + const migratedKeys = new Set(); + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceIndex = sourceRow * cutState.sourceColCount + sourceCol; + const sourceCell = cutState.sourceCells[sourceIndex]; + if (!sourceCell) { + continue; + } + const sourceKey = this.getCellKey(sourceCell); + if (migratedKeys.has(sourceKey)) { + continue; + } + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + return migratedSourceCells; + } + private getCellIdentity(col: number, row: number): SourceCell { const table = this.table as ListTableAPI; const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); @@ -1523,31 +1598,34 @@ export class EventManager { } // 清空选中区域的内容 - private async clearCutArea(cutState: CutState, changedCells: SourceCell[]): Promise { + private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { try { const table = this.table as ListTableAPI; - const changedKeys = new Set(); - changedCells.forEach(cell => { - changedKeys.add(this.getCellKey(cell)); - changedKeys.add(this.getCoordKey(cell)); + const changedTargetKeys = new Set(); + changedTargetCells.forEach(cell => { + changedTargetKeys.add(this.getCellKey(cell)); + changedTargetKeys.add(this.getCoordKey(cell)); }); - const ranges: CellRange[] = []; - for (let i = 0; i < cutState.sourceCells.length; i++) { - const sourceCell = cutState.sourceCells[i]; - if (changedKeys.has(this.getCellKey(sourceCell)) || changedKeys.has(this.getCoordKey(sourceCell))) { + const clearedKeys = new Set(); + for (let i = 0; i < migratedSourceCells.length; i++) { + const sourceCell = migratedSourceCells[i]; + const sourceKey = this.getCellKey(sourceCell); + if ( + clearedKeys.has(sourceKey) || + changedTargetKeys.has(sourceKey) || + changedTargetKeys.has(this.getCoordKey(sourceCell)) + ) { continue; } const currentAddress = this.getCurrentCellAddress(sourceCell); if (!currentAddress) { continue; } - ranges.push({ - start: { col: currentAddress.col, row: currentAddress.row }, - end: { col: currentAddress.col, row: currentAddress.row } - }); - } - if (ranges.length) { - await table.changeCellValuesByRanges(ranges, ''); + if (!this.isSourceCellUnchanged(sourceCell, currentAddress)) { + continue; + } + await table.changeCellValues(currentAddress.col, currentAddress.row, [['']], false); + clearedKeys.add(sourceKey); } } catch (error) { console.error('清空单元格内容失败', error); @@ -1555,6 +1633,11 @@ export class EventManager { } } + private isSourceCellUnchanged(sourceCell: SourceCell, currentAddress: { col: number; row: number }): boolean { + const table = this.table as ListTableAPI; + return Object.is(table.getCellOriginValue?.(currentAddress.col, currentAddress.row), sourceCell.value); + } + private getCurrentCellAddress(sourceCell: SourceCell): { col: number; row: number } | null { const table = this.table as ListTableAPI; if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { From 6518849cbfd68bd8d0cce0ecb030f0c58fecb0b6 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 12:09:06 +0800 Subject: [PATCH 24/65] fix: harden clipboard transaction guards Guard async clipboard tasks and preserve failed cut transactions. Co-authored-by: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 334 +++++++++++++++++++++++------ 1 file changed, 266 insertions(+), 68 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index c2c5e7ec8d..4b03ad1be4 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -57,12 +57,16 @@ type CopySnapshot = { sourceCells: SourceCell[]; sourceColCount: number; sourceRowCount: number; + clipboardData: EventClipboardData | null; clipboardSignature: string; }; type EventClipboardData = { html: string; text: string; + hasHtml?: boolean; + hasText?: boolean; + readFailed?: boolean; }; type SourceCell = { @@ -80,14 +84,17 @@ type CutState = { sourceCells: SourceCell[]; sourceColCount: number; sourceRowCount: number; + clipboardData: EventClipboardData | null; clipboardSignature: string; copySourceRange: { startCol: number; startRow: number } | null; + isSingleRange: boolean; }; type PasteContext = { pasteRange: PasteRange; copySourceRange: { startCol: number; startRow: number } | null; cutState?: CutState | null; + pasteOperationId?: number; }; type PasteWriteResult = { @@ -141,6 +148,7 @@ export class EventManager { private clipboardCheckTimer: number | null = null; // 剪贴板检测定时器 private cutOperationId: number = 0; private clipboardOperationId: number = 0; + private pasteOperationId: number = 0; private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; @@ -805,6 +813,9 @@ export class EventManager { } /** TODO 其他的事件并么有做remove */ release() { + this.clipboardOperationId++; + this.pasteOperationId++; + this.resetCutState(); this.gesture?.release(); // remove global event listerner @@ -858,19 +869,24 @@ export class EventManager { const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); - if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { - copiedClipboardData = { html: dataHTML ?? '', text: data }; + const eventClipboardWriteData = hasEventClipboard ? this.setCopyDataToEventClipboard(data, dataHTML, e) : null; + if (eventClipboardWriteData) { + copiedClipboardData = eventClipboardWriteData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { + if (!this.isClipboardOperationCurrent(operationId)) { + return; + } setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); }, 0); @@ -884,6 +900,9 @@ export class EventManager { element.focus(); // 短暂延迟,确保焦点设置完成 await new Promise(resolve => setTimeout(resolve, 10)); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } try { @@ -897,6 +916,9 @@ export class EventManager { name: 'clipboard-write' as PermissionName }); hasPermission = permissionState.state === 'granted'; + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } catch (permissionError) { // 权限查询失败,继续尝试写入 console.warn('无法查询剪贴板权限:', permissionError); @@ -908,6 +930,9 @@ export class EventManager { // 将复制的数据转为html格式 try { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } // 尝试使用 ClipboardItem(支持富文本) if (canWriteRichClipboard && dataHTML) { await navigator.clipboard.write([ @@ -916,34 +941,40 @@ export class EventManager { 'text/plain': new Blob([data], { type: 'text/plain' }) }) ]); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: data }; + copiedClipboardData = { html: dataHTML, text: data, hasHtml: true, hasText: true }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copySucceeded = true; - copiedClipboardData = { html: '', text: plainClipboardData }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } @@ -953,11 +984,12 @@ export class EventManager { if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } } catch (error) { @@ -967,12 +999,13 @@ export class EventManager { return null; } if (isCut) { - this.lastClipboardContent = data; - copySnapshot.clipboardSignature = this.getClipboardSignature({ html: '', text: data }); + this.lastClipboardContent = this.getClipboardSignature({ html: '', text: data, hasText: true }); + copySnapshot.clipboardData = { html: '', text: data, hasText: true }; + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } } @@ -1034,6 +1067,7 @@ export class EventManager { sourceCells, sourceColCount: sourceSize.colCount, sourceRowCount: sourceSize.rowCount, + clipboardData: null, clipboardSignature: '' }; } @@ -1082,7 +1116,44 @@ export class EventManager { } private getClipboardSignature(data: EventClipboardData | null): string { - return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; + return data + ? JSON.stringify({ + html: data.hasHtml ? data.html : null, + text: data.hasText ? data.text : null + }) + : ''; + } + + private isClipboardOperationCurrent(operationId: number): boolean { + return operationId === this.clipboardOperationId && !this.table.isReleased; + } + + private isPasteOperationCurrent(operationId: number | undefined): boolean { + return (operationId === undefined || operationId === this.pasteOperationId) && !this.table.isReleased; + } + + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { + if (!clipboardData || clipboardData.readFailed) { + return null; + } + const sourceData = cutState.clipboardData; + if (!sourceData) { + return this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + } + let hasComparableType = false; + if (clipboardData.hasHtml && sourceData.hasHtml) { + hasComparableType = true; + if (clipboardData.html !== sourceData.html) { + return true; + } + } + if (clipboardData.hasText && sourceData.hasText) { + hasComparableType = true; + if (clipboardData.text !== sourceData.text) { + return true; + } + } + return hasComparableType ? false : null; } private getCopyFormulaPlainData(data: string): string { @@ -1124,25 +1195,32 @@ export class EventManager { } } - private setCopyDataToEventClipboard(data: string, dataHTML: string | null, e: KeyboardEvent): boolean { + private setCopyDataToEventClipboard( + data: string, + dataHTML: string | null, + e: KeyboardEvent + ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { - return false; + return null; } try { clipboardData.setData('text/plain', data); + const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true }; if (dataHTML) { try { clipboardData.setData('text/html', dataHTML); + copiedClipboardData.html = dataHTML; + copiedClipboardData.hasHtml = true; } catch (error) { console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); } } - return true; + return copiedClipboardData; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); - return false; + return null; } } @@ -1199,8 +1277,10 @@ export class EventManager { sourceCells: copySnapshot.sourceCells, sourceColCount: copySnapshot.sourceColCount, sourceRowCount: copySnapshot.sourceRowCount, + clipboardData: copySnapshot.clipboardData, clipboardSignature: copySnapshot.clipboardSignature, - copySourceRange: copySnapshot.copySourceRange + copySourceRange: copySnapshot.copySourceRange, + isSingleRange: copySnapshot.ranges.length === 1 }; this.cutWaitPaste = true; this.activeCutState = cutState; @@ -1228,36 +1308,62 @@ export class EventManager { if (!pasteRange) { return; } + const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.readClipboardDataForPaste(eventClipboardData).then(clipboardData => { - this.executePaste(clipboardData, { - pasteRange, - copySourceRange: pasteCopySourceRange + this.readClipboardDataForPaste(eventClipboardData) + .then(clipboardData => { + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; + } + this.executePaste(clipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange, + pasteOperationId + }); + }) + .catch(error => { + console.warn('读取剪贴板数据失败:', error); }); - }); return; } const cutState = this.activeCutState; if (!cutState) { - this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange }); + this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange, pasteOperationId }); return; } this.readClipboardDataForPaste(eventClipboardData) .then(async clipboardData => { - const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; + } + const changed = this.isClipboardDataChanged(clipboardData, cutState); + if (changed === null) { + return; + } const pasteResult = await this.executePaste(clipboardData, { pasteRange, copySourceRange: changed ? null : cutState.copySourceRange, - cutState: changed ? null : cutState + cutState: changed ? null : cutState, + pasteOperationId }); + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; + } if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - if (pasteResult.migratedSourceCells.length >= cutState.sourceCells.length) { - await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (!cutState.isSingleRange) { + this.resetCutState(); + return; + } + if (pasteResult.migratedSourceCells.length === cutState.sourceCells.length) { + const cleared = await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (!cleared) { + return; + } if (this.activeCutState?.id === cutState.id) { this.resetCutState(); } @@ -1270,13 +1376,14 @@ export class EventManager { }) .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; + } await this.executePaste(eventClipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange + copySourceRange: pasteCopySourceRange, + pasteOperationId }); - if (this.activeCutState?.id === cutState.id) { - this.resetCutState(); - } }); } private async executePaste( @@ -1285,7 +1392,10 @@ export class EventManager { ): Promise { const table = this.table; const emptyResult = this.getEmptyPasteResult(); - if ((table as ListTableAPI).editorManager?.editingEditor) { + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { return emptyResult; } let pasteResult = emptyResult; @@ -1301,9 +1411,12 @@ export class EventManager { } } catch (error) { console.warn('粘贴HTML数据失败:', error); + if (/ { - if (eventClipboardData?.html || eventClipboardData?.text) { + if (eventClipboardData?.readFailed || eventClipboardData?.hasHtml || eventClipboardData?.hasText) { return eventClipboardData; } if (navigator.clipboard?.read) { @@ -1364,13 +1484,23 @@ export class EventManager { const clipboardData: EventClipboardData = { html: '', text: '' }; for (const item of clipboardItems) { if (!clipboardData.html && item.types.includes('text/html')) { - clipboardData.html = await (await item.getType('text/html')).text(); + try { + clipboardData.html = await (await item.getType('text/html')).text(); + clipboardData.hasHtml = true; + } catch (error) { + console.warn('读取HTML剪贴板数据失败:', error); + } } if (!clipboardData.text && item.types.includes('text/plain')) { - clipboardData.text = await (await item.getType('text/plain')).text(); + try { + clipboardData.text = await (await item.getType('text/plain')).text(); + clipboardData.hasText = true; + } catch (error) { + console.warn('读取纯文本剪贴板数据失败:', error); + } } } - if (clipboardData.html || clipboardData.text) { + if (clipboardData.hasHtml || clipboardData.hasText) { return clipboardData; } } catch (error) { @@ -1379,12 +1509,12 @@ export class EventManager { } if (navigator.clipboard?.readText) { try { - return { html: '', text: await navigator.clipboard.readText() }; + return { html: '', text: await navigator.clipboard.readText(), hasText: true }; } catch (error) { console.warn('剪贴板纯文本读取失败,使用事件剪贴板数据:', error); } } - return eventClipboardData; + return eventClipboardData ?? { html: '', text: '', readFailed: true }; } private decodeHTMLCellValue(cellHTML: string): string { @@ -1451,7 +1581,11 @@ export class EventManager { const valuesToPaste = processedValues ? processedValues : values; const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { @@ -1465,22 +1599,20 @@ export class EventManager { private async processPastedText(pastedData: string, pasteContext: PasteContext): Promise { const table = this.table; const pasteRange = pasteContext.pasteRange; - const { col, row } = pasteRange; - const rows = pastedData.split('\n'); // 将数据拆分为行 - const values: (string | number)[][] = []; - - rows.forEach(function (rowCells: any) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); - }); - }); + const { col, row, maxCol, maxRow } = pasteRange; + let values = this.parsePlainTextPastedData(pastedData); + const pasteValuesRowCount = values.length; + const pasteValuesColCount = Math.max(...values.map(rowValues => rowValues.length)); + if (!pasteValuesRowCount || !pasteValuesColCount) { + return this.getEmptyPasteResult(); + } + values = this.handlePasteValues( + values, + pasteValuesRowCount, + pasteValuesColCount, + maxRow - row + 1, + maxCol - col + 1 + ); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { @@ -1497,7 +1629,11 @@ export class EventManager { const valuesToPaste = processedValues ? processedValues : values; const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { @@ -1560,8 +1696,35 @@ export class EventManager { return changedCells; } + private normalizeChangedCellResults( + changedCellResults: boolean[][] | void, + pasteRange: PasteRange, + values: (string | number)[][] + ): boolean[][] { + if (Array.isArray(changedCellResults)) { + return changedCellResults; + } + const table = this.table as any; + return values.map((rowValues, rowIndex) => + rowValues.map((_value, colIndex) => { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + return ( + targetCol < table.colCount && + targetRow < table.rowCount && + (!table.isHasEditorDefine || table.isHasEditorDefine(targetCol, targetRow)) + ); + }) + ); + } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { - if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { + if ( + !cutState?.isSingleRange || + !cutState.sourceCells.length || + !cutState.sourceColCount || + !cutState.sourceRowCount + ) { return []; } const migratedSourceCells: SourceCell[] = []; @@ -1598,7 +1761,7 @@ export class EventManager { } // 清空选中区域的内容 - private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { + private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { try { const table = this.table as ListTableAPI; const changedTargetKeys = new Set(); @@ -1607,6 +1770,8 @@ export class EventManager { changedTargetKeys.add(this.getCoordKey(cell)); }); const clearedKeys = new Set(); + const sourceChanges: { recordIndex: number | number[]; field: any; value: string }[] = []; + const coordChanges: { col: number; row: number }[] = []; for (let i = 0; i < migratedSourceCells.length; i++) { const sourceCell = migratedSourceCells[i]; const sourceKey = this.getCellKey(sourceCell); @@ -1624,12 +1789,33 @@ export class EventManager { if (!this.isSourceCellUnchanged(sourceCell, currentAddress)) { continue; } - await table.changeCellValues(currentAddress.col, currentAddress.row, [['']], false); + if ( + sourceCell.recordIndex !== undefined && + sourceCell.field !== undefined && + (table as any).changeCellValuesByRecords + ) { + sourceChanges.push({ recordIndex: sourceCell.recordIndex, field: sourceCell.field, value: '' }); + } else { + coordChanges.push(currentAddress); + } clearedKeys.add(sourceKey); } + if (sourceChanges.length) { + (table as any).changeCellValuesByRecords(sourceChanges, { + triggerEvent: true, + autoRefresh: false + }); + } + for (let i = 0; i < coordChanges.length; i++) { + await table.changeCellValues(coordChanges[i].col, coordChanges[i].row, [['']], false); + } + if (sourceChanges.length) { + (table as any).refreshAfterSourceChange?.(); + } + return true; } catch (error) { console.error('清空单元格内容失败', error); - throw error; + return false; } } @@ -1642,9 +1828,10 @@ export class EventManager { const table = this.table as ListTableAPI; if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { const address = table.getCellAddrByFieldRecord(sourceCell.field, sourceCell.recordIndex); - if (address) { + if (address && address.col >= 0 && address.row >= 0) { return address; } + return null; } return { col: sourceCell.col, row: sourceCell.row }; } @@ -1665,6 +1852,9 @@ export class EventManager { this.activeCutState = null; this.cutCellRange = null; this.cutRanges = null; + if (this.table.keyboardOptions?.showCopyCellBorder) { + clearActiveCellRangeState(this.table); + } if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); this.clipboardCheckTimer = null; @@ -1689,6 +1879,14 @@ export class EventManager { return values; } + private parsePlainTextPastedData(pastedData: string): (string | number)[][] { + const rows = pastedData.replace(/\r\n|\r/g, '\n').split('\n'); + if (rows.length > 1 && rows[rows.length - 1] === '') { + rows.pop(); + } + return rows.map(rowCells => rowCells.split('\t')); + } + private processCellValue(cell: string): string | number { if (cell.includes('\n')) { cell = cell From 399b0955549dc252f74a557a4bccc9388bd2e61a Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 14:29:26 +0800 Subject: [PATCH 25/65] fix: harden clipboard transactions --- packages/vtable/src/event/event.ts | 246 ++++++++++++++++++++++++----- 1 file changed, 205 insertions(+), 41 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 4b03ad1be4..2ec91377e9 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -149,6 +149,10 @@ export class EventManager { private cutOperationId: number = 0; private clipboardOperationId: number = 0; private pasteOperationId: number = 0; + private pasteWriteInProgress: boolean = false; + private lastCopiedClipboardData: EventClipboardData | null = null; + private fieldKeyIds: WeakMap = new WeakMap(); + private fieldKeyId: number = 0; private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; @@ -844,11 +848,17 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; + if (this.pasteWriteInProgress) { + e.preventDefault(); + return null; + } if (!isCut) { this.resetCutState(); } const copySnapshot = this.getCopySnapshot(isCut); this.copySourceRange = null; + this.lastCopiedClipboardData = null; if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 @@ -868,10 +878,19 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; - const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); - const eventClipboardWriteData = hasEventClipboard ? this.setCopyDataToEventClipboard(data, dataHTML, e) : null; + const plainClipboardData = this.getCopyFormulaPlainData(data, isCut); + if (plainClipboardData === null) { + return null; + } + const eventClipboardWriteData = hasEventClipboard + ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e) + : null; if (eventClipboardWriteData) { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copiedClipboardData = eventClipboardWriteData; + this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); copySnapshot.clipboardData = copiedClipboardData; @@ -879,9 +898,6 @@ export class EventManager { } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { if (!this.isClipboardOperationCurrent(operationId)) { @@ -938,14 +954,14 @@ export class EventManager { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), - 'text/plain': new Blob([data], { type: 'text/plain' }) + 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); if (!this.isClipboardOperationCurrent(operationId)) { return null; } copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: data, hasHtml: true, hasText: true }; + copiedClipboardData = { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); @@ -958,30 +974,40 @@ export class EventManager { } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 - copySucceeded = this.fallbackCopyToClipboard(data); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } } else { // 没有权限,使用降级方案 - copySucceeded = this.fallbackCopyToClipboard(data); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } } else { // 不支持现代剪贴板API,使用降级方案 - copySucceeded = this.fallbackCopyToClipboard(data); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } if (!copySucceeded) { return null; } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); copySnapshot.clipboardData = copiedClipboardData; @@ -989,25 +1015,26 @@ export class EventManager { } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 - if (!this.fallbackCopyToClipboard(data)) { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + if (!this.fallbackCopyToClipboard(plainClipboardData)) { + return null; + } + if (!this.isClipboardOperationCurrent(operationId)) { return null; } + this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; if (isCut) { - this.lastClipboardContent = this.getClipboardSignature({ html: '', text: data, hasText: true }); - copySnapshot.clipboardData = { html: '', text: data, hasText: true }; + this.lastClipboardContent = this.getClipboardSignature(this.lastCopiedClipboardData); + copySnapshot.clipboardData = this.lastCopiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } } } else { return null; @@ -1140,6 +1167,9 @@ export class EventManager { if (!sourceData) { return this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; } + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return null; + } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1156,7 +1186,18 @@ export class EventManager { return hasComparableType ? false : null; } - private getCopyFormulaPlainData(data: string): string { + private isClipboardDataSameAsLastCopy(clipboardData: EventClipboardData | null): boolean { + const sourceData = this.lastCopiedClipboardData; + if (!clipboardData || clipboardData.readFailed || !sourceData) { + return false; + } + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return false; + } + return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); + } + + private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { const table = this.table; if (table.stateManager.select.ranges.length !== 1 || !table.options.keyboardOptions?.getCopyCellValue?.html) { return data; @@ -1167,7 +1208,7 @@ export class EventManager { ); } catch (error) { console.warn('复制公式纯文本数据生成失败,使用显示值:', error); - return data; + return isCut ? null : data; } } @@ -1304,11 +1345,15 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteOperationId = ++this.pasteOperationId; + if (this.pasteWriteInProgress) { + e.preventDefault(); + return; + } const pasteRange = this.getPasteRange(); if (!pasteRange) { return; } - const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1318,9 +1363,10 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteOperationId)) { return; } + const copySourceRange = this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null; this.executePaste(clipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange, + copySourceRange, pasteOperationId }); }) @@ -1332,7 +1378,11 @@ export class EventManager { const cutState = this.activeCutState; if (!cutState) { - this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange, pasteOperationId }); + this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); return; } @@ -1355,13 +1405,10 @@ export class EventManager { return; } if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - if (!cutState.isSingleRange) { - this.resetCutState(); - return; - } if (pasteResult.migratedSourceCells.length === cutState.sourceCells.length) { const cleared = await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); if (!cleared) { + this.resetCutState(); return; } if (this.activeCutState?.id === cutState.id) { @@ -1381,7 +1428,7 @@ export class EventManager { } await this.executePaste(eventClipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, pasteOperationId }); }); @@ -1393,12 +1440,14 @@ export class EventManager { const table = this.table; const emptyResult = this.getEmptyPasteResult(); if ( + this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || (table as ListTableAPI).editorManager?.editingEditor ) { return emptyResult; } let pasteResult = emptyResult; + this.pasteWriteInProgress = true; try { if ((table as ListTableAPI).changeCellValues) { try { @@ -1422,10 +1471,14 @@ export class EventManager { } } catch (error) { console.error('粘贴操作失败:', error); + if (pasteContext.cutState) { + this.resetCutState(); + } } } return pasteResult; } finally { + this.pasteWriteInProgress = false; if (!pasteContext.cutState && table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } @@ -1584,7 +1637,24 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); } - const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); + } + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + valuesToPaste, + !pasteContext.cutState + ); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); @@ -1632,7 +1702,24 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); } - const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); + } + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + valuesToPaste, + !pasteContext.cutState + ); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); @@ -1718,17 +1805,72 @@ export class EventManager { ); } + private async validatePasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): Promise { + const table = this.table as ListTableAPI; + const changedCellResults: boolean[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + changedCellResults[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + const tableAny = table as any; + let canChange = + targetCol < tableAny.colCount && + targetRow < tableAny.rowCount && + (!tableAny.isHasEditorDefine || tableAny.isHasEditorDefine(targetCol, targetRow)); + if (canChange) { + const editor = tableAny.getEditor?.(targetCol, targetRow); + const oldValue = table.getCellOriginValue?.(targetCol, targetRow); + const validateResult = + editor?.validateValue?.(values[rowIndex][colIndex], oldValue, { col: targetCol, row: targetRow }, table) ?? + true; + const resolvedValidateResult = + validateResult && typeof (validateResult as Promise).then === 'function' + ? await validateResult + : validateResult; + canChange = + resolvedValidateResult === true || + resolvedValidateResult === 'validate-exit' || + resolvedValidateResult === 'validate-not-exit'; + } + changedCellResults[rowIndex][colIndex] = canChange; + } + } + return changedCellResults; + } + + private isFullPasteAllowed(values: (string | number)[][], changedCellResults: boolean[][]): boolean { + return values.every((rowValues, rowIndex) => + rowValues.every((_value, colIndex) => changedCellResults[rowIndex]?.[colIndex] === true) + ); + } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { - if ( - !cutState?.isSingleRange || - !cutState.sourceCells.length || - !cutState.sourceColCount || - !cutState.sourceRowCount - ) { + if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { return []; } const migratedSourceCells: SourceCell[] = []; const migratedKeys = new Set(); + if (!cutState.isSingleRange) { + let sourceIndex = 0; + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + const sourceCell = cutState.sourceCells[sourceIndex++]; + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + if (!sourceCell) { + return []; + } + const sourceKey = this.getCellKey(sourceCell); + if (!migratedKeys.has(sourceKey)) { + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + } + return migratedSourceCells.length === cutState.sourceCells.length ? migratedSourceCells : []; + } for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { if (!changedCellResults[rowIndex][colIndex]) { @@ -1838,7 +1980,7 @@ export class EventManager { private getCellKey(cell: SourceCell): string { if (cell.recordIndex !== undefined && cell.field !== undefined) { - return `record:${String(cell.recordIndex)}:${String(cell.field)}`; + return `record:${this.getStableKeyPart(cell.recordIndex)}:${this.getStableKeyPart(cell.field)}`; } return this.getCoordKey(cell); } @@ -1847,6 +1989,28 @@ export class EventManager { return `coord:${cell.col}:${cell.row}`; } + private getStableKeyPart(value: any): string { + if (value === null) { + return 'null'; + } + const valueType = typeof value; + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + return `${valueType}:${JSON.stringify(value)}`; + } + if (Array.isArray(value)) { + return `array:[${value.map(item => this.getStableKeyPart(item)).join(',')}]`; + } + if (valueType === 'object' || valueType === 'function') { + let id = this.fieldKeyIds.get(value); + if (id === undefined) { + id = ++this.fieldKeyId; + this.fieldKeyIds.set(value, id); + } + return `${valueType}:#${id}`; + } + return `${valueType}:${String(value)}`; + } + private resetCutState(): void { this.cutWaitPaste = false; this.activeCutState = null; From 9f7219626cb32a0c084f83a73177ecc4d710105e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 15:05:39 +0800 Subject: [PATCH 26/65] fix: address clipboard review issues Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 194 +++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 38 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 2ec91377e9..609cd59295 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -55,6 +55,7 @@ type CopySnapshot = { copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; sourceColCount: number; sourceRowCount: number; clipboardData: EventClipboardData | null; @@ -77,11 +78,14 @@ type SourceCell = { value?: any; }; +type SourceCellMatrix = (SourceCell | null)[][]; + type CutState = { id: number; ranges: ClipboardRange[]; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; sourceColCount: number; sourceRowCount: number; clipboardData: EventClipboardData | null; @@ -150,6 +154,7 @@ export class EventManager { private clipboardOperationId: number = 0; private pasteOperationId: number = 0; private pasteWriteInProgress: boolean = false; + private clipboardWritePromise: Promise = Promise.resolve(); private lastCopiedClipboardData: EventClipboardData | null = null; private fieldKeyIds: WeakMap = new WeakMap(); private fieldKeyId: number = 0; @@ -847,12 +852,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; - const operationId = ++this.clipboardOperationId; - this.pasteOperationId++; if (this.pasteWriteInProgress) { e.preventDefault(); return null; } + const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; if (!isCut) { this.resetCutState(); } @@ -949,27 +954,26 @@ export class EventManager { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - // 尝试使用 ClipboardItem(支持富文本) - if (canWriteRichClipboard && dataHTML) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([dataHTML], { type: 'text/html' }), - 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) - }) - ]); + copiedClipboardData = await this.queueClipboardWrite(async () => { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; - } else { + // 尝试使用 ClipboardItem(支持富文本) + if (canWriteRichClipboard && dataHTML) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([dataHTML], { type: 'text/html' }), + 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) + }) + ]); + return { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; + } // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } + return { html: '', text: plainClipboardData, hasText: true }; + }); + if (copiedClipboardData) { copySucceeded = true; - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); @@ -1079,8 +1083,9 @@ export class EventManager { end: { col: range.end.col, row: range.end.row } })); const sourceRange = ranges.length === 1 ? ranges[0] : null; - const sourceCells = this.getSourceCells(clonedRanges); - const sourceSize = this.getSourceSize(clonedRanges, sourceCells.length); + const sourceCellMatrix = this.getSourceCellMatrix(clonedRanges); + const sourceCells = this.getSourceCells(sourceCellMatrix); + const sourceSize = this.getSourceSize(clonedRanges, sourceCellMatrix); return { ranges: clonedRanges, @@ -1092,6 +1097,7 @@ export class EventManager { : null, cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, sourceCells, + sourceCellMatrix, sourceColCount: sourceSize.colCount, sourceRowCount: sourceSize.rowCount, clipboardData: null, @@ -1106,29 +1112,101 @@ export class EventManager { })); } - private getSourceCells(ranges: ClipboardRange[]): SourceCell[] { + private getSourceCellMatrix(ranges: ClipboardRange[]): SourceCellMatrix { const table = this.table as ListTableAPI; - const sourceCells: SourceCell[] = []; - for (let i = 0; i < ranges.length; i++) { - const range = ranges[i]; + const createSourceCell = (col: number, row: number): SourceCell => { + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }; + }; + if (ranges.length === 1) { + const range = ranges[0]; const startCol = Math.min(range.start.col, range.end.col); const endCol = Math.max(range.start.col, range.end.col); const startRow = Math.min(range.start.row, range.end.row); const endRow = Math.max(range.start.row, range.end.row); + const sourceCellMatrix: SourceCellMatrix = []; for (let row = startRow; row <= endRow; row++) { + const rowCells: (SourceCell | null)[] = []; for (let col = startCol; col <= endCol; col++) { - const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); - const recordIndex = - recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; - const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; - sourceCells.push({ col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }); + rowCells.push(createSourceCell(col, row)); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + let minCol = Math.min(ranges[0].start.col, ranges[0].end.col); + let maxCol = Math.max(ranges[0].start.col, ranges[0].end.col); + let minRow = Math.min(ranges[0].start.row, ranges[0].end.row); + let maxRow = Math.max(ranges[0].start.row, ranges[0].end.row); + ranges.forEach(range => { + minCol = Math.min(minCol, range.start.col, range.end.col); + maxCol = Math.max(maxCol, range.start.col, range.end.col); + minRow = Math.min(minRow, range.start.row, range.end.row); + maxRow = Math.max(maxRow, range.start.row, range.end.row); + }); + const isExistDataInRow = (row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + return startRow <= row && endRow >= row; + }); + const isExistDataInCol = (col: number) => + ranges.some(range => { + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col; + }); + const getRangeExistDataInCell = (col: number, row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col && startRow <= row && endRow >= row; + }); + const sourceCellMatrix: SourceCellMatrix = []; + for (let row = minRow; row <= maxRow; row++) { + if (!isExistDataInRow(row)) { + continue; + } + const rowCells: (SourceCell | null)[] = []; + for (let col = minCol; col <= maxCol; col++) { + if (!isExistDataInCol(col)) { + continue; + } + rowCells.push(getRangeExistDataInCell(col, row) ? createSourceCell(col, row) : null); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + private getSourceCells(sourceCellMatrix: SourceCellMatrix): SourceCell[] { + const sourceCells: SourceCell[] = []; + for (let row = 0; row < sourceCellMatrix.length; row++) { + for (let col = 0; col < sourceCellMatrix[row].length; col++) { + const sourceCell = sourceCellMatrix[row][col]; + if (sourceCell) { + sourceCells.push(sourceCell); } } } return sourceCells; } - private getSourceSize(ranges: ClipboardRange[], sourceCellCount: number): { colCount: number; rowCount: number } { + private getSourceSize( + ranges: ClipboardRange[], + sourceCellMatrix: SourceCellMatrix + ): { colCount: number; rowCount: number } { + if (sourceCellMatrix.length) { + return { + colCount: sourceCellMatrix[0]?.length ?? 0, + rowCount: sourceCellMatrix.length + }; + } if (ranges.length === 1) { const range = ranges[0]; return { @@ -1136,10 +1214,7 @@ export class EventManager { rowCount: Math.abs(range.end.row - range.start.row) + 1 }; } - return { - colCount: sourceCellCount, - rowCount: 1 - }; + return { colCount: 0, rowCount: 0 }; } private getClipboardSignature(data: EventClipboardData | null): string { @@ -1159,6 +1234,33 @@ export class EventManager { return (operationId === undefined || operationId === this.pasteOperationId) && !this.table.isReleased; } + private async queueClipboardWrite( + write: () => Promise + ): Promise { + const previousWrite = this.clipboardWritePromise; + let settleCurrentWrite: () => void = () => { + return; + }; + this.clipboardWritePromise = previousWrite + .catch(() => { + // Ignore stale write failures so the latest copy can still proceed. + }) + .then( + () => + new Promise(resolve => { + settleCurrentWrite = resolve; + }) + ); + await previousWrite.catch(() => { + // Previous clipboard write failure should not block later copy attempts. + }); + try { + return await write(); + } finally { + settleCurrentWrite(); + } + } + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1170,6 +1272,15 @@ export class EventManager { if (sourceData.hasHtml && !clipboardData.hasHtml) { return null; } + if (!sourceData.hasHtml && clipboardData.hasHtml) { + return true; + } + if (sourceData.hasText && !clipboardData.hasText) { + return null; + } + if (!sourceData.hasText && clipboardData.hasText) { + return true; + } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1191,7 +1302,7 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - if (sourceData.hasHtml && !clipboardData.hasHtml) { + if (!!sourceData.hasHtml !== !!clipboardData.hasHtml || !!sourceData.hasText !== !!clipboardData.hasText) { return false; } return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); @@ -1316,6 +1427,7 @@ export class EventManager { ranges: this.cloneRanges(copySnapshot.ranges), cellInfos: copySnapshot.cellInfos, sourceCells: copySnapshot.sourceCells, + sourceCellMatrix: copySnapshot.sourceCellMatrix, sourceColCount: copySnapshot.sourceColCount, sourceRowCount: copySnapshot.sourceRowCount, clipboardData: copySnapshot.clipboardData, @@ -1345,7 +1457,6 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { - const pasteOperationId = ++this.pasteOperationId; if (this.pasteWriteInProgress) { e.preventDefault(); return; @@ -1354,6 +1465,7 @@ export class EventManager { if (!pasteRange) { return; } + const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1393,6 +1505,11 @@ export class EventManager { } const changed = this.isClipboardDataChanged(clipboardData, cutState); if (changed === null) { + await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); return; } const pasteResult = await this.executePaste(clipboardData, { @@ -1852,15 +1969,16 @@ export class EventManager { const migratedSourceCells: SourceCell[] = []; const migratedKeys = new Set(); if (!cutState.isSingleRange) { - let sourceIndex = 0; for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { - const sourceCell = cutState.sourceCells[sourceIndex++]; if (!changedCellResults[rowIndex][colIndex]) { continue; } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceCell = cutState.sourceCellMatrix[sourceRow]?.[sourceCol]; if (!sourceCell) { - return []; + continue; } const sourceKey = this.getCellKey(sourceCell); if (!migratedKeys.has(sourceKey)) { From 3da291aa0686d941a92696c68de7430a39c7f176 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 15:45:45 +0800 Subject: [PATCH 27/65] fix: harden clipboard async operations Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/ListTable.ts | 6 +- packages/vtable/src/core/record-helper.ts | 50 +++++- packages/vtable/src/event/event.ts | 154 +++++++++++++++---- packages/vtable/src/ts-types/table-engine.ts | 13 +- 4 files changed, 185 insertions(+), 38 deletions(-) diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 11ce44f6c0..b3bb28e128 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1846,7 +1846,8 @@ export class ListTable extends BaseTable implements ListTableAPI { values: (string | number)[][], workOnEditableCell = false, triggerEvent = true, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) { return listTableChangeCellValues( startCol, @@ -1855,7 +1856,8 @@ export class ListTable extends BaseTable implements ListTableAPI { workOnEditableCell, triggerEvent, this, - noTriggerChangeCellValuesEvent + noTriggerChangeCellValuesEvent, + shouldCancel ); } diff --git a/packages/vtable/src/core/record-helper.ts b/packages/vtable/src/core/record-helper.ts index 314dd30904..f920cfc325 100644 --- a/packages/vtable/src/core/record-helper.ts +++ b/packages/vtable/src/core/record-helper.ts @@ -164,7 +164,8 @@ export async function listTableChangeCellValues( workOnEditableCell: boolean, triggerEvent: boolean, table: ListTable, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ): Promise { const changedCellResults: boolean[][] = []; let pasteColEnd = startCol; @@ -206,8 +207,45 @@ export async function listTableChangeCellValues( changedValue: string | number; }[] = []; + const preValidatedCellResults: boolean[][] | null = shouldCancel && workOnEditableCell ? [] : null; + if (preValidatedCellResults) { + for (let i = 0; i < values.length; i++) { + if (shouldCancel?.()) { + return changedCellResults; + } + if (startRow + i > table.rowCount - 1) { + break; + } + preValidatedCellResults[i] = []; + const rowValues = values[i]; + for (let j = 0; j < rowValues.length; j++) { + if (startCol + j > table.colCount - 1) { + break; + } + let isCanChange = false; + if (table.isHasEditorDefine(startCol + j, startRow + i)) { + const editor = table.getEditor(startCol + j, startRow + i); + const oldValue = oldValues[i][j]; + const value = rowValues[j]; + const maybePromiseOrValue = + editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; + const validateResult = isPromise(maybePromiseOrValue) ? await maybePromiseOrValue : maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } + isCanChange = + validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; + } + preValidatedCellResults[i][j] = isCanChange; + } + } + } + //#endregion for (let i = 0; i < values.length; i++) { + if (shouldCancel?.()) { + return changedCellResults; + } if (startRow + i > table.rowCount - 1) { break; } @@ -221,7 +259,9 @@ export async function listTableChangeCellValues( } thisRowPasteColEnd = startCol + j; let isCanChange = false; - if (workOnEditableCell === false) { + if (preValidatedCellResults) { + isCanChange = preValidatedCellResults[i]?.[j] === true; + } else if (workOnEditableCell === false) { isCanChange = true; } else { if (table.isHasEditorDefine(startCol + j, startRow + i)) { @@ -232,6 +272,9 @@ export async function listTableChangeCellValues( editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; if (isPromise(maybePromiseOrValue)) { const validateResult = await maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } isCanChange = validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; } else { @@ -244,6 +287,9 @@ export async function listTableChangeCellValues( } // if ((workOnEditableCell && table.isHasEditorDefine(startCol + j, startRow + i)) || workOnEditableCell === false) { if (isCanChange) { + if (shouldCancel?.()) { + return changedCellResults; + } changedCellResults[i][j] = true; const value = rowValues[j]; const recordShowIndex = table.getRecordShowIndexByCell(startCol + j, startRow + i); diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 609cd59295..b7cf28af86 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -68,6 +68,7 @@ type EventClipboardData = { hasHtml?: boolean; hasText?: boolean; readFailed?: boolean; + internalFormulaText?: string; }; type SourceCell = { @@ -852,12 +853,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; + const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; if (this.pasteWriteInProgress) { e.preventDefault(); return null; } - const operationId = ++this.clipboardOperationId; - this.pasteOperationId++; if (!isCut) { this.resetCutState(); } @@ -883,18 +884,25 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; - const plainClipboardData = this.getCopyFormulaPlainData(data, isCut); - if (plainClipboardData === null) { + const formulaClipboardData = this.getCopyFormulaPlainData(data, isCut); + if (formulaClipboardData === null) { return null; } + const plainClipboardData = data; + const internalFormulaText = formulaClipboardData !== plainClipboardData ? formulaClipboardData : undefined; const eventClipboardWriteData = hasEventClipboard - ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e) + ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e, internalFormulaText) : null; if (eventClipboardWriteData) { - if (!this.isClipboardOperationCurrent(operationId)) { + copiedClipboardData = await this.ensureQueuedClipboardWrite( + eventClipboardWriteData, + canUseAsyncClipboard, + canWriteRichClipboard, + operationId + ); + if (!copiedClipboardData) { return null; } - copiedClipboardData = eventClipboardWriteData; this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); @@ -966,11 +974,17 @@ export class EventManager { 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); - return { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; + return { + html: dataHTML, + text: plainClipboardData, + hasHtml: true, + hasText: true, + internalFormulaText + }; } // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); - return { html: '', text: plainClipboardData, hasText: true }; + return { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; }); if (copiedClipboardData) { copySucceeded = true; @@ -986,21 +1000,21 @@ export class EventManager { return null; } if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } @@ -1031,7 +1045,7 @@ export class EventManager { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(this.lastCopiedClipboardData); copySnapshot.clipboardData = this.lastCopiedClipboardData; @@ -1261,6 +1275,37 @@ export class EventManager { } } + private async ensureQueuedClipboardWrite( + clipboardData: EventClipboardData, + canUseAsyncClipboard: boolean, + canWriteRichClipboard: boolean, + operationId: number + ): Promise { + return this.queueClipboardWrite(async () => { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + try { + if (canWriteRichClipboard && clipboardData.hasHtml) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([clipboardData.html], { type: 'text/html' }), + 'text/plain': new Blob([clipboardData.text], { type: 'text/plain' }) + }) + ]); + } else if (canUseAsyncClipboard) { + await navigator.clipboard.writeText(clipboardData.text); + } + } catch (error) { + console.warn('异步同步事件剪贴板数据失败,保留事件剪贴板结果:', error); + } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + return clipboardData; + }); + } + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1272,15 +1317,9 @@ export class EventManager { if (sourceData.hasHtml && !clipboardData.hasHtml) { return null; } - if (!sourceData.hasHtml && clipboardData.hasHtml) { - return true; - } if (sourceData.hasText && !clipboardData.hasText) { return null; } - if (!sourceData.hasText && clipboardData.hasText) { - return true; - } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1294,7 +1333,10 @@ export class EventManager { return true; } } - return hasComparableType ? false : null; + if (hasComparableType) { + return false; + } + return null; } private isClipboardDataSameAsLastCopy(clipboardData: EventClipboardData | null): boolean { @@ -1302,10 +1344,30 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - if (!!sourceData.hasHtml !== !!clipboardData.hasHtml || !!sourceData.hasText !== !!clipboardData.hasText) { + return this.isClipboardDataSameByCommonTypes(clipboardData, sourceData); + } + + private isClipboardDataSameByCommonTypes(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return false; + } + if (sourceData.hasText && !clipboardData.hasText) { return false; } - return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); + let hasComparableType = false; + if (sourceData.hasHtml && clipboardData.hasHtml) { + hasComparableType = true; + if (sourceData.html !== clipboardData.html) { + return false; + } + } + if (sourceData.hasText && clipboardData.hasText) { + hasComparableType = true; + if (sourceData.text !== clipboardData.text) { + return false; + } + } + return hasComparableType; } private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { @@ -1350,7 +1412,8 @@ export class EventManager { private setCopyDataToEventClipboard( data: string, dataHTML: string | null, - e: KeyboardEvent + e: KeyboardEvent, + internalFormulaText?: string ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { @@ -1359,7 +1422,7 @@ export class EventManager { try { clipboardData.setData('text/plain', data); - const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true }; + const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true, internalFormulaText }; if (dataHTML) { try { clipboardData.setData('text/html', dataHTML); @@ -1457,6 +1520,7 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteOperationId = ++this.pasteOperationId; if (this.pasteWriteInProgress) { e.preventDefault(); return; @@ -1465,7 +1529,6 @@ export class EventManager { if (!pasteRange) { return; } - const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1556,6 +1619,7 @@ export class EventManager { ): Promise { const table = this.table; const emptyResult = this.getEmptyPasteResult(); + const pasteClipboardData = this.getPasteClipboardData(clipboardData, pasteContext); if ( this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || @@ -1568,22 +1632,25 @@ export class EventManager { try { if ((table as ListTableAPI).changeCellValues) { try { - if (clipboardData?.html) { + if (pasteClipboardData?.html) { try { - const htmlResult = await this.processPastedHTML(clipboardData.html, pasteContext); + const htmlResult = await this.processPastedHTML(pasteClipboardData.html, pasteContext); if (htmlResult.pasted) { pasteResult = htmlResult; return pasteResult; } } catch (error) { console.warn('粘贴HTML数据失败:', error); - if (/ !this.isPasteOperationCurrent(pasteContext.pasteOperationId) ); if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); @@ -1832,7 +1919,10 @@ export class EventManager { col, row, valuesToPaste, - !pasteContext.cutState + !pasteContext.cutState, + true, + undefined, + () => !this.isPasteOperationCurrent(pasteContext.pasteOperationId) ); if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index ac4ce2c794..d8f89ef3bd 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -399,7 +399,8 @@ export interface ListTableAPI extends BaseTableAPI { values: (string | number)[][], workOnEditableCell?: boolean, triggerEvent?: boolean, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) => Promise | boolean[][]; /** * 根据源数据 records 的 index + field 修改值。 @@ -750,7 +751,15 @@ export interface PivotTableAPI extends BaseTableAPI { * @param row 粘贴数据的起始行号 * @param values 多个单元格的数据数组 */ - changeCellValues: (col: number, row: number, values: (string | number)[][], workOnEditableCell: boolean) => void; + changeCellValues: ( + col: number, + row: number, + values: (string | number)[][], + workOnEditableCell: boolean, + triggerEvent?: boolean, + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ) => void; /** * 获取行表头全路径 From c5a7733423cd9cebf19fd023d3aa1c6e5e5b4f58 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 17:04:30 +0800 Subject: [PATCH 28/65] docs: improve third-party component popup handling guidance and demos (#4232, #4213) --- .../arco-select-editor.md | 15 +++++++--- .../arco-select-editor.md | 10 +++++-- .../en/edit-data/arco-select-editor.md | 14 ++++++++- .../zh/edit-data/arco-select-editor.md | 13 ++++++++- docs/assets/guide/en/edit/edit_cell.md | 29 +++++++++++++++++++ docs/assets/guide/zh/edit/edit_cell.md | 29 +++++++++++++++++++ 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/docs/assets/demo-react/en/functional-components/arco-select-editor.md b/docs/assets/demo-react/en/functional-components/arco-select-editor.md index a6bb930ee6..1b533b0de9 100644 --- a/docs/assets/demo-react/en/functional-components/arco-select-editor.md +++ b/docs/assets/demo-react/en/functional-components/arco-select-editor.md @@ -20,6 +20,8 @@ https://arco.design/react/components/select ```javascript livedemo template=vtable-react // import * as ReactVTable from '@visactor/react-vtable'; +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { constructor() { this.root = null; @@ -48,6 +50,7 @@ class ArcoListEditor {
{ this.currentValue = value; @@ -86,19 +89,23 @@ class ArcoListEditor { } isEditorElement(target) { - // cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域 + // The popup is appended to body and is outside the editor container. + // Clicking the popup should still be treated as clicking inside the editor. return this.element.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || target.classList.contains('arco-select-vtable')) + ) { return true; } - // 如果到达了DOM树的顶部,则停止搜索 + // Stop searching when reaching the top of the DOM tree. target = target.parentNode; } - // 如果遍历结束也没有找到符合条件的父元素,则返回false + // Return false if no matching parent element is found. return false; } } diff --git a/docs/assets/demo-react/zh/functional-components/arco-select-editor.md b/docs/assets/demo-react/zh/functional-components/arco-select-editor.md index c4edc624b4..9b9d4abfb4 100644 --- a/docs/assets/demo-react/zh/functional-components/arco-select-editor.md +++ b/docs/assets/demo-react/zh/functional-components/arco-select-editor.md @@ -20,6 +20,8 @@ https://arco.design/react/components/select ```javascript livedemo template=vtable-react // import * as ReactVTable from '@visactor/react-vtable'; +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { constructor() { this.root = null; @@ -48,6 +50,7 @@ class ArcoListEditor {
{ this.currentValue = value; @@ -86,13 +89,16 @@ class ArcoListEditor { } isEditorElement(target) { - // cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域 + // popup append 在 body 尾部,不在编辑器容器内;点击 popup 时也应该被认为仍在编辑器区域。 return this.element.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || target.classList.contains('arco-select-vtable')) + ) { return true; } // 如果到达了DOM树的顶部,则停止搜索 diff --git a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md index 6d830f64f7..10d8206404 100644 --- a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md @@ -19,6 +19,8 @@ https://arco.design/vue/components/select ## Code Demonstration ```javascript livedemo template=vtable-vue +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { root = null; element = null; @@ -64,6 +66,9 @@ class ArcoListEditor { style: { height: '32px' }, placeholder: 'Select city', modelValue: this.currentValue, + triggerProps: { + class: selectEditorPopupClassName + }, 'onUpdate:modelValue': value => { this.currentValue = value; self.setValue(value); @@ -110,12 +115,19 @@ class ArcoListEditor { } isEditorElement(target) { + // The popup is appended to body and is outside the editor container. + // Clicking the popup should still be treated as clicking inside the editor. return this.element?.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || + target.classList.contains('arco-select-vtable') || + target.classList.contains('arco-select-popup')) + ) { return true; } target = target.parentNode; diff --git a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md index c4e66c1264..26f27d459b 100644 --- a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md @@ -19,6 +19,8 @@ https://arco.design/vue/components/select ## 代码演示 ```javascript livedemo template=vtable-vue +const selectEditorPopupClassName = 'vtable-editor-select-popup'; + class ArcoListEditor { root = null; element = null; @@ -64,6 +66,9 @@ class ArcoListEditor { style: { height: '32px' }, placeholder: 'Select city', modelValue: this.currentValue, + triggerProps: { + class: selectEditorPopupClassName + }, 'onUpdate:modelValue': value => { this.currentValue = value; self.setValue(value); @@ -110,12 +115,18 @@ class ArcoListEditor { } isEditorElement(target) { + // popup append 在 body 尾部,不在编辑器容器内;点击 popup 时也应该被认为仍在编辑器区域。 return this.element?.contains(target) || this.isClickPopUp(target); } isClickPopUp(target) { while (target) { - if (target.classList && target.classList.contains('arco-select-vtable')) { + if ( + target.classList && + (target.classList.contains(selectEditorPopupClassName) || + target.classList.contains('arco-select-vtable') || + target.classList.contains('arco-select-popup')) + ) { return true; } target = target.parentNode; diff --git a/docs/assets/guide/en/edit/edit_cell.md b/docs/assets/guide/en/edit/edit_cell.md index a8f2e0d7e6..feec4f08a2 100644 --- a/docs/assets/guide/en/edit/edit_cell.md +++ b/docs/assets/guide/en/edit/edit_cell.md @@ -184,6 +184,35 @@ VTable.register.editor('custom-date', custom_date_editor); In the above example, we created a custom editor named `DateEditor` and implemented the methods required by the `IEditor` interface. Then, we register the custom editor into the VTable through the `VTable.register.editor` method for use in the table. +### Handling popup DOM from third-party components + +When a custom editor uses third-party components such as Select, Cascader, DatePicker, or Tooltip, the popup DOM may not be mounted inside the editor container. Many component libraries append popups to `body` through portal/teleport. In this case, when the user clicks a popup option, the click target is outside the editor container from VTable's perspective, and the editor may exit too early. + +It is recommended to configure a stable class on the popup root element and let `isEditorElement` check both the editor container and the popup DOM: + +```ts +const editorPopupClassName = 'vtable-editor-popup'; + +isEditorElement(target: HTMLElement) { + return this.element.contains(target) || this.isClickPopUp(target); +} + +isClickPopUp(target: HTMLElement) { + let current: HTMLElement | null = target; + while (current) { + if (current.classList?.contains(editorPopupClassName)) { + return true; + } + current = current.parentNode as HTMLElement | null; + } + return false; +} +``` + +Different component libraries expose different props for popup class names. For example, React Arco Select can use `dropdownMenuClassName`, Vue Arco Select can use `triggerProps.class`, and Ant Design Select can use `classNames.popup.root` or `popupClassName` in older versions. See the React and Vue Arco Select custom editor demos for complete examples. + +If the third-party component has a close animation after selection, or if its internal implementation affects the order of `onChange` and blur events, update the editor's current value in the component change callback before calling `endEdit`. This prevents `getValue` from reading the old value. + `IEditor` [definition](https://github.com/VisActor/VTable/blob/main/packages/vtable-editors/src/types.ts): ```ts diff --git a/docs/assets/guide/zh/edit/edit_cell.md b/docs/assets/guide/zh/edit/edit_cell.md index 131974bee5..7cafa9e484 100644 --- a/docs/assets/guide/zh/edit/edit_cell.md +++ b/docs/assets/guide/zh/edit/edit_cell.md @@ -189,6 +189,35 @@ VTable.register.editor('custom-date', custom_date_editor); 在上面的示例中,我们创建了一个名为`DateEditor`的自定义编辑器,并实现了`IEditor`接口所要求的方法。然后,我们通过`VTable.register.editor`方法将自定义编辑器注册到 VTable 中,以便在表格中使用。 +### 处理第三方组件的弹层 DOM + +当自定义编辑器使用 Select、Cascader、DatePicker、Tooltip 等第三方组件时,弹层 DOM 可能不会挂载在编辑器容器内部,而是通过 portal/teleport 追加到 `body`。这种情况下,用户点击弹层选项时,VTable 看到的点击目标不在编辑器容器内,可能会触发编辑器提前结束。 + +建议为第三方组件的弹层根节点配置一个稳定的 class,并在 `isEditorElement` 中同时判断编辑器容器和弹层 DOM: + +```ts +const editorPopupClassName = 'vtable-editor-popup'; + +isEditorElement(target: HTMLElement) { + return this.element.contains(target) || this.isClickPopUp(target); +} + +isClickPopUp(target: HTMLElement) { + let current: HTMLElement | null = target; + while (current) { + if (current.classList?.contains(editorPopupClassName)) { + return true; + } + current = current.parentNode as HTMLElement | null; + } + return false; +} +``` + +不同组件库暴露的弹层 class 配置项不同,例如 React Arco Select 可以通过 `dropdownMenuClassName` 配置,Vue Arco Select 可以通过 `triggerProps.class` 配置,Ant Design Select 可以通过 `classNames.popup.root` 或旧版本的 `popupClassName` 配置。完整示例可参考 React 和 Vue 的 Arco Select 自定义编辑器 demo。 + +如果第三方组件在选中后存在关闭动画,或者 `onChange` 与失焦事件的触发顺序受组件内部实现影响,需要先在组件的变更回调中更新编辑器当前值,再按需调用 `endEdit` 结束编辑,避免 `getValue` 读取到旧值。 + `IEditor` 接口[定义](https://github.com/VisActor/VTable/blob/main/packages/vtable-editors/src/types.ts): ```ts From f4b6b9ca006b9da97ee41018b57f7f6651eecf64 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 17:14:40 +0800 Subject: [PATCH 29/65] fix: tighten clipboard transaction guards Prevent async clipboard mirrors from overwriting event clipboard writes. Require a VTable HTML marker before restoring copied formula text. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/PivotTable.ts | 23 ++- packages/vtable/src/core/record-helper.ts | 82 +++++++++-- packages/vtable/src/data/DataSource.ts | 17 +-- packages/vtable/src/event/event.ts | 144 +++++++++---------- packages/vtable/src/ts-types/table-engine.ts | 2 +- 5 files changed, 169 insertions(+), 99 deletions(-) diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index de64c0e3fc..fcfbb1a5f4 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -2047,7 +2047,19 @@ export class PivotTable extends BaseTable implements PivotTableAPI { * @param values 多个单元格的数据数组 * @param workOnEditableCell 是否仅更改可编辑单元格 */ - changeCellValues(startCol: number, startRow: number, values: string[][], workOnEditableCell = false) { + changeCellValues( + startCol: number, + startRow: number, + values: (string | number)[][], + workOnEditableCell = false, + triggerEvent = true, + _noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ): boolean[][] { + const changedCellResults: boolean[][] = []; + if (shouldCancel?.()) { + return changedCellResults; + } let pasteColEnd = startCol; let pasteRowEnd = startRow; // const rowCount = values.length; @@ -2078,6 +2090,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { if (startRow + i > this.rowCount - 1) { break; } + changedCellResults[i] = []; pasteRowEnd = startRow + i; const rowValues = values[i]; let thisRowPasteColEnd = startCol; @@ -2095,12 +2108,13 @@ export class PivotTable extends BaseTable implements PivotTableAPI { let newValue: string | number = value; const oldValue = oldValues[i][j]; const rawValue = beforeChangeValues[i][j]; - if (typeof rawValue === 'number' && isAllDigits(value)) { + if (typeof rawValue === 'number' && typeof value === 'string' && isAllDigits(value)) { newValue = parseFloat(value); } + changedCellResults[i][j] = true; this._changeCellValueToDataSet(startCol + j, startRow + i, oldValue, newValue); const changedValue = this.getCellOriginValue(startCol + j, startRow + i); - if (changedValue !== oldValue) { + if (changedValue !== oldValue && triggerEvent) { this.fireListeners(TABLE_EVENT_TYPE.CHANGE_CELL_VALUE, { col: startCol + j, row: startRow + i, @@ -2109,6 +2123,8 @@ export class PivotTable extends BaseTable implements PivotTableAPI { changedValue }); } + } else { + changedCellResults[i][j] = false; } } pasteColEnd = Math.max(pasteColEnd, thisRowPasteColEnd); @@ -2167,6 +2183,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { } this.scenegraph.updateNextFrame(); + return changedCellResults; } private _changeCellValueToDataSet(col: number, row: number, oldValue: string | number, newValue: string | number) { diff --git a/packages/vtable/src/core/record-helper.ts b/packages/vtable/src/core/record-helper.ts index f920cfc325..c02f7bc86c 100644 --- a/packages/vtable/src/core/record-helper.ts +++ b/packages/vtable/src/core/record-helper.ts @@ -31,6 +31,53 @@ function refreshCustomMergeCellGroups(table: ListTable) { } } +type ChangeCellTargetSnapshot = { + col: number; + row: number; + isHeader: boolean; + recordIndex?: number | number[]; + field?: any; +}; + +function isSameRecordIndex( + sourceRecordIndex: number | number[] | undefined, + currentRecordIndex: number | number[] | undefined +): boolean { + if (Array.isArray(sourceRecordIndex) || Array.isArray(currentRecordIndex)) { + return ( + Array.isArray(sourceRecordIndex) && + Array.isArray(currentRecordIndex) && + sourceRecordIndex.length === currentRecordIndex.length && + sourceRecordIndex.every((value, index) => value === currentRecordIndex[index]) + ); + } + return sourceRecordIndex === currentRecordIndex; +} + +function isTargetCellSnapshotCurrent(table: ListTable, snapshot: ChangeCellTargetSnapshot): boolean { + if (table.isHeader(snapshot.col, snapshot.row) !== snapshot.isHeader) { + return false; + } + if (snapshot.isHeader) { + return true; + } + const recordShowIndex = table.getRecordShowIndexByCell(snapshot.col, snapshot.row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(snapshot.col, snapshot.row); + return isSameRecordIndex(snapshot.recordIndex, recordIndex) && snapshot.field === field; +} + +function areTargetCellSnapshotsCurrent(table: ListTable, snapshots: ChangeCellTargetSnapshot[][]): boolean { + for (let i = 0; i < snapshots.length; i++) { + for (let j = 0; j < snapshots[i].length; j++) { + if (!isTargetCellSnapshotCurrent(table, snapshots[i][j])) { + return false; + } + } + } + return true; +} + /** * 更改单元格数据 会触发change_cell_value事件 * @param col @@ -174,6 +221,7 @@ export async function listTableChangeCellValues( //#region 提前组织好未更改前的数据 const beforeChangeValues: (string | number)[][] = []; const oldValues: (string | number)[][] = []; + const targetSnapshots: ChangeCellTargetSnapshot[][] = []; let cellUpdateType: 'normal' | 'sort' | 'group'; for (let i = 0; i < values.length; i++) { @@ -183,17 +231,26 @@ export async function listTableChangeCellValues( const rowValues = values[i]; const rawRowValues: (string | number)[] = []; const oldRowValues: (string | number)[] = []; + const rowTargetSnapshots: ChangeCellTargetSnapshot[] = []; beforeChangeValues.push(rawRowValues); oldValues.push(oldRowValues); + targetSnapshots.push(rowTargetSnapshots); for (let j = 0; j < rowValues.length; j++) { if (startCol + j > table.colCount - 1) { break; } + const col = startCol + j; + const row = startRow + i; cellUpdateType = getCellUpdateType(startCol + j, startRow + i, table, cellUpdateType); - const beforeChangeValue = table.getCellRawValue(startCol + j, startRow + i); + const beforeChangeValue = table.getCellRawValue(col, row); rawRowValues.push(beforeChangeValue); - const oldValue = table.getCellOriginValue(startCol + j, startRow + i); + const oldValue = table.getCellOriginValue(col, row); oldRowValues.push(oldValue); + const isHeader = table.isHeader(col, row); + const recordShowIndex = table.getRecordShowIndexByCell(col, row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(col, row); + rowTargetSnapshots.push({ col, row, isHeader, recordIndex, field }); } } @@ -240,12 +297,12 @@ export async function listTableChangeCellValues( } } } + if (shouldCancel?.() || !areTargetCellSnapshotsCurrent(table, targetSnapshots)) { + return changedCellResults; + } //#endregion for (let i = 0; i < values.length; i++) { - if (shouldCancel?.()) { - return changedCellResults; - } if (startRow + i > table.rowCount - 1) { break; } @@ -287,9 +344,6 @@ export async function listTableChangeCellValues( } // if ((workOnEditableCell && table.isHasEditorDefine(startCol + j, startRow + i)) || workOnEditableCell === false) { if (isCanChange) { - if (shouldCancel?.()) { - return changedCellResults; - } changedCellResults[i][j] = true; const value = rowValues[j]; const recordShowIndex = table.getRecordShowIndexByCell(startCol + j, startRow + i); @@ -302,7 +356,17 @@ export async function listTableChangeCellValues( if (table.isHeader(startCol + j, startRow + i)) { table.internalProps.layoutMap.updateColumnTitle(startCol + j, startRow + i, value as string); } else { - table.dataSource.changeFieldValue(value, recordShowIndex, field, startCol + j, startRow + i, table); + const changeResult = table.dataSource.changeFieldValue( + value, + recordShowIndex, + field, + startCol + j, + startRow + i, + table + ); + if (isPromise(changeResult)) { + await changeResult; + } } const changedValue = table.getCellOriginValue(startCol + j, startRow + i); if (oldValue !== changedValue && triggerEvent) { diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index da90340fc1..d24368e13f 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -732,7 +732,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { col?: number, row?: number, table?: BaseTableAPI - ): FieldData { + ): FieldData | Promise { if (field === null) { return undefined; } @@ -752,20 +752,21 @@ export class DataSource extends EventTarget implements DataSourceAPI { formatValue = parseFloat(value); } if (isPromise(record)) { - record + return record .then(record => { record[field as string | number] = formatValue; + return formatValue; }) .catch((err: Error) => { console.error('VTable Error:', err); + return undefined; }); + } + if (record) { + record[field] = formatValue; } else { - if (record) { - record[field] = formatValue; - } else { - this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; - this.records[dataIndex as number][field] = formatValue; - } + this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; + this.records[dataIndex as number][field] = formatValue; } } } diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index b7cf28af86..4b890d6a4d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -69,6 +69,7 @@ type EventClipboardData = { hasText?: boolean; readFailed?: boolean; internalFormulaText?: string; + internalFormulaHtmlMarker?: string; }; type SourceCell = { @@ -155,8 +156,10 @@ export class EventManager { private clipboardOperationId: number = 0; private pasteOperationId: number = 0; private pasteWriteInProgress: boolean = false; + private pasteWritePromise: Promise = Promise.resolve(); private clipboardWritePromise: Promise = Promise.resolve(); private lastCopiedClipboardData: EventClipboardData | null = null; + private internalFormulaHtmlMarkerId: number = 0; private fieldKeyIds: WeakMap = new WeakMap(); private fieldKeyId: number = 0; private activeCutState: CutState | null = null; @@ -855,10 +858,6 @@ export class EventManager { const table = this.table; const operationId = ++this.clipboardOperationId; this.pasteOperationId++; - if (this.pasteWriteInProgress) { - e.preventDefault(); - return null; - } if (!isCut) { this.resetCutState(); } @@ -890,19 +889,22 @@ export class EventManager { } const plainClipboardData = data; const internalFormulaText = formulaClipboardData !== plainClipboardData ? formulaClipboardData : undefined; + const internalFormulaHtmlMarker = internalFormulaText ? this.createInternalFormulaHtmlMarker() : undefined; + const clipboardHTML = + dataHTML && internalFormulaHtmlMarker + ? this.attachInternalFormulaHtmlMarker(dataHTML, internalFormulaHtmlMarker) + : dataHTML; const eventClipboardWriteData = hasEventClipboard - ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e, internalFormulaText) + ? this.setCopyDataToEventClipboard( + plainClipboardData, + clipboardHTML, + e, + internalFormulaText, + internalFormulaHtmlMarker + ) : null; if (eventClipboardWriteData) { - copiedClipboardData = await this.ensureQueuedClipboardWrite( - eventClipboardWriteData, - canUseAsyncClipboard, - canWriteRichClipboard, - operationId - ); - if (!copiedClipboardData) { - return null; - } + copiedClipboardData = eventClipboardWriteData; this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); @@ -967,19 +969,20 @@ export class EventManager { return null; } // 尝试使用 ClipboardItem(支持富文本) - if (canWriteRichClipboard && dataHTML) { + if (canWriteRichClipboard && clipboardHTML) { await navigator.clipboard.write([ new ClipboardItem({ - 'text/html': new Blob([dataHTML], { type: 'text/html' }), + 'text/html': new Blob([clipboardHTML], { type: 'text/html' }), 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); return { - html: dataHTML, + html: clipboardHTML, text: plainClipboardData, hasHtml: true, hasText: true, - internalFormulaText + internalFormulaText, + internalFormulaHtmlMarker }; } // 降级到纯文本 @@ -1275,37 +1278,6 @@ export class EventManager { } } - private async ensureQueuedClipboardWrite( - clipboardData: EventClipboardData, - canUseAsyncClipboard: boolean, - canWriteRichClipboard: boolean, - operationId: number - ): Promise { - return this.queueClipboardWrite(async () => { - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } - try { - if (canWriteRichClipboard && clipboardData.hasHtml) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([clipboardData.html], { type: 'text/html' }), - 'text/plain': new Blob([clipboardData.text], { type: 'text/plain' }) - }) - ]); - } else if (canUseAsyncClipboard) { - await navigator.clipboard.writeText(clipboardData.text); - } - } catch (error) { - console.warn('异步同步事件剪贴板数据失败,保留事件剪贴板结果:', error); - } - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } - return clipboardData; - }); - } - private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1344,30 +1316,16 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - return this.isClipboardDataSameByCommonTypes(clipboardData, sourceData); + return this.isClipboardDataSameBySignature(clipboardData, sourceData); } - private isClipboardDataSameByCommonTypes(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { - if (sourceData.hasHtml && !clipboardData.hasHtml) { - return false; - } - if (sourceData.hasText && !clipboardData.hasText) { - return false; - } - let hasComparableType = false; - if (sourceData.hasHtml && clipboardData.hasHtml) { - hasComparableType = true; - if (sourceData.html !== clipboardData.html) { - return false; - } - } - if (sourceData.hasText && clipboardData.hasText) { - hasComparableType = true; - if (sourceData.text !== clipboardData.text) { - return false; - } - } - return hasComparableType; + private isClipboardDataSameBySignature(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { + return ( + sourceData.hasHtml === clipboardData.hasHtml && + sourceData.hasText === clipboardData.hasText && + (!sourceData.hasHtml || sourceData.html === clipboardData.html) && + (!sourceData.hasText || sourceData.text === clipboardData.text) + ); } private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { @@ -1409,11 +1367,20 @@ export class EventManager { } } + private createInternalFormulaHtmlMarker(): string { + return `vtable-formula-${Date.now()}-${++this.internalFormulaHtmlMarkerId}`; + } + + private attachInternalFormulaHtmlMarker(html: string, marker: string): string { + return `${html}`; + } + private setCopyDataToEventClipboard( data: string, dataHTML: string | null, e: KeyboardEvent, - internalFormulaText?: string + internalFormulaText?: string, + internalFormulaHtmlMarker?: string ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { @@ -1428,6 +1395,7 @@ export class EventManager { clipboardData.setData('text/html', dataHTML); copiedClipboardData.html = dataHTML; copiedClipboardData.hasHtml = true; + copiedClipboardData.internalFormulaHtmlMarker = internalFormulaHtmlMarker; } catch (error) { console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); } @@ -1521,10 +1489,6 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { const pasteOperationId = ++this.pasteOperationId; - if (this.pasteWriteInProgress) { - e.preventDefault(); - return; - } const pasteRange = this.getPasteRange(); if (!pasteRange) { return; @@ -1621,13 +1585,29 @@ export class EventManager { const emptyResult = this.getEmptyPasteResult(); const pasteClipboardData = this.getPasteClipboardData(clipboardData, pasteContext); if ( - this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || (table as ListTableAPI).editorManager?.editingEditor ) { return emptyResult; } + if (this.pasteWriteInProgress) { + await this.pasteWritePromise.catch(() => { + // Previous paste failures are handled by their own task. + }); + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { + return emptyResult; + } + } let pasteResult = emptyResult; + let settlePasteWrite: () => void = () => { + return; + }; + this.pasteWritePromise = new Promise(resolve => { + settlePasteWrite = resolve; + }); this.pasteWriteInProgress = true; try { if ((table as ListTableAPI).changeCellValues) { @@ -1663,6 +1643,7 @@ export class EventManager { return pasteResult; } finally { this.pasteWriteInProgress = false; + settlePasteWrite(); if (!pasteContext.cutState && table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } @@ -1677,7 +1658,14 @@ export class EventManager { return clipboardData; } const sourceData = pasteContext.cutState?.clipboardData ?? this.lastCopiedClipboardData; - if (!sourceData?.internalFormulaText || !this.isClipboardDataSameByCommonTypes(clipboardData, sourceData)) { + if ( + !sourceData?.internalFormulaText || + !sourceData.internalFormulaHtmlMarker || + !sourceData.hasHtml || + !clipboardData.hasHtml || + !this.isClipboardDataSameBySignature(clipboardData, sourceData) || + !clipboardData.html.includes(sourceData.internalFormulaHtmlMarker) + ) { return clipboardData; } return { diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index d8f89ef3bd..6e1c48afc4 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -759,7 +759,7 @@ export interface PivotTableAPI extends BaseTableAPI { triggerEvent?: boolean, noTriggerChangeCellValuesEvent?: boolean, shouldCancel?: () => boolean - ) => void; + ) => boolean[][]; /** * 获取行表头全路径 From b90c3f7d3855476b05804428e8b51936d17aa46e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 17:30:15 +0800 Subject: [PATCH 30/65] fix: correct Vue Arco Select triggerProps.contentClass and remove broad arco-select-popup check --- docs/assets/demo-vue/en/edit-data/arco-select-editor.md | 5 ++--- docs/assets/demo-vue/zh/edit-data/arco-select-editor.md | 5 ++--- docs/assets/guide/en/edit/edit_cell.md | 2 +- docs/assets/guide/zh/edit/edit_cell.md | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md index 10d8206404..f58df2d8d5 100644 --- a/docs/assets/demo-vue/en/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/en/edit-data/arco-select-editor.md @@ -67,7 +67,7 @@ class ArcoListEditor { placeholder: 'Select city', modelValue: this.currentValue, triggerProps: { - class: selectEditorPopupClassName + contentClass: selectEditorPopupClassName }, 'onUpdate:modelValue': value => { this.currentValue = value; @@ -125,8 +125,7 @@ class ArcoListEditor { if ( target.classList && (target.classList.contains(selectEditorPopupClassName) || - target.classList.contains('arco-select-vtable') || - target.classList.contains('arco-select-popup')) + target.classList.contains('arco-select-vtable')) ) { return true; } diff --git a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md index 26f27d459b..e1c0a2f5cf 100644 --- a/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md +++ b/docs/assets/demo-vue/zh/edit-data/arco-select-editor.md @@ -67,7 +67,7 @@ class ArcoListEditor { placeholder: 'Select city', modelValue: this.currentValue, triggerProps: { - class: selectEditorPopupClassName + contentClass: selectEditorPopupClassName }, 'onUpdate:modelValue': value => { this.currentValue = value; @@ -124,8 +124,7 @@ class ArcoListEditor { if ( target.classList && (target.classList.contains(selectEditorPopupClassName) || - target.classList.contains('arco-select-vtable') || - target.classList.contains('arco-select-popup')) + target.classList.contains('arco-select-vtable')) ) { return true; } diff --git a/docs/assets/guide/en/edit/edit_cell.md b/docs/assets/guide/en/edit/edit_cell.md index feec4f08a2..3b0bbc049e 100644 --- a/docs/assets/guide/en/edit/edit_cell.md +++ b/docs/assets/guide/en/edit/edit_cell.md @@ -209,7 +209,7 @@ isClickPopUp(target: HTMLElement) { } ``` -Different component libraries expose different props for popup class names. For example, React Arco Select can use `dropdownMenuClassName`, Vue Arco Select can use `triggerProps.class`, and Ant Design Select can use `classNames.popup.root` or `popupClassName` in older versions. See the React and Vue Arco Select custom editor demos for complete examples. +Different component libraries expose different props for popup class names. For example, React Arco Select can use `dropdownMenuClassName`, Vue Arco Select can use `triggerProps.contentClass`, and Ant Design Select can use `classNames.popup.root` or `popupClassName` in older versions. See the React and Vue Arco Select custom editor demos for complete examples. If the third-party component has a close animation after selection, or if its internal implementation affects the order of `onChange` and blur events, update the editor's current value in the component change callback before calling `endEdit`. This prevents `getValue` from reading the old value. diff --git a/docs/assets/guide/zh/edit/edit_cell.md b/docs/assets/guide/zh/edit/edit_cell.md index 7cafa9e484..9b55dcc85b 100644 --- a/docs/assets/guide/zh/edit/edit_cell.md +++ b/docs/assets/guide/zh/edit/edit_cell.md @@ -214,7 +214,7 @@ isClickPopUp(target: HTMLElement) { } ``` -不同组件库暴露的弹层 class 配置项不同,例如 React Arco Select 可以通过 `dropdownMenuClassName` 配置,Vue Arco Select 可以通过 `triggerProps.class` 配置,Ant Design Select 可以通过 `classNames.popup.root` 或旧版本的 `popupClassName` 配置。完整示例可参考 React 和 Vue 的 Arco Select 自定义编辑器 demo。 +不同组件库暴露的弹层 class 配置项不同,例如 React Arco Select 可以通过 `dropdownMenuClassName` 配置,Vue Arco Select 可以通过 `triggerProps.contentClass` 配置,Ant Design Select 可以通过 `classNames.popup.root` 或旧版本的 `popupClassName` 配置。完整示例可参考 React 和 Vue 的 Arco Select 自定义编辑器 demo。 如果第三方组件在选中后存在关闭动画,或者 `onChange` 与失焦事件的触发顺序受组件内部实现影响,需要先在组件的变更回调中更新编辑器当前值,再按需调用 `endEdit` 结束编辑,避免 `getValue` 读取到旧值。 From f08617c2b3b19b3920070b3d62e05ec241266b5e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 14:29:10 +0800 Subject: [PATCH 31/65] fix(vtable): harden custom corner merge cleanup Keep corner merged cells unclipped on default fallback. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../vtable/src/scenegraph/group-creater/cell-helper.ts | 8 +++++--- .../scenegraph/group-creater/cell-type/text-cell.ts | 8 ++++---- packages/vtable/src/scenegraph/layout/update-height.ts | 10 +++++++++- packages/vtable/src/scenegraph/layout/update-width.ts | 10 +++++++++- .../vtable/src/scenegraph/utils/corner-custom-merge.ts | 6 +++++- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index e897e949ff..11a6400017 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -599,7 +599,7 @@ function _generateCustomElementsGroup( // custom merge custom render const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table) + ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table, col, row) : undefined; renderDefault = shouldRenderContent ? customResult.renderDefault : false; } else if (range?.isCustom && !table.isCornerHeader(col, row)) { @@ -633,7 +633,7 @@ function _generateCustomElementsGroup( ); const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table) + ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table, col, row) : undefined; renderDefault = shouldRenderContent ? customResult.renderDefault : false; } @@ -1412,7 +1412,9 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr contentWidth, contentHeight, customMergeRange, - table + table, + col, + row ); if (cellGroup.childrenCount > 0 && customElementsGroup) { diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts index 04e7f8deef..36ed060fc2 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts @@ -43,14 +43,14 @@ export function createCellGroup( textAlign: CanvasTextAlign, textBaseline: CanvasTextBaseline, mayHaveIcon: boolean, - customElementsGroup: VGroup, + customElementsGroup: VGroup | undefined, renderDefault: boolean, cellTheme: IThemeSpec, range: CellRange | undefined, isAsync: boolean ): Group { const isCornerCustomMergeContentCell = - !!customElementsGroup && isCornerCustomMergeRange(range, table) && col === range.end.col && row === range.end.row; + isCornerCustomMergeRange(range, table) && col === range.end.col && row === range.end.row; // The carrier cell must allow the clipped merge container to span internal cell bounds. const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); @@ -124,8 +124,8 @@ export function createCellGroup( } if (isCornerCustomMergeContentCell) { cellGroup.setAttributes({ - width: Math.max(cellGroup.attribute.width, customElementsGroup.attribute.width ?? 0), - height: Math.max(cellGroup.attribute.height, customElementsGroup.attribute.height ?? 0) + width: Math.max(cellGroup.attribute.width, customElementsGroup?.attribute.width ?? 0), + height: Math.max(cellGroup.attribute.height, customElementsGroup?.attribute.height ?? 0) }); } if (renderDefault) { diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 4df8519720..386e22f4d4 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -283,7 +283,15 @@ export function updateCellHeight( ); const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, width, height, mergeRange, scene.table) + ? createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, + scene.table, + col, + row + ) : undefined; renderDefault = shouldRenderContent ? customResult.renderDefault : false; } diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index a86d2eeccd..e62ba1d848 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -446,7 +446,15 @@ function updateCellWidth( ); const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, width, height, mergeRange, scene.table) + ? createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, + scene.table, + col, + row + ) : undefined; renderDefault = shouldRenderContent ? customResult.renderDefault : false; isHeightChange = true; diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index 0fcc2f101a..9c9599fb5f 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -26,7 +26,9 @@ export function createCornerCustomMergeContainer( width: number, height: number, range: CellRange | undefined, - table: BaseTableAPI + table: BaseTableAPI, + col: number, + row: number ): VGroup | undefined { if (!customElementsGroup || !isCornerCustomMergeRange(range, table)) { return customElementsGroup; @@ -43,6 +45,8 @@ export function createCornerCustomMergeContainer( clip: true }); customContainer.name = CUSTOM_CONTAINER_NAME; + customContainer.col = col; + customContainer.row = row; customContainer.appendChild(customElementsGroup); return customContainer as unknown as VGroup; From 1db8cd8f079c7fd22ecbaada041704b496b29f7e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 15:34:52 +0800 Subject: [PATCH 32/65] fix(vtable): guard custom corner merge rendering Avoid creating custom layout roots for non-carrier cells. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../scenegraph/group-creater/cell-helper.ts | 46 ++++++++++++------- .../src/scenegraph/layout/update-height.ts | 42 +++++++++-------- .../src/scenegraph/layout/update-width.ts | 42 +++++++++-------- .../scenegraph/utils/corner-custom-merge.ts | 9 ++-- 4 files changed, 80 insertions(+), 59 deletions(-) diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 11a6400017..bdc4cf3e64 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -595,13 +595,24 @@ function _generateCustomElementsGroup( ) { let customElementsGroup; let renderDefault = true; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); + if (!shouldRenderContent) { + return { + customElementsGroup, + renderDefault: false + }; + } + if (customResult) { // custom merge custom render - const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); - customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table, col, row) - : undefined; - renderDefault = shouldRenderContent ? customResult.renderDefault : false; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + cellWidth, + cellHeight, + range, + table + ); + renderDefault = customResult.renderDefault; } else if (range?.isCustom && !table.isCornerHeader(col, row)) { // 判断不是角头单元格,来兼容corner中设置的customLayout // custom merge && no custom render @@ -618,11 +629,13 @@ function _generateCustomElementsGroup( customLayout = define?.customLayout; } if (customLayout || customRender) { + const customCol = range && isCornerCustomMergeRange(range, table) ? range.start.col : col; + const customRow = range && isCornerCustomMergeRange(range, table) ? range.start.row : row; const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, cellWidth, cellHeight, false, @@ -631,11 +644,14 @@ function _generateCustomElementsGroup( range, table ); - const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, range, table); - customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer(customResult.elementsGroup, cellWidth, cellHeight, range, table, col, row) - : undefined; - renderDefault = shouldRenderContent ? customResult.renderDefault : false; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + cellWidth, + cellHeight, + range, + table + ); + renderDefault = customResult.renderDefault; } } return { @@ -686,7 +702,7 @@ export function updateCell( // cellTheme.group.cornerRadius = getCellCornerRadius(col, row, table); // } - if (customLayout || customRender) { + if ((customLayout || customRender) && shouldRenderCornerCustomMergeContent(col, row, customMergeRange, table)) { customResult = dealWithCustom( customLayout, customRender, @@ -1412,9 +1428,7 @@ export function getCustomCellMergeCustom(col: number, row: number, cellGroup: Gr contentWidth, contentHeight, customMergeRange, - table, - col, - row + table ); if (cellGroup.childrenCount > 0 && customElementsGroup) { diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 386e22f4d4..af51c64138 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -255,24 +255,33 @@ export function updateCellHeight( } } - if (customLayout || customRender) { + const mergeRange = isMergeCellGroup(cell) + ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) + : undefined; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + if (!shouldRenderContent) { + renderDefault = false; + } else if (customLayout || customRender) { // const { autoRowHeight } = table.internalProps; const style = scene.table._getCellStyle(col, row) as ProgressBarStyle; const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); let width = cell.attribute.width; let height = cell.attribute.height; - const mergeRange = isMergeCellGroup(cell) - ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) - : undefined; if (isMergeCellGroup(cell)) { width = scene.table.getColsWidth(cell.mergeStartCol, cell.mergeEndCol); height = scene.table.getRowsHeight(cell.mergeStartRow, cell.mergeEndRow); } + let customCol = col; + let customRow = row; + if (mergeRange && isCornerCustomMergeRange(mergeRange, scene.table)) { + customCol = mergeRange.start.col; + customRow = mergeRange.start.row; + } const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, width, height, false, @@ -281,19 +290,14 @@ export function updateCellHeight( mergeRange, scene.table ); - const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); - customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer( - customResult.elementsGroup, - width, - height, - mergeRange, - scene.table, - col, - row - ) - : undefined; - renderDefault = shouldRenderContent ? customResult.renderDefault : false; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, + scene.table + ); + renderDefault = customResult.renderDefault; } if (cell.childrenCount > 0 && customElementsGroup) { diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index e62ba1d848..6aa45fef52 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -418,24 +418,33 @@ function updateCellWidth( } } - if (customLayout || customRender) { + const mergeRange = isMergeCellGroup(cellGroup) + ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) + : undefined; + const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); + if (!shouldRenderContent) { + renderDefault = false; + } else if (customLayout || customRender) { // const { autoRowHeight } = table.internalProps; const style = scene.table._getCellStyle(col, row) as ProgressBarStyle; const padding = getQuadProps(getProp('padding', style, col, row, scene.table)); let width = cellGroup.attribute.width; let height = cellGroup.attribute.height; - const mergeRange = isMergeCellGroup(cellGroup) - ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) - : undefined; if (isMergeCellGroup(cellGroup)) { width = scene.table.getColsWidth(cellGroup.mergeStartCol, cellGroup.mergeEndCol); height = scene.table.getRowsHeight(cellGroup.mergeStartRow, cellGroup.mergeEndRow); } + let customCol = col; + let customRow = row; + if (mergeRange && isCornerCustomMergeRange(mergeRange, scene.table)) { + customCol = mergeRange.start.col; + customRow = mergeRange.start.row; + } const customResult = dealWithCustom( customLayout, customRender, - col, - row, + customCol, + customRow, width, height, false, @@ -444,19 +453,14 @@ function updateCellWidth( mergeRange, scene.table ); - const shouldRenderContent = shouldRenderCornerCustomMergeContent(col, row, mergeRange, scene.table); - customElementsGroup = shouldRenderContent - ? createCornerCustomMergeContainer( - customResult.elementsGroup, - width, - height, - mergeRange, - scene.table, - col, - row - ) - : undefined; - renderDefault = shouldRenderContent ? customResult.renderDefault : false; + customElementsGroup = createCornerCustomMergeContainer( + customResult.elementsGroup, + width, + height, + mergeRange, + scene.table + ); + renderDefault = customResult.renderDefault; isHeightChange = true; } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index 9c9599fb5f..2a01a34b1c 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -7,6 +7,7 @@ import { Group } from '../graphic/group'; export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { return ( !!range?.isCustom && + (range.start.col !== range.end.col || range.start.row !== range.end.row) && table.isCornerHeader(range.start.col, range.start.row) && table.isCornerHeader(range.end.col, range.end.row) ); @@ -26,9 +27,7 @@ export function createCornerCustomMergeContainer( width: number, height: number, range: CellRange | undefined, - table: BaseTableAPI, - col: number, - row: number + table: BaseTableAPI ): VGroup | undefined { if (!customElementsGroup || !isCornerCustomMergeRange(range, table)) { return customElementsGroup; @@ -45,8 +44,8 @@ export function createCornerCustomMergeContainer( clip: true }); customContainer.name = CUSTOM_CONTAINER_NAME; - customContainer.col = col; - customContainer.row = row; + customContainer.col = range.start.col; + customContainer.row = range.start.row; customContainer.appendChild(customElementsGroup); return customContainer as unknown as VGroup; From ad0315697494f0779043dee53a4e96bddd2f934e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 16:36:28 +0800 Subject: [PATCH 33/65] fix(vtable): preserve corner custom layout during resize Avoid a second carrier-cell refresh after resize rebuilds the custom layout. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../vtable/src/scenegraph/layout/update-height.ts | 15 ++++++++++++--- .../vtable/src/scenegraph/layout/update-width.ts | 8 +++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index af51c64138..ef51cd57f9 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -327,7 +327,15 @@ export function updateCellHeight( // scene.table // ); // } - updateMergeCellContentHeight(cell, distHeight, detaY, scene.table.isAutoRowHeight(row), renderDefault, scene.table); + updateMergeCellContentHeight( + cell, + distHeight, + detaY, + scene.table.isAutoRowHeight(row), + renderDefault, + scene.table, + !customContainer + ); } } @@ -337,7 +345,8 @@ function updateMergeCellContentHeight( detaY: number, autoRowHeight: boolean, renderDefault: boolean, - table: BaseTableAPI + table: BaseTableAPI, + refreshCornerCustomMergeContent = true ) { if (isMergeCellGroup(cellGroup)) { distHeight = 0; @@ -402,7 +411,7 @@ function updateMergeCellContentHeight( } } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); - if (isCornerCustomMergeRange(mergeRange, table)) { + if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); } } else { diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 6aa45fef52..aa57183055 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -483,7 +483,8 @@ function updateCellWidth( detaX, autoRowHeight, renderDefault, - scene.table + scene.table, + !customContainer ); isHeightChange = isHeightChange || cellChange; } @@ -500,7 +501,8 @@ function updateMergeCellContentWidth( detaX: number, autoRowHeight: boolean, renderDefault: boolean, - table: BaseTableAPI + table: BaseTableAPI, + refreshCornerCustomMergeContent = true ) { if (isMergeCellGroup(cellGroup)) { distWidth = 0; @@ -594,7 +596,7 @@ function updateMergeCellContentWidth( } } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); - if (isCornerCustomMergeRange(mergeRange, table)) { + if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); } return isHeightChange; From e1ed11ed7df9e94b3655b76abd0daa2669858b39 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 16:50:39 +0800 Subject: [PATCH 34/65] fix(vtable): refresh corner merge layout safely Clear the old React container before rebuilding the merged corner carrier. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../src/scenegraph/layout/update-height.ts | 5 +++-- .../vtable/src/scenegraph/layout/update-width.ts | 5 +++-- .../src/scenegraph/utils/corner-custom-merge.ts | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index ef51cd57f9..7cce555246 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -21,7 +21,8 @@ import { Factory } from '../../core/factory'; import { createCornerCustomMergeContainer, isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent + shouldRenderCornerCustomMergeContent, + updateCornerCustomMergeContent } from '../utils/corner-custom-merge'; export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, skipTableHeightMap?: boolean) { @@ -412,7 +413,7 @@ function updateMergeCellContentHeight( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); + updateCornerCustomMergeContent(mergeRange, table); } } else { const style = table._getCellStyle(cellGroup.col, cellGroup.row); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index aa57183055..5637eb1643 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -20,7 +20,8 @@ import { updateCellContentWidth } from '../utils/text-icon-layout'; import { createCornerCustomMergeContainer, isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent + shouldRenderCornerCustomMergeContent, + updateCornerCustomMergeContent } from '../utils/corner-custom-merge'; import { computeRowHeight } from './compute-row-height'; import { updateCellHeightForRow } from './update-height'; @@ -597,7 +598,7 @@ function updateMergeCellContentWidth( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - table.scenegraph.updateCellContent(mergeRange.end.col, mergeRange.end.row); + updateCornerCustomMergeContent(mergeRange, table); } return isHeightChange; } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index 2a01a34b1c..faf07ebc8d 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -1,7 +1,7 @@ import type { Group as VGroup } from '@src/vrender'; import type { CellRange } from '../../ts-types'; import type { BaseTableAPI } from '../../ts-types/base-table'; -import { CUSTOM_CONTAINER_NAME } from '../component/custom'; +import { CUSTOM_CONTAINER_NAME, CUSTOM_MERGE_CONTAINER_NAME } from '../component/custom'; import { Group } from '../graphic/group'; export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { @@ -50,3 +50,17 @@ export function createCornerCustomMergeContainer( return customContainer as unknown as VGroup; } + +export function updateCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { + const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); + const customContainer = + cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); + + if (customContainer) { + table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row); + customContainer.removeAllChild(); + cellGroup.removeChild(customContainer); + } + + table.scenegraph.updateCellContent(range.end.col, range.end.row); +} From 2ec2275b921937f28bf1e9f1e8bc9fe60d814938 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 17:03:24 +0800 Subject: [PATCH 35/65] fix(vtable): correct complex corner divider paths Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/demo/en/custom-render/complex-corner.md | 1 + docs/assets/demo/zh/custom-render/complex-corner.md | 1 + packages/vtable/examples/custom/complex-corner.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/assets/demo/en/custom-render/complex-corner.md b/docs/assets/demo/en/custom-render/complex-corner.md index 8d4df3ba88..18fcf213f2 100644 --- a/docs/assets/demo/en/custom-render/complex-corner.md +++ b/docs/assets/demo/en/custom-render/complex-corner.md @@ -116,6 +116,7 @@ const option = { const linePoints = [ { x: 0, y: 0 }, { x: width - 40, y: height }, + { x: 0, y: 0 }, { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, { x: provinceWidth, y: height }, diff --git a/docs/assets/demo/zh/custom-render/complex-corner.md b/docs/assets/demo/zh/custom-render/complex-corner.md index 95aa161557..c14b8794f7 100644 --- a/docs/assets/demo/zh/custom-render/complex-corner.md +++ b/docs/assets/demo/zh/custom-render/complex-corner.md @@ -116,6 +116,7 @@ const option = { const linePoints = [ { x: 0, y: 0 }, { x: width - 40, y: height }, + { x: 0, y: 0 }, { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, { x: provinceWidth, y: height }, diff --git a/packages/vtable/examples/custom/complex-corner.ts b/packages/vtable/examples/custom/complex-corner.ts index 70c586ba30..6701fecb0a 100644 --- a/packages/vtable/examples/custom/complex-corner.ts +++ b/packages/vtable/examples/custom/complex-corner.ts @@ -103,6 +103,7 @@ export function createTable() { const linePoints = [ { x: 0, y: 0 }, { x: width - 40, y: height }, + { x: 0, y: 0 }, { x: provinceWidth + cityWidth + 3, y: height }, { x: 0, y: 0 }, { x: provinceWidth, y: height }, From 3107a0f48a3cea9d00c5d675a1a2494afaf3f599 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 17:57:23 +0800 Subject: [PATCH 36/65] fix(react-vtable): flush custom layout unmounts Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../table-components/custom/custom-layout.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/react-vtable/src/table-components/custom/custom-layout.tsx b/packages/react-vtable/src/table-components/custom/custom-layout.tsx index 9731ded216..31f3f84d6a 100644 --- a/packages/react-vtable/src/table-components/custom/custom-layout.tsx +++ b/packages/react-vtable/src/table-components/custom/custom-layout.tsx @@ -106,7 +106,7 @@ export const CustomLayout: React.FC = (props: PropsWithChildr const key = `${col}-${row}`; if (container.current.has(key)) { const currentContainer = container.current.get(key); - module.reconcilor.updateContainer(null, currentContainer, null); + reconcilorUnmountContainer(module, currentContainer); // group = currentContainer.containerInfo; currentContainer.containerInfo.delete(); container.current.delete(key); @@ -121,7 +121,7 @@ export const CustomLayout: React.FC = (props: PropsWithChildr } container.current.forEach((value, key) => { const currentContainer = value; - module.reconcilor.updateContainer(null, currentContainer, null); + reconcilorUnmountContainer(module, currentContainer); currentContainer.containerInfo.delete(); }); container.current.clear(); @@ -237,6 +237,20 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem // } } +function reconcilorUnmountContainer(module: ReconcilerModule, currentContainer: any) { + const { reconcilor } = module; + const updateContainerSync = (reconcilor as any).updateContainerSync; + if (typeof updateContainerSync === 'function') { + updateContainerSync(null, currentContainer, null); + const flushSyncWork = (reconcilor as any).flushSyncWork; + if (typeof flushSyncWork === 'function') { + flushSyncWork(); + } + return; + } + reconcilor.updateContainer(null, currentContainer, null); +} + function getCellRect(col: number, row: number, table: any) { const range = table.getCellRange(col, row); const rect = table.getCellsRect(range.start.col, range.start.row, range.end.col, range.end.row); From 45e4f589052e1624a2263d20af961fb174e1f98c Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 27 Aug 2026 19:13:37 +0800 Subject: [PATCH 37/65] fix(vtable): stabilize corner custom layout refresh Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../table-components/custom/custom-layout.tsx | 96 ++++++++++++++++--- .../components/react/react-custom-layout.ts | 11 ++- .../src/scenegraph/layout/update-height.ts | 41 ++++++-- .../src/scenegraph/layout/update-width.ts | 28 ++++-- .../scenegraph/utils/corner-custom-merge.ts | 40 +++++++- 5 files changed, 179 insertions(+), 37 deletions(-) diff --git a/packages/react-vtable/src/table-components/custom/custom-layout.tsx b/packages/react-vtable/src/table-components/custom/custom-layout.tsx index 31f3f84d6a..bc74c726dc 100644 --- a/packages/react-vtable/src/table-components/custom/custom-layout.tsx +++ b/packages/react-vtable/src/table-components/custom/custom-layout.tsx @@ -98,19 +98,30 @@ export const CustomLayout: React.FC = (props: PropsWithChildr [children, reportReconcilerError] ); - const removeContainer = useCallback((col: number, row: number) => { + const removeContainer = useCallback((col: number, row: number, afterRemove?: () => void) => { const module = reconcilerModule.current; if (!module) { - return; + return true; } const key = `${col}-${row}`; if (container.current.has(key)) { const currentContainer = container.current.get(key); - reconcilorUnmountContainer(module, currentContainer); - // group = currentContainer.containerInfo; - currentContainer.containerInfo.delete(); - container.current.delete(key); + const syncUnmounted = reconcilorUnmountContainer(module, currentContainer); + const finalizeRemove = () => { + currentContainer.containerInfo.delete(); + container.current.delete(key); + }; + if (syncUnmounted) { + finalizeRemove(); + return true; + } + scheduleAfterReconcilerCommit(module, () => { + finalizeRemove(); + afterRemove?.(); + }); + return false; } + return true; }, []); const removeAllContainer = useCallback(() => { @@ -119,12 +130,25 @@ export const CustomLayout: React.FC = (props: PropsWithChildr container.current.clear(); return; } + const pendingContainers: Array<{ key: string; currentContainer: FiberRoot }> = []; + let canFlushSync = true; container.current.forEach((value, key) => { const currentContainer = value; - reconcilorUnmountContainer(module, currentContainer); - currentContainer.containerInfo.delete(); + canFlushSync = requestReconcilerUnmountContainer(module, currentContainer) && canFlushSync; + pendingContainers.push({ key, currentContainer }); }); - container.current.clear(); + const finalizeRemove = () => { + pendingContainers.forEach(({ key, currentContainer }) => { + currentContainer.containerInfo.delete(); + container.current.delete(key); + }); + }; + + if (canFlushSync && flushReconcilerWork(module)) { + finalizeRemove(); + } else { + scheduleAfterReconcilerCommit(module, finalizeRemove); + } }, []); useLayoutEffect(() => { @@ -237,18 +261,60 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem // } } -function reconcilorUnmountContainer(module: ReconcilerModule, currentContainer: any) { +function reconcilorUnmountContainer(module: ReconcilerModule, currentContainer: any): boolean { + if (!requestReconcilerUnmountContainer(module, currentContainer)) { + return false; + } + return flushReconcilerWork(module); +} + +function requestReconcilerUnmountContainer(module: ReconcilerModule, currentContainer: any): boolean { const { reconcilor } = module; const updateContainerSync = (reconcilor as any).updateContainerSync; if (typeof updateContainerSync === 'function') { - updateContainerSync(null, currentContainer, null); - const flushSyncWork = (reconcilor as any).flushSyncWork; - if (typeof flushSyncWork === 'function') { - flushSyncWork(); + try { + updateContainerSync(null, currentContainer, null); + return true; + } catch { + reconcilor.updateContainer(null, currentContainer, null); + return false; } - return; } reconcilor.updateContainer(null, currentContainer, null); + return false; +} + +function flushReconcilerWork(module: ReconcilerModule): boolean { + const { reconcilor } = module; + const flushSyncWork = (reconcilor as any).flushSyncWork; + if (typeof flushSyncWork === 'function') { + try { + const result = flushSyncWork(); + if (result === false) { + return false; + } + } catch { + return false; + } + } + + const flushPassiveEffects = (reconcilor as any).flushPassiveEffects; + if (typeof flushPassiveEffects === 'function') { + try { + flushPassiveEffects(); + } catch { + return false; + } + } + return true; +} + +function scheduleAfterReconcilerCommit(module: ReconcilerModule, callback: () => void) { + // eslint-disable-next-line no-undef + setTimeout(() => { + flushReconcilerWork(module); + callback(); + }, 0); } function getCellRect(col: number, row: number, table: any) { diff --git a/packages/vtable/src/components/react/react-custom-layout.ts b/packages/vtable/src/components/react/react-custom-layout.ts index 8805a7e89f..dd6d966fcc 100644 --- a/packages/vtable/src/components/react/react-custom-layout.ts +++ b/packages/vtable/src/components/react/react-custom-layout.ts @@ -16,10 +16,10 @@ export class ReactCustomLayout { removeAllContainer: () => void; table: BaseTableAPI; customLayoutFuncCache: Map; - reactRemoveGraphicCache: Map void>; + reactRemoveGraphicCache: Map void) => boolean | void>; reactRemoveAllGraphicCache: Map void>; headerCustomLayoutFuncCache: Map; - headerReactRemoveGraphicCache: Map void>; + headerReactRemoveGraphicCache: Map void) => boolean | void>; headerReactRemoveAllGraphicCache: Map void>; // reactContainerCache: Map>; constructor(table: BaseTableAPI) { @@ -58,7 +58,7 @@ export class ReactCustomLayout { setReactRemoveGraphic( componentId: string, - removeGraphic: (col: number, row: number) => void, + removeGraphic: (col: number, row: number, afterRemove?: () => void) => boolean | void, isHeaderCustomLayout?: boolean ) { if (isHeaderCustomLayout) { @@ -134,7 +134,7 @@ export class ReactCustomLayout { return this.customLayoutFuncCache.get(componentId) || emptyCustomLayout; } - removeCustomCell(col: number, row: number) { + removeCustomCell(col: number, row: number, afterRemove?: () => void): boolean { // const { startInTotal } = this.table.getBodyColumnDefine(col, row) as any; const isHeader = this.table.isHeader(col, row); let removeFun; @@ -148,8 +148,9 @@ export class ReactCustomLayout { removeFun = this.reactRemoveGraphicCache.get(componentId); } if (removeFun) { - removeFun(col, row); + return removeFun(col, row, afterRemove) !== false; } + return true; } clearCache() { diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 7cce555246..6c557929be 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -22,7 +22,7 @@ import { createCornerCustomMergeContainer, isCornerCustomMergeRange, shouldRenderCornerCustomMergeContent, - updateCornerCustomMergeContent + updateCornerCustomMergeContentOnce } from '../utils/corner-custom-merge'; export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, skipTableHeightMap?: boolean) { @@ -31,6 +31,7 @@ export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, s scene.table._setRowHeight(row, scene.table.getRowHeight(row) + detaY, true); } + const refreshedCornerCustomMergeRanges = new Set(); for (let col = 0; col < scene.table.colCount; col++) { const cell = scene.getCell(col, row); if (cell.role === 'empty') { @@ -43,7 +44,16 @@ export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, s const height = cell.attribute.height; // cell.setAttribute('height', height); // (cell.firstChild as Rect).setAttribute('height', cell.attribute.height); - updateCellHeightForRow(scene, cell, col, row, height + detaY, detaY, scene.table.isHeader(col, row)); + updateCellHeightForRow( + scene, + cell, + col, + row, + height + detaY, + detaY, + scene.table.isHeader(col, row), + refreshedCornerCustomMergeRanges + ); scene.updateCellContentWhileResize(col, row); } @@ -89,7 +99,8 @@ export function updateCellHeightForRow( row: number, height: number, detaY: number, - isHeader: boolean + isHeader: boolean, + refreshedCornerCustomMergeRanges?: Set // autoRowHeight: boolean ) { // cell.setAttribute('height', height); @@ -101,7 +112,7 @@ export function updateCellHeightForRow( return; } - updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader); + updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader, refreshedCornerCustomMergeRanges); } export function updateCellHeightForColumn( @@ -125,7 +136,8 @@ export function updateCellHeight( row: number, distHeight: number, detaY: number, - isHeader: boolean + isHeader: boolean, + refreshedCornerCustomMergeRanges?: Set ) { if (cell.attribute.height === distHeight && !cell.needUpdateHeight) { return; @@ -174,7 +186,16 @@ export function updateCellHeight( oldBarCell.release(); // deal with text - updateMergeCellContentHeight(cell, distHeight, detaY, scene.table.isAutoRowHeight(row), true, scene.table); + updateMergeCellContentHeight( + cell, + distHeight, + detaY, + scene.table.isAutoRowHeight(row), + true, + scene.table, + true, + refreshedCornerCustomMergeRanges + ); } else if (type === 'sparkline') { // 目前先采用重新生成节点的方案 cell.removeAllChild(); @@ -335,7 +356,8 @@ export function updateCellHeight( scene.table.isAutoRowHeight(row), renderDefault, scene.table, - !customContainer + !customContainer, + refreshedCornerCustomMergeRanges ); } } @@ -347,7 +369,8 @@ function updateMergeCellContentHeight( autoRowHeight: boolean, renderDefault: boolean, table: BaseTableAPI, - refreshCornerCustomMergeContent = true + refreshCornerCustomMergeContent = true, + refreshedCornerCustomMergeRanges?: Set ) { if (isMergeCellGroup(cellGroup)) { distHeight = 0; @@ -413,7 +436,7 @@ function updateMergeCellContentHeight( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - updateCornerCustomMergeContent(mergeRange, table); + updateCornerCustomMergeContentOnce(mergeRange, table, refreshedCornerCustomMergeRanges); } } else { const style = table._getCellStyle(cellGroup.col, cellGroup.row); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 5637eb1643..1c51193b99 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -21,7 +21,7 @@ import { createCornerCustomMergeContainer, isCornerCustomMergeRange, shouldRenderCornerCustomMergeContent, - updateCornerCustomMergeContent + updateCornerCustomMergeContentOnce } from '../utils/corner-custom-merge'; import { computeRowHeight } from './compute-row-height'; import { updateCellHeightForRow } from './update-height'; @@ -133,6 +133,7 @@ function updateColunmWidth( scene: Scenegraph ) { let needRerangeRow = false; + const refreshedCornerCustomMergeRanges = new Set(); // const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; const oldColumnWidth = columnGroup?.attribute.width ?? 0; columnGroup?.setAttribute('width', oldColumnWidth + detaX); @@ -147,7 +148,8 @@ function updateColunmWidth( oldColumnWidth + detaX, detaX, mode === 'row-body' ? cell.col < scene.table.rowHeaderLevelCount : true, - scene.table.internalProps.autoWrapText + scene.table.internalProps.autoWrapText, + refreshedCornerCustomMergeRanges ); if (isHeightChange) { const mergeInfo = getCellMergeInfo(scene.table, cell.col, cell.row); @@ -252,7 +254,8 @@ function updateCellWidth( detaX: number, isHeader: boolean, // autoColWidth: boolean, - autoWrapText: boolean + autoWrapText: boolean, + refreshedCornerCustomMergeRanges?: Set ): boolean { if (cell.attribute.width === distWidth && !cell.needUpdateWidth) { return false; @@ -314,7 +317,16 @@ function updateCellWidth( oldBarCell.release(); // deal width text - const cellChange = updateMergeCellContentWidth(cellGroup, distWidth, detaX, autoRowHeight, true, scene.table); + const cellChange = updateMergeCellContentWidth( + cellGroup, + distWidth, + detaX, + autoRowHeight, + true, + scene.table, + true, + refreshedCornerCustomMergeRanges + ); isHeightChange = isHeightChange || cellChange; } else if (type === 'sparkline') { // 目前先采用重新生成节点的方案 @@ -485,7 +497,8 @@ function updateCellWidth( autoRowHeight, renderDefault, scene.table, - !customContainer + !customContainer, + refreshedCornerCustomMergeRanges ); isHeightChange = isHeightChange || cellChange; } @@ -503,7 +516,8 @@ function updateMergeCellContentWidth( autoRowHeight: boolean, renderDefault: boolean, table: BaseTableAPI, - refreshCornerCustomMergeContent = true + refreshCornerCustomMergeContent = true, + refreshedCornerCustomMergeRanges?: Set ) { if (isMergeCellGroup(cellGroup)) { distWidth = 0; @@ -598,7 +612,7 @@ function updateMergeCellContentWidth( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - updateCornerCustomMergeContent(mergeRange, table); + updateCornerCustomMergeContentOnce(mergeRange, table, refreshedCornerCustomMergeRanges); } return isHeightChange; } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index faf07ebc8d..1efbd8b021 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -52,12 +52,50 @@ export function createCornerCustomMergeContainer( } export function updateCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { + updateCornerCustomMergeContentOnce(range, table); +} + +export function updateCornerCustomMergeContentOnce( + range: CellRange, + table: BaseTableAPI, + refreshedRanges?: Set +): void { + if (refreshedRanges) { + const rangeKey = `${range.start.col}-${range.start.row}-${range.end.col}-${range.end.row}`; + if (refreshedRanges.has(rangeKey)) { + return; + } + refreshedRanges.add(rangeKey); + } + + refreshCornerCustomMergeContent(range, table); +} + +function refreshCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { + const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); + const customContainer = + cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); + + if (customContainer) { + const removed = table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row, () => { + removeCornerCustomContainerAndUpdate(range, table); + }); + if (removed === false) { + return; + } + removeCornerCustomContainerAndUpdate(range, table); + return; + } + + table.scenegraph.updateCellContent(range.end.col, range.end.row); +} + +function removeCornerCustomContainerAndUpdate(range: CellRange, table: BaseTableAPI): void { const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); const customContainer = cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); if (customContainer) { - table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row); customContainer.removeAllChild(); cellGroup.removeChild(customContainer); } From 8e565ff2015a0ae4c22e7071a5d4172f5ff683ee Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 28 Aug 2026 10:48:45 +0800 Subject: [PATCH 38/65] fix: stabilize react custom layout refresh Keep React custom layout teardown synchronous across refresh paths. Avoid deferred cleanup deleting newly mounted containers. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../table-components/custom/custom-layout.tsx | 74 +++++++------------ .../components/react/react-custom-layout.ts | 11 ++- .../src/scenegraph/layout/update-height.ts | 21 ++++-- .../src/scenegraph/layout/update-width.ts | 36 ++++++--- packages/vtable/src/scenegraph/scenegraph.ts | 25 ++++++- .../scenegraph/utils/corner-custom-merge.ts | 20 +---- .../src/state/resize/update-resize-column.ts | 36 +++++++-- .../src/state/resize/update-resize-row.ts | 23 ++++-- 8 files changed, 139 insertions(+), 107 deletions(-) diff --git a/packages/react-vtable/src/table-components/custom/custom-layout.tsx b/packages/react-vtable/src/table-components/custom/custom-layout.tsx index bc74c726dc..7f3f904a47 100644 --- a/packages/react-vtable/src/table-components/custom/custom-layout.tsx +++ b/packages/react-vtable/src/table-components/custom/custom-layout.tsx @@ -98,30 +98,20 @@ export const CustomLayout: React.FC = (props: PropsWithChildr [children, reportReconcilerError] ); - const removeContainer = useCallback((col: number, row: number, afterRemove?: () => void) => { + const removeContainer = useCallback((col: number, row: number) => { const module = reconcilerModule.current; if (!module) { - return true; + return; } const key = `${col}-${row}`; if (container.current.has(key)) { const currentContainer = container.current.get(key); - const syncUnmounted = reconcilorUnmountContainer(module, currentContainer); - const finalizeRemove = () => { + reconcilorUnmountContainer(module, currentContainer); + if (container.current.get(key) === currentContainer) { currentContainer.containerInfo.delete(); container.current.delete(key); - }; - if (syncUnmounted) { - finalizeRemove(); - return true; } - scheduleAfterReconcilerCommit(module, () => { - finalizeRemove(); - afterRemove?.(); - }); - return false; } - return true; }, []); const removeAllContainer = useCallback(() => { @@ -130,25 +120,20 @@ export const CustomLayout: React.FC = (props: PropsWithChildr container.current.clear(); return; } - const pendingContainers: Array<{ key: string; currentContainer: FiberRoot }> = []; - let canFlushSync = true; - container.current.forEach((value, key) => { - const currentContainer = value; - canFlushSync = requestReconcilerUnmountContainer(module, currentContainer) && canFlushSync; - pendingContainers.push({ key, currentContainer }); + const pendingContainers = Array.from(container.current.entries()); + batchReconcilerUpdates(module, () => { + pendingContainers.forEach(([, currentContainer]) => { + requestReconcilerUnmountContainer(module, currentContainer); + }); }); - const finalizeRemove = () => { - pendingContainers.forEach(({ key, currentContainer }) => { + flushReconcilerWork(module); + + pendingContainers.forEach(([key, currentContainer]) => { + if (container.current.get(key) === currentContainer) { currentContainer.containerInfo.delete(); container.current.delete(key); - }); - }; - - if (canFlushSync && flushReconcilerWork(module)) { - finalizeRemove(); - } else { - scheduleAfterReconcilerCommit(module, finalizeRemove); - } + } + }); }, []); useLayoutEffect(() => { @@ -243,10 +228,7 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem const updateContainerSync = (reconcilor as any).updateContainerSync; if (typeof updateContainerSync === 'function') { updateContainerSync(element, currentContainer, null); - const flushSyncWork = (reconcilor as any).flushSyncWork; - if (typeof flushSyncWork === 'function') { - flushSyncWork(); - } + flushReconcilerWork(module); return; } reconcilor.updateContainer(element, currentContainer, null); @@ -262,26 +244,23 @@ function reconcilorUpdateContainer(module: ReconcilerModule, children: ReactElem } function reconcilorUnmountContainer(module: ReconcilerModule, currentContainer: any): boolean { - if (!requestReconcilerUnmountContainer(module, currentContainer)) { - return false; - } + requestReconcilerUnmountContainer(module, currentContainer); return flushReconcilerWork(module); } -function requestReconcilerUnmountContainer(module: ReconcilerModule, currentContainer: any): boolean { +function requestReconcilerUnmountContainer(module: ReconcilerModule, currentContainer: any) { const { reconcilor } = module; const updateContainerSync = (reconcilor as any).updateContainerSync; if (typeof updateContainerSync === 'function') { try { updateContainerSync(null, currentContainer, null); - return true; + return; } catch { reconcilor.updateContainer(null, currentContainer, null); - return false; + return; } } reconcilor.updateContainer(null, currentContainer, null); - return false; } function flushReconcilerWork(module: ReconcilerModule): boolean { @@ -290,7 +269,7 @@ function flushReconcilerWork(module: ReconcilerModule): boolean { if (typeof flushSyncWork === 'function') { try { const result = flushSyncWork(); - if (result === false) { + if (result === true) { return false; } } catch { @@ -309,12 +288,13 @@ function flushReconcilerWork(module: ReconcilerModule): boolean { return true; } -function scheduleAfterReconcilerCommit(module: ReconcilerModule, callback: () => void) { - // eslint-disable-next-line no-undef - setTimeout(() => { - flushReconcilerWork(module); +function batchReconcilerUpdates(module: ReconcilerModule, callback: () => void) { + const batchedUpdates = (module.reconcilor as any).batchedUpdates; + if (typeof batchedUpdates === 'function') { + batchedUpdates(callback); + } else { callback(); - }, 0); + } } function getCellRect(col: number, row: number, table: any) { diff --git a/packages/vtable/src/components/react/react-custom-layout.ts b/packages/vtable/src/components/react/react-custom-layout.ts index dd6d966fcc..8805a7e89f 100644 --- a/packages/vtable/src/components/react/react-custom-layout.ts +++ b/packages/vtable/src/components/react/react-custom-layout.ts @@ -16,10 +16,10 @@ export class ReactCustomLayout { removeAllContainer: () => void; table: BaseTableAPI; customLayoutFuncCache: Map; - reactRemoveGraphicCache: Map void) => boolean | void>; + reactRemoveGraphicCache: Map void>; reactRemoveAllGraphicCache: Map void>; headerCustomLayoutFuncCache: Map; - headerReactRemoveGraphicCache: Map void) => boolean | void>; + headerReactRemoveGraphicCache: Map void>; headerReactRemoveAllGraphicCache: Map void>; // reactContainerCache: Map>; constructor(table: BaseTableAPI) { @@ -58,7 +58,7 @@ export class ReactCustomLayout { setReactRemoveGraphic( componentId: string, - removeGraphic: (col: number, row: number, afterRemove?: () => void) => boolean | void, + removeGraphic: (col: number, row: number) => void, isHeaderCustomLayout?: boolean ) { if (isHeaderCustomLayout) { @@ -134,7 +134,7 @@ export class ReactCustomLayout { return this.customLayoutFuncCache.get(componentId) || emptyCustomLayout; } - removeCustomCell(col: number, row: number, afterRemove?: () => void): boolean { + removeCustomCell(col: number, row: number) { // const { startInTotal } = this.table.getBodyColumnDefine(col, row) as any; const isHeader = this.table.isHeader(col, row); let removeFun; @@ -148,9 +148,8 @@ export class ReactCustomLayout { removeFun = this.reactRemoveGraphicCache.get(componentId); } if (removeFun) { - return removeFun(col, row, afterRemove) !== false; + removeFun(col, row); } - return true; } clearCache() { diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 6c557929be..2e7f9c60ce 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -25,13 +25,18 @@ import { updateCornerCustomMergeContentOnce } from '../utils/corner-custom-merge'; -export function updateRowHeight(scene: Scenegraph, row: number, detaY: number, skipTableHeightMap?: boolean) { +export function updateRowHeight( + scene: Scenegraph, + row: number, + detaY: number, + skipTableHeightMap?: boolean, + refreshedCornerCustomMergeRanges = new Set() +) { // 更新table行高存储 if (!skipTableHeightMap && detaY) { scene.table._setRowHeight(row, scene.table.getRowHeight(row) + detaY, true); } - const refreshedCornerCustomMergeRanges = new Set(); for (let col = 0; col < scene.table.colCount; col++) { const cell = scene.getCell(col, row); if (cell.role === 'empty') { @@ -226,9 +231,15 @@ export function updateCellHeight( (cell.getChildByName(CUSTOM_CONTAINER_NAME) as Group) || (cell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); if (customContainer) { - // if (scene.table.reactCustomLayout) { - // scene.table.reactCustomLayout.removeCustomCell(col, row); - // } + if (scene.table.reactCustomLayout) { + const mergeRange = isMergeCellGroup(cell) + ? scene.table.getCellRange(cell.mergeStartCol, cell.mergeStartRow) + : undefined; + const shouldUseMergeStart = mergeRange && isCornerCustomMergeRange(mergeRange, scene.table); + const removeCol = shouldUseMergeStart ? mergeRange.start.col : col; + const removeRow = shouldUseMergeStart ? mergeRange.start.row : row; + scene.table.reactCustomLayout.removeCustomCell(removeCol, removeRow); + } // customContainer.removeAllChild(); let customElementsGroup; cell.removeChild(customContainer); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 1c51193b99..ea3eef6f39 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -37,7 +37,13 @@ import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; * @param {number} detaX * @return {*} */ -export function updateColWidth(scene: Scenegraph, col: number, detaX: number, skipTableWidthMap?: boolean) { +export function updateColWidth( + scene: Scenegraph, + col: number, + detaX: number, + skipTableWidthMap?: boolean, + refreshedCornerCustomMergeRanges = new Set() +) { if (!skipTableWidthMap) { scene.table._setColWidth(col, scene.table.getColWidth(col) + detaX, true); } @@ -46,33 +52,33 @@ export function updateColWidth(scene: Scenegraph, col: number, detaX: number, sk const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; const rightTopColumn = scene.getColGroupInRightTopCorner(col); if (colOrCornerHeaderColumn && !rightTopColumn) { - updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene); + updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene, refreshedCornerCustomMergeRanges); } // deal with right bottom frozen cells if (rightTopColumn) { - updateColunmWidth(rightTopColumn, detaX, 'right-top', scene); + updateColunmWidth(rightTopColumn, detaX, 'right-top', scene, refreshedCornerCustomMergeRanges); } // deal with row header or body or right frozen cells const rowHeaderOrBodyColumn = scene.getColGroup(col) as Group; if (rowHeaderOrBodyColumn) { - updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene); + updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene, refreshedCornerCustomMergeRanges); } const leftBottomColumn = scene.getColGroupInLeftBottomCorner(col); // deal with left bottom frozen cells if (leftBottomColumn) { - updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene); + updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene, refreshedCornerCustomMergeRanges); } // deal with bottom frozen cells const bottomColumn = scene.getColGroupInBottom(col); if (bottomColumn) { - updateColunmWidth(bottomColumn, detaX, 'bottom', scene); + updateColunmWidth(bottomColumn, detaX, 'bottom', scene, refreshedCornerCustomMergeRanges); } // deal with right bottom frozen cells const rightBottomColumn = scene.getColGroupInRightBottomCorner(col); if (rightBottomColumn) { - updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene); + updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene, refreshedCornerCustomMergeRanges); } // 更新剩余列位置 @@ -130,10 +136,10 @@ function updateColunmWidth( detaX: number, // autoRowHeight: boolean, mode: 'col-corner' | 'row-body' | 'bottom' | 'left-bottom' | 'right-top' | 'right-bottom', - scene: Scenegraph + scene: Scenegraph, + refreshedCornerCustomMergeRanges: Set ) { let needRerangeRow = false; - const refreshedCornerCustomMergeRanges = new Set(); // const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; const oldColumnWidth = columnGroup?.attribute.width ?? 0; columnGroup?.setAttribute('width', oldColumnWidth + detaX); @@ -380,9 +386,15 @@ function updateCellWidth( (cell.getChildByName(CUSTOM_MERGE_CONTAINER_NAME) as Group); if (customContainer) { // fix for custom component flash - // if (scene.table.reactCustomLayout) { - // scene.table.reactCustomLayout.removeCustomCell(col, row); - // } + if (scene.table.reactCustomLayout) { + const mergeRange = isMergeCellGroup(cellGroup) + ? scene.table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow) + : undefined; + const shouldUseMergeStart = mergeRange && isCornerCustomMergeRange(mergeRange, scene.table); + const removeCol = shouldUseMergeStart ? mergeRange.start.col : col; + const removeRow = shouldUseMergeStart ? mergeRange.start.row : row; + scene.table.reactCustomLayout.removeCustomCell(removeCol, removeRow); + } // customContainer.removeAllChild(); let customElementsGroup; cell.removeChild(customContainer); diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index fb43b590b6..09ce2dedf8 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -512,6 +512,8 @@ export class Scenegraph { } releaseStage() { + this.clear = true; + this._needUpdateContainer = false; const releaseAppRef = this.releaseVRenderAppRef; this.releaseVRenderAppRef = undefined; @@ -994,8 +996,14 @@ export class Scenegraph { * @param {number} detaX 改变的宽度值 * @return {*} */ - updateColWidth(col: number, detaX: number, skipUpdateContainer?: boolean, skipTableWidthMap?: boolean) { - updateColWidth(this, col, Math.round(detaX), skipTableWidthMap); + updateColWidth( + col: number, + detaX: number, + skipUpdateContainer?: boolean, + skipTableWidthMap?: boolean, + refreshedCornerCustomMergeRanges?: Set + ) { + updateColWidth(this, col, Math.round(detaX), skipTableWidthMap, refreshedCornerCustomMergeRanges); // this.updateContainerWidth(col, detaX); if (!skipUpdateContainer) { // this.updateContainerAttrWidthAndX(); @@ -1538,9 +1546,14 @@ export class Scenegraph { } } - updateRowHeight(row: number, detaY: number, skipTableHeightMap?: boolean) { + updateRowHeight( + row: number, + detaY: number, + skipTableHeightMap?: boolean, + refreshedCornerCustomMergeRanges?: Set + ) { detaY = Math.round(detaY); - updateRowHeight(this, row, detaY, skipTableHeightMap); + updateRowHeight(this, row, detaY, skipTableHeightMap, refreshedCornerCustomMergeRanges); this.updateContainerHeight(row, detaY); } updateRowsHeight(rows: number[], detaYs: number[], skipTableHeightMap?: boolean) { @@ -2223,6 +2236,10 @@ export class Scenegraph { if (!this._needUpdateContainer) { this._needUpdateContainer = true; setTimeout(() => { + if (!this._needUpdateContainer || this.clear) { + this._needUpdateContainer = false; + return; + } this.updateContainerSync(updateConfig.needUpdateCellY ?? false); }, 0); } diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index 1efbd8b021..3a2f28d677 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -77,25 +77,7 @@ function refreshCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); if (customContainer) { - const removed = table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row, () => { - removeCornerCustomContainerAndUpdate(range, table); - }); - if (removed === false) { - return; - } - removeCornerCustomContainerAndUpdate(range, table); - return; - } - - table.scenegraph.updateCellContent(range.end.col, range.end.row); -} - -function removeCornerCustomContainerAndUpdate(range: CellRange, table: BaseTableAPI): void { - const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); - const customContainer = - cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); - - if (customContainer) { + table.reactCustomLayout?.removeCustomCell(range.start.col, range.start.row); customContainer.removeAllChild(); cellGroup.removeChild(customContainer); } diff --git a/packages/vtable/src/state/resize/update-resize-column.ts b/packages/vtable/src/state/resize/update-resize-column.ts index 3c7add4245..fe8a31d7fa 100644 --- a/packages/vtable/src/state/resize/update-resize-column.ts +++ b/packages/vtable/src/state/resize/update-resize-column.ts @@ -133,19 +133,38 @@ export function updateResizeColumn(xInTable: number, yInTable: number, state: St } function updateResizeColForColumn(detaX: number, state: StateManager) { + const refreshedCornerCustomMergeRanges = new Set(); if (state.table.widthMode === 'adaptive' && state.columnResize.col < state.table.colCount - 1) { // in adaptive mode, the right column width can not be negative // const rightColWidth = state.table.getColWidth(state.columnResize.col + 1); // if (rightColWidth - detaX < 0) { // detaX = rightColWidth; // } - state.table.scenegraph.updateColWidth(state.columnResize.col, detaX); - state.table.scenegraph.updateColWidth(state.columnResize.col + 1, -detaX); + state.table.scenegraph.updateColWidth( + state.columnResize.col, + detaX, + undefined, + undefined, + refreshedCornerCustomMergeRanges + ); + state.table.scenegraph.updateColWidth( + state.columnResize.col + 1, + -detaX, + undefined, + undefined, + refreshedCornerCustomMergeRanges + ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); state.table.internalProps._widthResizedColMap.add(state.columnResize.col + 1); } else { - state.table.scenegraph.updateColWidth(state.columnResize.col, detaX); + state.table.scenegraph.updateColWidth( + state.columnResize.col, + detaX, + undefined, + undefined, + refreshedCornerCustomMergeRanges + ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); } } @@ -153,6 +172,7 @@ function updateResizeColForColumn(detaX: number, state: StateManager) { function updateResizeColForAll(detaX: number, state: StateManager) { // 全列调整 const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; + const refreshedCornerCustomMergeRanges = new Set(); for (let col = state.table.frozenColCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -162,7 +182,7 @@ function updateResizeColForAll(detaX: number, state: StateManager) { } } // state.table.setColWidth(col, afterSize); - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } } @@ -180,16 +200,17 @@ function updateResizeColForIndicator(detaX: number, state: StateManager) { resizeDimensionKey = headerPath.dimensionKey; resizeDimensionValue = headerPath.value; } + const refreshedCornerCustomMergeRanges = new Set(); for (let col = state.table.rowHeaderLevelCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { const indicatorKey = layout.getIndicatorKey(col, state.table.columnHeaderLevelCount); if (layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } else if (!layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(col, state.table.columnHeaderLevelCount - 1); const headerPath = headerPaths?.colHeaderPaths[headerPaths.colHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateColWidth(col, detaX); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } } @@ -210,6 +231,7 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalColWidth = state.table.getColsWidth(startCol, endCol); const moveX = detaX; // 纠正moveX 用于指标列均分该值 + const refreshedCornerCustomMergeRanges = new Set(); for (let col = startCol; col <= endCol; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -227,7 +249,7 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { } else if (deltaWidth < 0 && deltaWidth >= -0.5) { deltaWidth = -0.5; } - state.table.scenegraph.updateColWidth(col, deltaWidth); + state.table.scenegraph.updateColWidth(col, deltaWidth, undefined, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } } diff --git a/packages/vtable/src/state/resize/update-resize-row.ts b/packages/vtable/src/state/resize/update-resize-row.ts index 3622b6f58d..a22fa8bfb7 100644 --- a/packages/vtable/src/state/resize/update-resize-row.ts +++ b/packages/vtable/src/state/resize/update-resize-row.ts @@ -69,14 +69,20 @@ export function updateResizeRow(xInTable: number, yInTable: number, state: State } function updateResizeColForRow(detaY: number, state: StateManager) { + const refreshedCornerCustomMergeRanges = new Set(); if (state.table.heightMode === 'adaptive' && state.rowResize.row < state.table.rowCount - 1) { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY); - state.table.scenegraph.updateRowHeight(state.rowResize.row + 1, -detaY); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight( + state.rowResize.row + 1, + -detaY, + undefined, + refreshedCornerCustomMergeRanges + ); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row + 1); } else { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); } } @@ -84,6 +90,7 @@ function updateResizeColForRow(detaY: number, state: StateManager) { function updateResizeColForAll(detaY: number, state: StateManager) { // 全列调整 // const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; + const refreshedCornerCustomMergeRanges = new Set(); for (let row = state.table.frozenRowCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; row++) { // // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -92,7 +99,7 @@ function updateResizeColForAll(detaY: number, state: StateManager) { // continue; // } // } - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } } @@ -110,6 +117,7 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { resizeDimensionKey = headerPath?.dimensionKey; resizeDimensionValue = headerPath?.value; } + const refreshedCornerCustomMergeRanges = new Set(); for ( let row = state.table.columnHeaderLevelCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; @@ -117,13 +125,13 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { ) { const indicatorKey = layout.getIndicatorKey(state.table.rowHeaderLevelCount, row); if (!layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } else if (layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(state.table.rowHeaderLevelCount - 1, row); const headerPath = headerPaths?.rowHeaderPaths?.[headerPaths.rowHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateRowHeight(row, detaY); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } } @@ -144,6 +152,7 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalRowHeight = state.table.getRowsHeight(startRow, endRow); const moveY = detaY; // 纠正moveX 用于指标列均分该值 + const refreshedCornerCustomMergeRanges = new Set(); for (let row = startRow; row <= endRow; row++) { // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -161,7 +170,7 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { } else if (deltaHeight < 0 && deltaHeight >= -0.5) { deltaHeight = -0.51; } - state.table.scenegraph.updateRowHeight(row, deltaHeight); + state.table.scenegraph.updateRowHeight(row, deltaHeight, undefined, refreshedCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } } From 4fa3405a3973163454464745afebce6d9816987e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 28 Aug 2026 11:54:56 +0800 Subject: [PATCH 39/65] fix: defer corner merge refresh during resize Refresh corner custom merge content after grouped row or column resize updates. Use final dimensions instead of the first intermediate resize state. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../src/scenegraph/layout/update-height.ts | 31 +++++++++----- .../src/scenegraph/layout/update-width.ts | 41 +++++++++++-------- packages/vtable/src/scenegraph/scenegraph.ts | 8 ++-- .../scenegraph/utils/corner-custom-merge.ts | 33 ++++++++++----- .../src/state/resize/update-resize-column.ts | 27 +++++++----- .../src/state/resize/update-resize-row.ts | 32 +++++++-------- 6 files changed, 104 insertions(+), 68 deletions(-) diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index 2e7f9c60ce..517622040d 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -19,10 +19,12 @@ import { getCellMergeRange } from '../../tools/merge-range'; import type { ColumnDefine, ListTableConstructorOptions } from '../../ts-types'; import { Factory } from '../../core/factory'; import { + type CornerCustomMergeRangeUpdateMap, createCornerCustomMergeContainer, + flushCornerCustomMergeContentUpdates, isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent, - updateCornerCustomMergeContentOnce + queueCornerCustomMergeContentUpdate, + shouldRenderCornerCustomMergeContent } from '../utils/corner-custom-merge'; export function updateRowHeight( @@ -30,8 +32,11 @@ export function updateRowHeight( row: number, detaY: number, skipTableHeightMap?: boolean, - refreshedCornerCustomMergeRanges = new Set() + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { + const shouldFlushCornerCustomMergeRanges = !pendingCornerCustomMergeRanges; + pendingCornerCustomMergeRanges ??= new Map(); + // 更新table行高存储 if (!skipTableHeightMap && detaY) { scene.table._setRowHeight(row, scene.table.getRowHeight(row) + detaY, true); @@ -57,7 +62,7 @@ export function updateRowHeight( height + detaY, detaY, scene.table.isHeader(col, row), - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); scene.updateCellContentWhileResize(col, row); @@ -91,6 +96,10 @@ export function updateRowHeight( } } } + + if (shouldFlushCornerCustomMergeRanges) { + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, scene.table); + } } /** @@ -105,7 +114,7 @@ export function updateCellHeightForRow( height: number, detaY: number, isHeader: boolean, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap // autoRowHeight: boolean ) { // cell.setAttribute('height', height); @@ -117,7 +126,7 @@ export function updateCellHeightForRow( return; } - updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader, refreshedCornerCustomMergeRanges); + updateCellHeight(scene, cellGroup, col, row, distHeight, detaY, isHeader, pendingCornerCustomMergeRanges); } export function updateCellHeightForColumn( @@ -142,7 +151,7 @@ export function updateCellHeight( distHeight: number, detaY: number, isHeader: boolean, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (cell.attribute.height === distHeight && !cell.needUpdateHeight) { return; @@ -199,7 +208,7 @@ export function updateCellHeight( true, scene.table, true, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); } else if (type === 'sparkline') { // 目前先采用重新生成节点的方案 @@ -368,7 +377,7 @@ export function updateCellHeight( renderDefault, scene.table, !customContainer, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); } } @@ -381,7 +390,7 @@ function updateMergeCellContentHeight( renderDefault: boolean, table: BaseTableAPI, refreshCornerCustomMergeContent = true, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (isMergeCellGroup(cellGroup)) { distHeight = 0; @@ -447,7 +456,7 @@ function updateMergeCellContentHeight( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - updateCornerCustomMergeContentOnce(mergeRange, table, refreshedCornerCustomMergeRanges); + queueCornerCustomMergeContentUpdate(mergeRange, table, pendingCornerCustomMergeRanges); } } else { const style = table._getCellStyle(cellGroup.col, cellGroup.row); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index ea3eef6f39..55d156e3dd 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -18,10 +18,12 @@ import { isMergeCellGroup } from '../utils/is-merge-cell-group'; import { getQuadProps } from '../utils/padding'; import { updateCellContentWidth } from '../utils/text-icon-layout'; import { + type CornerCustomMergeRangeUpdateMap, createCornerCustomMergeContainer, + flushCornerCustomMergeContentUpdates, isCornerCustomMergeRange, - shouldRenderCornerCustomMergeContent, - updateCornerCustomMergeContentOnce + queueCornerCustomMergeContentUpdate, + shouldRenderCornerCustomMergeContent } from '../utils/corner-custom-merge'; import { computeRowHeight } from './compute-row-height'; import { updateCellHeightForRow } from './update-height'; @@ -42,8 +44,11 @@ export function updateColWidth( col: number, detaX: number, skipTableWidthMap?: boolean, - refreshedCornerCustomMergeRanges = new Set() + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { + const shouldFlushCornerCustomMergeRanges = !pendingCornerCustomMergeRanges; + pendingCornerCustomMergeRanges ??= new Map(); + if (!skipTableWidthMap) { scene.table._setColWidth(col, scene.table.getColWidth(col) + detaX, true); } @@ -52,33 +57,33 @@ export function updateColWidth( const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; const rightTopColumn = scene.getColGroupInRightTopCorner(col); if (colOrCornerHeaderColumn && !rightTopColumn) { - updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(colOrCornerHeaderColumn, detaX, 'col-corner', scene, pendingCornerCustomMergeRanges); } // deal with right bottom frozen cells if (rightTopColumn) { - updateColunmWidth(rightTopColumn, detaX, 'right-top', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(rightTopColumn, detaX, 'right-top', scene, pendingCornerCustomMergeRanges); } // deal with row header or body or right frozen cells const rowHeaderOrBodyColumn = scene.getColGroup(col) as Group; if (rowHeaderOrBodyColumn) { - updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(rowHeaderOrBodyColumn, detaX, 'row-body', scene, pendingCornerCustomMergeRanges); } const leftBottomColumn = scene.getColGroupInLeftBottomCorner(col); // deal with left bottom frozen cells if (leftBottomColumn) { - updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(leftBottomColumn, detaX, 'left-bottom', scene, pendingCornerCustomMergeRanges); } // deal with bottom frozen cells const bottomColumn = scene.getColGroupInBottom(col); if (bottomColumn) { - updateColunmWidth(bottomColumn, detaX, 'bottom', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(bottomColumn, detaX, 'bottom', scene, pendingCornerCustomMergeRanges); } // deal with right bottom frozen cells const rightBottomColumn = scene.getColGroupInRightBottomCorner(col); if (rightBottomColumn) { - updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene, refreshedCornerCustomMergeRanges); + updateColunmWidth(rightBottomColumn, detaX, 'right-bottom', scene, pendingCornerCustomMergeRanges); } // 更新剩余列位置 @@ -128,6 +133,10 @@ export function updateColWidth( }); } + if (shouldFlushCornerCustomMergeRanges) { + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, scene.table); + } + // scene.table.setColWidth(col, rowHeaderOrBodyColumn.attribute.width, true); } @@ -137,7 +146,7 @@ function updateColunmWidth( // autoRowHeight: boolean, mode: 'col-corner' | 'row-body' | 'bottom' | 'left-bottom' | 'right-top' | 'right-bottom', scene: Scenegraph, - refreshedCornerCustomMergeRanges: Set + pendingCornerCustomMergeRanges: CornerCustomMergeRangeUpdateMap ) { let needRerangeRow = false; // const colOrCornerHeaderColumn = scene.getColGroup(col, true) as Group; @@ -155,7 +164,7 @@ function updateColunmWidth( detaX, mode === 'row-body' ? cell.col < scene.table.rowHeaderLevelCount : true, scene.table.internalProps.autoWrapText, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); if (isHeightChange) { const mergeInfo = getCellMergeInfo(scene.table, cell.col, cell.row); @@ -261,7 +270,7 @@ function updateCellWidth( isHeader: boolean, // autoColWidth: boolean, autoWrapText: boolean, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ): boolean { if (cell.attribute.width === distWidth && !cell.needUpdateWidth) { return false; @@ -331,7 +340,7 @@ function updateCellWidth( true, scene.table, true, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); isHeightChange = isHeightChange || cellChange; } else if (type === 'sparkline') { @@ -510,7 +519,7 @@ function updateCellWidth( renderDefault, scene.table, !customContainer, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); isHeightChange = isHeightChange || cellChange; } @@ -529,7 +538,7 @@ function updateMergeCellContentWidth( renderDefault: boolean, table: BaseTableAPI, refreshCornerCustomMergeContent = true, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: CornerCustomMergeRangeUpdateMap ) { if (isMergeCellGroup(cellGroup)) { distWidth = 0; @@ -624,7 +633,7 @@ function updateMergeCellContentWidth( } const mergeRange = table.getCellRange(cellGroup.mergeStartCol, cellGroup.mergeStartRow); if (refreshCornerCustomMergeContent && isCornerCustomMergeRange(mergeRange, table)) { - updateCornerCustomMergeContentOnce(mergeRange, table, refreshedCornerCustomMergeRanges); + queueCornerCustomMergeContentUpdate(mergeRange, table, pendingCornerCustomMergeRanges); } return isHeightChange; } diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index 09ce2dedf8..f2a18bbad8 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -1001,9 +1001,9 @@ export class Scenegraph { detaX: number, skipUpdateContainer?: boolean, skipTableWidthMap?: boolean, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: Map ) { - updateColWidth(this, col, Math.round(detaX), skipTableWidthMap, refreshedCornerCustomMergeRanges); + updateColWidth(this, col, Math.round(detaX), skipTableWidthMap, pendingCornerCustomMergeRanges); // this.updateContainerWidth(col, detaX); if (!skipUpdateContainer) { // this.updateContainerAttrWidthAndX(); @@ -1550,10 +1550,10 @@ export class Scenegraph { row: number, detaY: number, skipTableHeightMap?: boolean, - refreshedCornerCustomMergeRanges?: Set + pendingCornerCustomMergeRanges?: Map ) { detaY = Math.round(detaY); - updateRowHeight(this, row, detaY, skipTableHeightMap, refreshedCornerCustomMergeRanges); + updateRowHeight(this, row, detaY, skipTableHeightMap, pendingCornerCustomMergeRanges); this.updateContainerHeight(row, detaY); } updateRowsHeight(rows: number[], detaYs: number[], skipTableHeightMap?: boolean) { diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index 3a2f28d677..b28c51d30e 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -4,6 +4,8 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; import { CUSTOM_CONTAINER_NAME, CUSTOM_MERGE_CONTAINER_NAME } from '../component/custom'; import { Group } from '../graphic/group'; +export type CornerCustomMergeRangeUpdateMap = Map; + export function isCornerCustomMergeRange(range: CellRange | undefined, table: BaseTableAPI): boolean { return ( !!range?.isCustom && @@ -52,23 +54,34 @@ export function createCornerCustomMergeContainer( } export function updateCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { - updateCornerCustomMergeContentOnce(range, table); + refreshCornerCustomMergeContent(range, table); } -export function updateCornerCustomMergeContentOnce( +export function queueCornerCustomMergeContentUpdate( range: CellRange, table: BaseTableAPI, - refreshedRanges?: Set + pendingRanges?: CornerCustomMergeRangeUpdateMap ): void { - if (refreshedRanges) { - const rangeKey = `${range.start.col}-${range.start.row}-${range.end.col}-${range.end.row}`; - if (refreshedRanges.has(rangeKey)) { - return; - } - refreshedRanges.add(rangeKey); + if (!pendingRanges) { + refreshCornerCustomMergeContent(range, table); + return; } - refreshCornerCustomMergeContent(range, table); + pendingRanges.set(getCornerCustomMergeRangeKey(range), range); +} + +export function flushCornerCustomMergeContentUpdates( + pendingRanges: CornerCustomMergeRangeUpdateMap, + table: BaseTableAPI +): void { + pendingRanges.forEach(range => { + refreshCornerCustomMergeContent(range, table); + }); + pendingRanges.clear(); +} + +function getCornerCustomMergeRangeKey(range: CellRange): string { + return `${range.start.col}-${range.start.row}-${range.end.col}-${range.end.row}`; } function refreshCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): void { diff --git a/packages/vtable/src/state/resize/update-resize-column.ts b/packages/vtable/src/state/resize/update-resize-column.ts index fe8a31d7fa..7d7c7cf41e 100644 --- a/packages/vtable/src/state/resize/update-resize-column.ts +++ b/packages/vtable/src/state/resize/update-resize-column.ts @@ -1,5 +1,6 @@ import type { ListTable } from '../../ListTable'; import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; +import { flushCornerCustomMergeContentUpdates } from '../../scenegraph/utils/corner-custom-merge'; import type { IndicatorData } from '../../ts-types/list-table/layout-map/api'; import type { StateManager } from '../state'; @@ -133,7 +134,7 @@ export function updateResizeColumn(xInTable: number, yInTable: number, state: St } function updateResizeColForColumn(detaX: number, state: StateManager) { - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); if (state.table.widthMode === 'adaptive' && state.columnResize.col < state.table.colCount - 1) { // in adaptive mode, the right column width can not be negative // const rightColWidth = state.table.getColWidth(state.columnResize.col + 1); @@ -145,14 +146,14 @@ function updateResizeColForColumn(detaX: number, state: StateManager) { detaX, undefined, undefined, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); state.table.scenegraph.updateColWidth( state.columnResize.col + 1, -detaX, undefined, undefined, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); @@ -163,16 +164,17 @@ function updateResizeColForColumn(detaX: number, state: StateManager) { detaX, undefined, undefined, - refreshedCornerCustomMergeRanges + pendingCornerCustomMergeRanges ); state.table.internalProps._widthResizedColMap.add(state.columnResize.col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForAll(detaX: number, state: StateManager) { // 全列调整 const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for (let col = state.table.frozenColCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -182,9 +184,10 @@ function updateResizeColForAll(detaX: number, state: StateManager) { } } // state.table.setColWidth(col, afterSize); - state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicator(detaX: number, state: StateManager) { @@ -200,21 +203,22 @@ function updateResizeColForIndicator(detaX: number, state: StateManager) { resizeDimensionKey = headerPath.dimensionKey; resizeDimensionValue = headerPath.value; } - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for (let col = state.table.rowHeaderLevelCount; col < state.table.colCount - state.table.rightFrozenColCount; col++) { const indicatorKey = layout.getIndicatorKey(col, state.table.columnHeaderLevelCount); if (layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } else if (!layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(col, state.table.columnHeaderLevelCount - 1); const headerPath = headerPaths?.colHeaderPaths[headerPaths.colHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateColWidth(col, detaX, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } } } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { @@ -231,7 +235,7 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalColWidth = state.table.getColsWidth(startCol, endCol); const moveX = detaX; // 纠正moveX 用于指标列均分该值 - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for (let col = startCol; col <= endCol; col++) { // 是否禁止调整列宽disableColumnResize 对应canResizeColumn的逻辑判断 if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -249,7 +253,8 @@ function updateResizeColForIndicatorGroup(detaX: number, state: StateManager) { } else if (deltaWidth < 0 && deltaWidth >= -0.5) { deltaWidth = -0.5; } - state.table.scenegraph.updateColWidth(col, deltaWidth, undefined, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateColWidth(col, deltaWidth, undefined, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._widthResizedColMap.add(col); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } diff --git a/packages/vtable/src/state/resize/update-resize-row.ts b/packages/vtable/src/state/resize/update-resize-row.ts index a22fa8bfb7..0d61f43790 100644 --- a/packages/vtable/src/state/resize/update-resize-row.ts +++ b/packages/vtable/src/state/resize/update-resize-row.ts @@ -1,5 +1,6 @@ import type { ListTable } from '../../ListTable'; import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; +import { flushCornerCustomMergeContentUpdates } from '../../scenegraph/utils/corner-custom-merge'; import type { IndicatorData } from '../../ts-types/list-table/layout-map/api'; import type { StateManager } from '../state'; @@ -69,28 +70,24 @@ export function updateResizeRow(xInTable: number, yInTable: number, state: State } function updateResizeColForRow(detaY: number, state: StateManager) { - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); if (state.table.heightMode === 'adaptive' && state.rowResize.row < state.table.rowCount - 1) { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, refreshedCornerCustomMergeRanges); - state.table.scenegraph.updateRowHeight( - state.rowResize.row + 1, - -detaY, - undefined, - refreshedCornerCustomMergeRanges - ); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, pendingCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(state.rowResize.row + 1, -detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row + 1); } else { - state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(state.rowResize.row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(state.rowResize.row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForAll(detaY: number, state: StateManager) { // 全列调整 // const layout = state.table.internalProps.layoutMap as PivotHeaderLayoutMap; - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for (let row = state.table.frozenRowCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; row++) { // // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -99,9 +96,10 @@ function updateResizeColForAll(detaY: number, state: StateManager) { // continue; // } // } - state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicator(detaY: number, state: StateManager) { @@ -117,7 +115,7 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { resizeDimensionKey = headerPath?.dimensionKey; resizeDimensionValue = headerPath?.value; } - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for ( let row = state.table.columnHeaderLevelCount; row < state.table.rowCount - state.table.bottomFrozenRowCount; @@ -125,17 +123,18 @@ function updateResizeColForIndicator(detaY: number, state: StateManager) { ) { const indicatorKey = layout.getIndicatorKey(state.table.rowHeaderLevelCount, row); if (!layout.indicatorsAsCol && indicatorKey === resizeIndicatorKey) { - state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } else if (layout.indicatorsAsCol) { const headerPaths = layout.getCellHeaderPaths(state.table.rowHeaderLevelCount - 1, row); const headerPath = headerPaths?.rowHeaderPaths?.[headerPaths.rowHeaderPaths.length - 1]; if (headerPath && resizeDimensionKey === headerPath.dimensionKey && resizeDimensionValue === headerPath.value) { - state.table.scenegraph.updateRowHeight(row, detaY, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(row, detaY, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } } } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { @@ -152,7 +151,7 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { // 计算当前受影响列的总宽度 后面会利用这个计算比例 const totalRowHeight = state.table.getRowsHeight(startRow, endRow); const moveY = detaY; // 纠正moveX 用于指标列均分该值 - const refreshedCornerCustomMergeRanges = new Set(); + const pendingCornerCustomMergeRanges = new Map(); for (let row = startRow; row <= endRow; row++) { // 是否禁止调整列宽disableRowResize 对应canResizeRow的逻辑判断 // if (!(state.table.internalProps.transpose || (state.table.isPivotTable() && !layout.indicatorsAsCol))) { @@ -170,7 +169,8 @@ function updateResizeColForIndicatorGroup(detaY: number, state: StateManager) { } else if (deltaHeight < 0 && deltaHeight >= -0.5) { deltaHeight = -0.51; } - state.table.scenegraph.updateRowHeight(row, deltaHeight, undefined, refreshedCornerCustomMergeRanges); + state.table.scenegraph.updateRowHeight(row, deltaHeight, undefined, pendingCornerCustomMergeRanges); state.table.internalProps._heightResizedRowMap.add(row); } + flushCornerCustomMergeContentUpdates(pendingCornerCustomMergeRanges, state.table); } From a26131ad0f664ca244087fdfb64f506cf6b4d496 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 28 Aug 2026 14:10:29 +0800 Subject: [PATCH 40/65] fix: preserve corner merge react state on resize Keep resize refreshes from unmounting corner custom merge React roots. Reuse the existing root while updating the resized container rect. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../src/scenegraph/utils/corner-custom-merge.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts index b28c51d30e..434eac5545 100644 --- a/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts +++ b/packages/vtable/src/scenegraph/utils/corner-custom-merge.ts @@ -75,7 +75,7 @@ export function flushCornerCustomMergeContentUpdates( table: BaseTableAPI ): void { pendingRanges.forEach(range => { - refreshCornerCustomMergeContent(range, table); + refreshCornerCustomMergeContentForResize(range, table); }); pendingRanges.clear(); } @@ -97,3 +97,15 @@ function refreshCornerCustomMergeContent(range: CellRange, table: BaseTableAPI): table.scenegraph.updateCellContent(range.end.col, range.end.row); } + +function refreshCornerCustomMergeContentForResize(range: CellRange, table: BaseTableAPI): void { + const cellGroup = table.scenegraph.getCell(range.end.col, range.end.row); + const customContainer = + cellGroup.getChildByName(CUSTOM_CONTAINER_NAME) || cellGroup.getChildByName(CUSTOM_MERGE_CONTAINER_NAME); + + if (customContainer) { + cellGroup.removeChild(customContainer); + } + + table.scenegraph.updateCellContent(range.end.col, range.end.row); +} From d6d41859d3a2b35f1f3c8e63cea60bddae38faeb Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 03:10:06 +0800 Subject: [PATCH 41/65] fix: preserve pivot column widths after updateOption --- ...-group-for-first-screen-issue-5298.test.ts | 72 +++++++++++++++++++ .../progress/create-group-for-first-screen.ts | 10 ++- 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts new file mode 100644 index 0000000000..25371dc658 --- /dev/null +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +import { createGroupForFirstScreen } from '../src/scenegraph/group-creater/progress/create-group-for-first-screen'; +import { computeColsWidth } from '../src/scenegraph/layout/compute-col-width'; + +jest.mock('../src/scenegraph/layout/compute-col-width', () => ({ + computeColsWidth: jest.fn() +})); +jest.mock('../src/scenegraph/layout/compute-row-height', () => ({ + computeRowsHeight: jest.fn() +})); +jest.mock('../src/scenegraph/group-creater/column', () => ({ + createColGroup: jest.fn() +})); + +describe('createGroupForFirstScreen issue #5298', () => { + test('recalculates unresized columns when some columns keep manual widths', () => { + const makeGroup = () => ({ + firstChild: null, + lastChild: null, + setAttribute: jest.fn(), + setAttributes: jest.fn() + }); + const table = { + widthMode: 'standard', + heightMode: 'standard', + options: {}, + colCount: 3, + rowCount: 3, + frozenColCount: 1, + frozenRowCount: 1, + rowHeaderLevelCount: 1, + leftRowSeriesNumberCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 0, + isListTable: () => false, + isPivotChart: () => false, + getFrozenColsWidth: () => 0, + getFrozenRowsHeight: () => 0, + scenegraph: { + leftBottomCornerGroup: makeGroup(), + rightTopCornerGroup: makeGroup(), + rightBottomCornerGroup: makeGroup(), + colHeaderGroup: makeGroup(), + rowHeaderGroup: makeGroup(), + bottomFrozenGroup: makeGroup(), + rightFrozenGroup: makeGroup(), + bodyGroup: makeGroup() + }, + internalProps: { + _widthResizedColMap: new Set([0]), + _heightResizedRowMap: new Set() + } + }; + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 2); + }); +}); diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 01b31ddeb1..4014c125c1 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -75,9 +75,13 @@ export function createGroupForFirstScreen( distRow = Math.min(proxy.firstScreenRowLimit - 1, table.rowCount - 1); } let bodyDistRow = Math.min(proxy.bodyBottomRow, distRow - table.bottomFrozenRowCount); - if (table.internalProps._widthResizedColMap.size === 0) { - // compute colums width in first screen - computeColsWidth(table, 0, distColForCompute ?? distCol); + const colEndForCompute = distColForCompute ?? distCol; + const hasUnresizedColumns = Array.from({ length: colEndForCompute + 1 }, (_, col) => + table.internalProps._widthResizedColMap.has(col) + ).some(resized => !resized); + if (hasUnresizedColumns) { + // Compute widths for new columns while preserving manually resized columns. + computeColsWidth(table, 0, colEndForCompute); } if (table.internalProps._heightResizedRowMap.size === 0) { From e45f89ff6ab57a6dbd7e05aa591642ae41efb890 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 03:10:55 +0800 Subject: [PATCH 42/65] docs: update changlog of rush --- ...e-5298-clear-col-width-cache_2026-08-28-00-00.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json diff --git a/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json b/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json new file mode 100644 index 0000000000..5c3ba761db --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: preserve pivot table column widths after updateOption (#5298)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "biukam.w@gmail.com" +} From d1975345a8ef89225d8958081ebfc01258fadcd3 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 10:13:00 +0800 Subject: [PATCH 43/65] fix: avoid allocating resized column scan array Co-Authored-By: Claude Sonnet 4.6 --- ...-group-for-first-screen-issue-5298.test.ts | 104 +++++++++++------- .../progress/create-group-for-first-screen.ts | 10 +- 2 files changed, 74 insertions(+), 40 deletions(-) diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts index 25371dc658..8932109619 100644 --- a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -13,44 +13,47 @@ jest.mock('../src/scenegraph/group-creater/column', () => ({ })); describe('createGroupForFirstScreen issue #5298', () => { + const makeGroup = () => ({ + firstChild: null, + lastChild: null, + setAttribute: jest.fn(), + setAttributes: jest.fn() + }); + + const createMockTable = () => ({ + widthMode: 'standard', + heightMode: 'standard', + options: {}, + colCount: 3, + rowCount: 3, + frozenColCount: 1, + frozenRowCount: 1, + rowHeaderLevelCount: 1, + leftRowSeriesNumberCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 0, + isListTable: () => false, + isPivotChart: () => false, + getFrozenColsWidth: () => 0, + getFrozenRowsHeight: () => 0, + scenegraph: { + leftBottomCornerGroup: makeGroup(), + rightTopCornerGroup: makeGroup(), + rightBottomCornerGroup: makeGroup(), + colHeaderGroup: makeGroup(), + rowHeaderGroup: makeGroup(), + bottomFrozenGroup: makeGroup(), + rightFrozenGroup: makeGroup(), + bodyGroup: makeGroup() + }, + internalProps: { + _widthResizedColMap: new Set([0]), + _heightResizedRowMap: new Set() + } + }); + test('recalculates unresized columns when some columns keep manual widths', () => { - const makeGroup = () => ({ - firstChild: null, - lastChild: null, - setAttribute: jest.fn(), - setAttributes: jest.fn() - }); - const table = { - widthMode: 'standard', - heightMode: 'standard', - options: {}, - colCount: 3, - rowCount: 3, - frozenColCount: 1, - frozenRowCount: 1, - rowHeaderLevelCount: 1, - leftRowSeriesNumberCount: 0, - rightFrozenColCount: 0, - bottomFrozenRowCount: 0, - isListTable: () => false, - isPivotChart: () => false, - getFrozenColsWidth: () => 0, - getFrozenRowsHeight: () => 0, - scenegraph: { - leftBottomCornerGroup: makeGroup(), - rightTopCornerGroup: makeGroup(), - rightBottomCornerGroup: makeGroup(), - colHeaderGroup: makeGroup(), - rowHeaderGroup: makeGroup(), - bottomFrozenGroup: makeGroup(), - rightFrozenGroup: makeGroup(), - bodyGroup: makeGroup() - }, - internalProps: { - _widthResizedColMap: new Set([0]), - _heightResizedRowMap: new Set() - } - }; + const table = createMockTable(); const proxy = { table, firstScreenColLimit: 3, @@ -69,4 +72,31 @@ describe('createGroupForFirstScreen issue #5298', () => { expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 2); }); + + test('stops scanning resized columns after finding an unresized column', () => { + const table = createMockTable(); + table.colCount = 10000; + table.widthMode = 'adaptive'; + table.internalProps._widthResizedColMap = { + has: jest.fn(col => col === 0) + }; + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(table.internalProps._widthResizedColMap.has).toHaveBeenCalledTimes(2); + expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 9999); + }); }); diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 4014c125c1..8f8c44df09 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -76,9 +76,13 @@ export function createGroupForFirstScreen( } let bodyDistRow = Math.min(proxy.bodyBottomRow, distRow - table.bottomFrozenRowCount); const colEndForCompute = distColForCompute ?? distCol; - const hasUnresizedColumns = Array.from({ length: colEndForCompute + 1 }, (_, col) => - table.internalProps._widthResizedColMap.has(col) - ).some(resized => !resized); + let hasUnresizedColumns = false; + for (let col = 0; col <= colEndForCompute; col++) { + if (!table.internalProps._widthResizedColMap.has(col)) { + hasUnresizedColumns = true; + break; + } + } if (hasUnresizedColumns) { // Compute widths for new columns while preserving manually resized columns. computeColsWidth(table, 0, colEndForCompute); From b0ce0998111f7a843b67ea69ce4f0f361e81c28b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 10:22:01 +0800 Subject: [PATCH 44/65] fix: preserve unresized row height recalculation Co-Authored-By: Claude Sonnet 4.6 --- ...-group-for-first-screen-issue-5298.test.ts | 50 +++++++++++++++++++ .../progress/create-group-for-first-screen.ts | 21 +++++--- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts index 8932109619..d173452067 100644 --- a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { createGroupForFirstScreen } from '../src/scenegraph/group-creater/progress/create-group-for-first-screen'; import { computeColsWidth } from '../src/scenegraph/layout/compute-col-width'; +import { computeRowsHeight } from '../src/scenegraph/layout/compute-row-height'; jest.mock('../src/scenegraph/layout/compute-col-width', () => ({ computeColsWidth: jest.fn() @@ -99,4 +100,53 @@ describe('createGroupForFirstScreen issue #5298', () => { expect(table.internalProps._widthResizedColMap.has).toHaveBeenCalledTimes(2); expect(computeColsWidth).toHaveBeenCalledWith(table, 0, 9999); }); + + test('recalculates unresized rows when some rows keep manual heights', () => { + const table = createMockTable(); + table.internalProps._heightResizedRowMap = new Set([0]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).toHaveBeenCalledWith(table, 0, 2); + }); + + test('stops scanning resized rows after finding an unresized row', () => { + const table = createMockTable(); + table.rowCount = 10000; + table.heightMode = 'adaptive'; + table.internalProps._heightResizedRowMap = { + has: jest.fn(row => row === 0) + }; + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(table.internalProps._heightResizedRowMap.has).toHaveBeenCalledTimes(2); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 0, 9999); + }); }); diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 8f8c44df09..99570540d9 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -88,15 +88,20 @@ export function createGroupForFirstScreen( computeColsWidth(table, 0, colEndForCompute); } - if (table.internalProps._heightResizedRowMap.size === 0) { + const rowEndForCompute = + table.options.canvasHeight === 'auto' || table.options.customConfig?.forceComputeAllRowHeight + ? table.rowCount - 1 + : distRowForCompute ?? distRow; + let hasUnresizedRows = false; + for (let row = 0; row <= rowEndForCompute; row++) { + if (!table.internalProps._heightResizedRowMap.has(row)) { + hasUnresizedRows = true; + break; + } + } + if (hasUnresizedRows) { // compute rows height in first screen - computeRowsHeight( - table, - 0, - table.options.canvasHeight === 'auto' || table.options.customConfig?.forceComputeAllRowHeight - ? table.rowCount - 1 - : distRowForCompute ?? distRow - ); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 + computeRowsHeight(table, 0, rowEndForCompute); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 if (table.heightMode === 'autoHeight') { bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); syncVisibleBodyRows(proxy, bodyDistRow); From 5bf93dd656bb13caa554aafb2e12403f391d3274 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 10:28:21 +0800 Subject: [PATCH 45/65] fix: keep autoHeight row sync independent Co-Authored-By: Claude Sonnet 4.6 --- ...-group-for-first-screen-issue-5298.test.ts | 34 +++++++++++++++++++ .../progress/create-group-for-first-screen.ts | 8 ++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts index d173452067..69bc276fa3 100644 --- a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -149,4 +149,38 @@ describe('createGroupForFirstScreen issue #5298', () => { expect(table.internalProps._heightResizedRowMap.has).toHaveBeenCalledTimes(2); expect(computeRowsHeight).toHaveBeenCalledWith(table, 0, 9999); }); + + test('syncs autoHeight visible rows even when all first screen rows keep manual heights', () => { + const table = createMockTable(); + table.heightMode = 'autoHeight'; + table.rowCount = 5; + table.tableNoFrameHeight = 100; + table.getBottomFrozenRowsHeight = () => 0; + table.getRowsHeight = jest.fn((start, end) => (end < 4 ? 50 : 120)); + table.internalProps._heightResizedRowMap = new Set([0, 1, 2]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + bodyBottomRow: 4, + bodyTopRow: 1, + totalActualBodyRowCount: 2, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).not.toHaveBeenCalledWith(table, 0, 2); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 3, 3, false); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 4, 4, false); + expect(proxy.totalRow).toBe(4); + expect(proxy.totalActualBodyRowCount).toBe(4); + }); }); diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 99570540d9..139c77f4e3 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -102,10 +102,10 @@ export function createGroupForFirstScreen( if (hasUnresizedRows) { // compute rows height in first screen computeRowsHeight(table, 0, rowEndForCompute); //如果配置了 canvasHeight为 'auto', 则一次性将所有行高都计算出来才能满足后续赋值表格高度的使用 - if (table.heightMode === 'autoHeight') { - bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); - syncVisibleBodyRows(proxy, bodyDistRow); - } + } + if (table.heightMode === 'autoHeight') { + bodyDistRow = fillVisibleBodyRows(proxy, bodyDistRow); + syncVisibleBodyRows(proxy, bodyDistRow); } if (distCol < table.colCount - table.rightFrozenColCount) { From 0665dbe9602756b9a442210472b8adb87bed093f Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 10:49:32 +0800 Subject: [PATCH 46/65] fix: preserve manual row heights during recompute Keep row-height recomputation from touching manually resized rows and avoid using a manual row as the representative height for fixed body rows. Co-Authored-By: Claude Sonnet 4.6 --- ...-group-for-first-screen-issue-5298.test.ts | 96 +++++++++++++++++++ .../progress/create-group-for-first-screen.ts | 4 +- .../scenegraph/layout/compute-row-height.ts | 31 ++++-- 3 files changed, 122 insertions(+), 9 deletions(-) diff --git a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts index 69bc276fa3..0deaeddde3 100644 --- a/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts +++ b/packages/vtable/__tests__/create-group-for-first-screen-issue-5298.test.ts @@ -3,6 +3,10 @@ import { createGroupForFirstScreen } from '../src/scenegraph/group-creater/progr import { computeColsWidth } from '../src/scenegraph/layout/compute-col-width'; import { computeRowsHeight } from '../src/scenegraph/layout/compute-row-height'; +const { computeRowHeight, computeRowsHeight: computeRowsHeightActual } = jest.requireActual( + '../src/scenegraph/layout/compute-row-height' +); + jest.mock('../src/scenegraph/layout/compute-col-width', () => ({ computeColsWidth: jest.fn() })); @@ -183,4 +187,96 @@ describe('createGroupForFirstScreen issue #5298', () => { expect(proxy.totalRow).toBe(4); expect(proxy.totalActualBodyRowCount).toBe(4); }); + + test('does not recompute manually resized rows while filling autoHeight visible rows', () => { + const table = createMockTable(); + table.heightMode = 'autoHeight'; + table.rowCount = 5; + table.tableNoFrameHeight = 100; + table.getBottomFrozenRowsHeight = () => 0; + table.getRowsHeight = jest.fn((start, end) => (end < 4 ? 50 : 120)); + table.internalProps._heightResizedRowMap = new Set([0, 1, 2, 3]); + const proxy = { + table, + firstScreenColLimit: 3, + firstScreenRowLimit: 3, + bodyBottomRow: 4, + bodyTopRow: 1, + totalActualBodyRowCount: 2, + totalCol: 2, + totalRow: 2, + setParamsForRow: jest.fn(), + setParamsForColumn: jest.fn(), + progress: jest.fn(), + colStart: 0, + rowStart: 0 + }; + const groups = [makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup(), makeGroup()]; + + createGroupForFirstScreen(...groups, 0, 0, proxy); + + expect(computeRowsHeight).not.toHaveBeenCalledWith(table, 3, 3, false); + expect(computeRowsHeight).toHaveBeenCalledWith(table, 4, 4, false); + }); + + test('keeps manually resized row height when computing row height directly', () => { + const table = { + internalProps: { + _heightResizedRowMap: new Set([1]) + }, + getRowHeight: jest.fn(() => 42), + isAutoRowHeight: jest.fn() + }; + + expect(computeRowHeight(1, 0, 2, table)).toBe(42); + expect(table.isAutoRowHeight).not.toHaveBeenCalled(); + }); + + test('uses an unresized representative row when filling fixed body row heights', () => { + const setRowHeight = jest.fn(); + const table = { + defaultHeaderRowHeight: 40, + defaultHeaderColWidth: 80, + defaultRowHeight: 33, + heightMode: 'standard', + heightAdaptiveMode: 'all', + autoFillHeight: false, + options: { + defaultRowHeight: 'auto' + }, + rowCount: 4, + colCount: 2, + columnHeaderLevelCount: 1, + rowHeaderLevelCount: 0, + bottomFrozenRowCount: 0, + internalProps: { + _heightResizedRowMap: new Set([1]), + layoutMap: { + getBody: jest.fn(() => ({ + cellType: 'text', + define: {} + })) + }, + transpose: false, + autoWrapText: false, + enableLineBreak: false, + useOneRowHeightFillAll: false + }, + isPivotTable: () => false, + isPivotChart: () => false, + isAutoRowHeight: () => false, + getDefaultRowHeight: jest.fn(row => (row === 1 ? 200 : 33)), + getRowHeight: jest.fn(row => (row === 1 ? 200 : 33)), + _getCellStyle: jest.fn(() => ({})), + _setRowHeight: setRowHeight, + _clearRowRangeHeightsMap: jest.fn() + }; + + computeRowsHeightActual(table, 0, 3); + + expect(table.getDefaultRowHeight).toHaveBeenCalledWith(2); + expect(setRowHeight).not.toHaveBeenCalledWith(1, expect.any(Number)); + expect(setRowHeight).toHaveBeenCalledWith(2, 33); + expect(setRowHeight).toHaveBeenCalledWith(3, 33); + }); }); diff --git a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts index 139c77f4e3..4e16f01836 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/create-group-for-first-screen.ts @@ -14,7 +14,9 @@ function fillVisibleBodyRows(proxy: SceneProxy, distRow: number): number { while (targetRow < bodyBottomRow && table.getRowsHeight(table.frozenRowCount, targetRow) < visibleBodyHeight) { const nextRow = targetRow + 1; - computeRowsHeight(table, nextRow, nextRow, false); + if (!table.internalProps._heightResizedRowMap.has(nextRow)) { + computeRowsHeight(table, nextRow, nextRow, false); + } targetRow = nextRow; } diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index 7ae9074b67..0806eda809 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -139,14 +139,13 @@ export function computeRowsHeight( ) { // check fixed style and no wrap situation, fill all row width single compute // traspose table and row indicator pivot table cannot use single row height - const height = computeRowHeight(table.columnHeaderLevelCount, 0, table.colCount - 1, table); - fillRowsHeight( - height, - table.columnHeaderLevelCount, - table.rowCount - 1 - table.bottomFrozenRowCount, - table, - update ? newHeights : undefined - ); + const fillStartRow = table.columnHeaderLevelCount; + const fillEndRow = table.rowCount - 1 - table.bottomFrozenRowCount; + const rowForCompute = getFirstUnresizedRow(table, fillStartRow, fillEndRow); + if (rowForCompute >= 0) { + const height = computeRowHeight(rowForCompute, 0, table.colCount - 1, table); + fillRowsHeight(height, fillStartRow, fillEndRow, table, update ? newHeights : undefined); + } //底部冻结的行行高需要单独计算 for (let row = table.rowCount - table.bottomFrozenRowCount; row <= rowEnd; row++) { const height = computeRowHeight(row, 0, table.colCount - 1, table); @@ -352,6 +351,10 @@ export function computeRowsHeight( } export function computeRowHeight(row: number, startCol: number, endCol: number, table: BaseTableAPI): number { + if (table.internalProps._heightResizedRowMap.has(row)) { + return table.getRowHeight(row); + } + return computeRowHeightInternal(row, startCol, endCol, table, true); } @@ -607,6 +610,9 @@ function fillRowsHeight( return; } for (let row = startRow; row <= endRow; row++) { + if (table.internalProps._heightResizedRowMap.has(row)) { + continue; + } if (newHeights) { newHeights[row] = height; } else { @@ -616,6 +622,15 @@ function fillRowsHeight( table.internalProps.useOneRowHeightFillAll = true; } +function getFirstUnresizedRow(table: BaseTableAPI, startRow: number, endRow: number): number { + for (let row = startRow; row <= endRow; row++) { + if (!table.internalProps._heightResizedRowMap.has(row)) { + return row; + } + } + return -1; +} + /** * @description: compute customRender height * @param {number} col From bad2312f18d68b17ed1ea1375542826b1528bb22 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 14:45:35 +0800 Subject: [PATCH 47/65] feat: support grouped grand totals by dimension Allow pivot grand totals to retain lower-level dimensions. Preserve existing behavior when the option is omitted. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../en/data-analysis/pivot-analysis-total.md | 3 +- .../zh/data-analysis/pivot-analysis-total.md | 3 +- .../data_analysis/pivot_table_dataAnalysis.md | 3 + .../data_analysis/pivot_table_dataAnalysis.md | 3 + docs/assets/option/en/table/pivotTable.md | 2 + docs/assets/option/zh/table/pivotTable.md | 2 + .../__tests__/pivotTable-analysis.test.ts | 111 ++++ packages/vtable/src/dataset/dataset.ts | 590 ++++++++++-------- packages/vtable/src/ts-types/new-data-set.ts | 2 + 9 files changed, 467 insertions(+), 252 deletions(-) diff --git a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md index a0fdb79b40..278ba495b2 100644 --- a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # Pivot Analysis - Subtotal Total -To summarize table data in pivot analysis, configure totals in dataConfig to set the total subtotal of the row and column dimensions.This example summary is always displayed to the bottom, and can also be displayed to the top through total.showGrandTotalsOnTop. [Configuration reference](../../option/PivotTable#dataConfig.totals) +Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Totals appear at the bottom or on the right by default; use `showGrandTotalsOnTop` to move them to the top or left. [Configuration reference](../../option/PivotTable#dataConfig.totals) ## Key Configurations @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md index 36cb888639..44791e0a3d 100644 --- a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # 透视分析——小计总计 -透视分析表格数据汇总,dataConfig 中配置 totals 来设置行列维度的小计总计。该示例汇总总显示到底部,也可以通过 total.showGrandTotalsOnTop 来显示到顶部。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。汇总默认显示在底部或右侧,也可以通过 `showGrandTotalsOnTop` 调整到顶部或左侧。[配置参考](../../option/PivotTable#dataConfig.totals) ## 关键配置 @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md index 682cfd9b1e..05858ce3d0 100644 --- a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['city'], // Group the grand total by city and append an overall subtotal grandTotalLabel: 'row total', subTotalLabel: 'Subtotal', showGrandTotalsOnTop: true, //totals show on top @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, when the row dimensions are `['province', 'city']`, setting `grandTotalDimensions: ['city']` on the row total displays a total for each city under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. + Online demo:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### Custom summary data diff --git a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md index 4d41ba4c14..1df878dea2 100644 --- a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['city'], // 总计区域继续按 city 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如行维度为 `['province', 'city']` 时,将行总计配置为 `grandTotalDimensions: ['city']`,会在“行总计”下按城市分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 + 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### 自定义汇总数据 diff --git a/docs/assets/option/en/table/pivotTable.md b/docs/assets/option/en/table/pivotTable.md index 43e8cd36e4..d23c35eac1 100644 --- a/docs/assets/option/en/table/pivotTable.md +++ b/docs/assets/option/en/table/pivotTable.md @@ -225,6 +225,8 @@ export interface Total { showSubTotals: boolean; // Subtotal summary dimension definition subTotalsDimensions?: string[]; + // Lower-level dimensions retained in the grand total area for grouped totals. Only dimensions after the first dimension on the current axis are supported. + grandTotalDimensions?: string[]; //Default 'total' grandTotalLabel?: string; //Default 'Subtotal' diff --git a/docs/assets/option/zh/table/pivotTable.md b/docs/assets/option/zh/table/pivotTable.md index 34b21b536b..a201cd5f0d 100644 --- a/docs/assets/option/zh/table/pivotTable.md +++ b/docs/assets/option/zh/table/pivotTable.md @@ -229,6 +229,8 @@ export interface Total { showSubTotals: boolean; // 小计汇总维度定义 subTotalsDimensions?: string[]; + // 总计中保留的下级维度,用于在总计区域内继续按维度分组;仅支持当前行/列方向首层之后的维度 + grandTotalDimensions?: string[]; // 默认'总计' grandTotalLabel?: string; // 默认'小计' diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index ffff4f483a..cd08c14f9c 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -378,3 +378,114 @@ describe('pivotTable-analysis init test', () => { pivotTable.release(); }); }); + +describe('pivotTable grand total grouped by lower-level dimension', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + test('creates grouped grand total headers and values', () => { + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '信用证'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1000); + }); + + test('keeps grouped grand total headers after sorting updates', () => { + pivotTable.updateSortRules([ + { + sortField: 'type', + sortType: VTable.TYPES.SortType.DESC + } + ]); + + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + }); + + test('recalculates grouped and overall grand totals after editing', () => { + pivotTable.dataset.changeTreeNodeValue(['公司一', '银票'], [], 'balance', 150); + + const groupedGrandTotal = pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance'); + expect(groupedGrandTotal.children.map(child => child.value())).toEqual([150, 300]); + expect(groupedGrandTotal.value()).toBe(450); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable column grand total grouped by lower-level dimension', () => { + test('creates grouped column grand total headers and values', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + pivotTable.release(); + }); +}); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index a2f5c516e0..7a5fd81770 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -328,7 +328,9 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel ); } else { this.rowHeaderTree = this.ArrToTree( @@ -342,7 +344,8 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions ); } } @@ -376,7 +379,8 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions ); // } } @@ -1062,7 +1066,9 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1076,7 +1082,8 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions ); } } @@ -1093,7 +1100,8 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1653,63 +1661,73 @@ export class Dataset { } } } - if (that.totals?.column?.showGrandTotals || this.rows.length === 0) { - const flatColTotalKey = that.colGrandTotalLabel; - if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { - // 利用汇总数据替换 - this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; - return; - } - if (!this.tree[flatRowKey][flatColTotalKey]) { - this.tree[flatRowKey][flatColTotalKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find(rule => rule.key === toComputeIndicatorKeys[i]); - if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowKey][flatColTotalKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); - } - } else { - if (!this.tree[flatRowKey][flatColTotalKey][i]) { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== that.colGrandTotalLabel) { + const flatColTotalKeys = this.getGrandTotalKeys( + colKey, + that.columns, + that.totals?.column?.grandTotalDimensions, + that.colGrandTotalLabel, + that.colSubTotalLabel + ); + for (const flatColTotalKey of flatColTotalKeys) { + if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { + // 利用汇总数据替换 + this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; + continue; + } + if (!this.tree[flatRowKey][flatColTotalKey]) { + this.tree[flatRowKey][flatColTotalKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowKey][flatColTotalKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } + } else { + if (!this.tree[flatRowKey][flatColTotalKey][i]) { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } } } } @@ -1815,60 +1833,71 @@ export class Dataset { } } - if (that.totals?.row?.showGrandTotals || this.columns.length === 0) { - const flatRowTotalKey = that.rowGrandTotalLabel; - if (!this.tree[flatRowTotalKey]) { - this.tree[flatRowTotalKey] = {}; - rowTotalKeys.push(flatRowTotalKey); - } - if (!this.tree[flatRowTotalKey][flatColKey]) { - this.tree[flatRowTotalKey][flatColKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (!this.tree[flatRowTotalKey][flatColKey][i]) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find( - rule => rule.key === toComputeIndicatorKeys[i] - ); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowTotalKey][flatColKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } else { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ( + (that.totals?.row?.showGrandTotals || this.columns.length === 0) && + rowKey[0] !== that.rowGrandTotalLabel + ) { + const flatRowTotalKeys = this.getGrandTotalKeys( + rowKey, + that.rows, + that.totals?.row?.grandTotalDimensions, + that.rowGrandTotalLabel, + that.rowSubTotalLabel + ); + for (const flatRowTotalKey of flatRowTotalKeys) { + if (!this.tree[flatRowTotalKey]) { + this.tree[flatRowTotalKey] = {}; + rowTotalKeys.push(flatRowTotalKey); + } + if (!this.tree[flatRowTotalKey][flatColKey]) { + this.tree[flatRowTotalKey][flatColKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (!this.tree[flatRowTotalKey][flatColKey][i]) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowTotalKey][flatColKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } else { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + } + } + if (flatRowTotalKey !== flatRowKey) { + this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } - } - if (flatRowTotalKey !== flatRowKey) { - this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -1928,13 +1957,40 @@ export class Dataset { * @param arr * @returns */ + private getGrandTotalDimensionIndexes(dimensions: string[], grandTotalDimensions?: string[]) { + if (!grandTotalDimensions?.length) { + return []; + } + return grandTotalDimensions + .map(dimension => dimensions.indexOf(dimension)) + .filter((index, position, indexes) => index > 0 && indexes.indexOf(index) === position) + .sort((a, b) => a - b); + } + + private getGrandTotalKeys( + dimensionKey: string[], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + grandTotalLabel: string, + subTotalLabel: string + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if (!dimensionIndexes.length) { + return [grandTotalLabel]; + } + const groupedKey = [grandTotalLabel, ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex])]; + return [join(groupedKey, this.stringJoinChar), join([grandTotalLabel, subTotalLabel], this.stringJoinChar)]; + } + private ArrToTree1( arr: string[][], rows: string[], indicators: (string | IIndicator)[] | undefined, isGrandTotal: boolean, grandTotalLabel: string, - showGrandTotalsOnTop: boolean + showGrandTotalsOnTop: boolean, + grandTotalDimensions?: string[], + subTotalLabel?: string ) { /** * @@ -1991,9 +2047,64 @@ export class Dataset { }); } + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { + return { + indicatorKey: indicator, + value: indicator + }; + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; + arr.forEach(item => addList(item, false)); if (isGrandTotal) { - addList([grandTotalLabel], isGrandTotal); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + if (!grandTotalDimensionIndexes.length) { + addList([grandTotalLabel], isGrandTotal); + } else { + const grandTotalNode: any = { + value: grandTotalLabel, + dimensionKey: rows[0], + children: [], + role: 'grand-total' + }; + const grandTotalMap = new Map(); + arr.forEach(list => { + let parent = grandTotalNode; + const path = [grandTotalLabel]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[dimensionIndex]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + item = { + value, + dimensionKey: rows[dimensionIndex], + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + grandTotalNode.children.push({ + value: subTotalLabel, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + children: createIndicatorChildren() + }); + if (showGrandTotalsOnTop) { + result.unshift(grandTotalNode); + } else { + result.push(grandTotalNode); + } + } } return result; } @@ -2012,7 +2123,8 @@ export class Dataset { grandTotalLabel: string, subTotalLabel: string, showGrandTotalsOnTop: boolean, - showSubTotalsOnTop: boolean + showSubTotalsOnTop: boolean, + grandTotalDimensions?: string[] ) { /** * @@ -2120,25 +2232,65 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { + return { + indicatorKey: indicator, + value: indicator + }; + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; const node: { value: string; dimensionKey: string; children: any[]; levelSpan: number; role: string } = { value: grandTotalLabel, // getId(item?.id, 1), dimensionKey: rows[0], - levelSpan: subTotalFlags.length, - children: - indicators?.map(indicator => { - if (typeof indicator === 'string') { - return { - indicatorKey: indicator, - value: indicator - }; - } - return { - indicatorKey: indicator.indicatorKey, - value: indicator.title - }; - }) ?? [], + levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, + children: [], role: 'grand-total' }; + if (grandTotalDimensionIndexes.length) { + const grandTotalMap = new Map(); + arr.forEach(list => { + let parent = node; + const path = [grandTotalLabel]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[dimensionIndex]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; + item = { + value, + dimensionKey: rows[dimensionIndex], + levelSpan: nextDimensionIndex - dimensionIndex, + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + const totalChild = { + value: subTotalLabel, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + levelSpan: rows.length - grandTotalDimensionIndexes[0], + children: createIndicatorChildren() + }; + if (showSubTotalsOnTop) { + node.children.unshift(totalChild); + } else { + node.children.push(totalChild); + } + } else { + node.children = createIndicatorChildren(); + } if (showGrandTotalsOnTop) { result.unshift(node); } else { @@ -2328,15 +2480,15 @@ export class Dataset { //#region 参数处理与初始化 // 将输入参数转换为字符串格式,用于在树中查找 - const flatRowKey = Array.isArray(rowKey) ? rowKey.join(this.stringJoinChar) : rowKey; - const flatColKey = Array.isArray(colKey) ? colKey.join(this.stringJoinChar) : colKey; + const flatRowKey = Array.isArray(rowKey) ? join(rowKey, this.stringJoinChar) : rowKey; + const flatColKey = Array.isArray(colKey) ? join(colKey, this.stringJoinChar) : colKey; const indicatorIndex = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys.indexOf(indicator); // 找出受影响的小计和总计 const aggregatorsToReset: Set = new Set(); // 存储所有需要重置的聚合器 const subTotalAggregators: Set = new Set(); // 用于标记哪些聚合器是小计 - const rowKeyParts = flatRowKey.split(this.stringJoinChar); // 拆分行键以便构建小计键 - const colKeyParts = flatColKey.split(this.stringJoinChar); // 拆分列键以便构建小计键 + const rowKeyParts = Array.isArray(rowKey) ? rowKey.slice() : rowKey.split(this.stringJoinChar); + const colKeyParts = Array.isArray(colKey) ? colKey.slice() : colKey.split(this.stringJoinChar); //#endregion //#region 辅助函数定义 @@ -2360,8 +2512,10 @@ export class Dataset { }; //#endregion - //#region 处理行维度的小计和总计 - // 1. 找出受影响的行小计 + const rowAggregateKeys: string[] = []; + const colAggregateKeys: string[] = []; + + // 找出受影响的行小计 if (this.totals?.row?.subTotalsDimensions && this.totals.row.showSubTotals !== false) { for (let i = 0; i < this.totals.row.subTotalsDimensions.length; i++) { const dimension = this.totals.row.subTotalsDimensions[i]; @@ -2374,146 +2528,82 @@ export class Dataset { rowSubTotalKeyParts.push(this.rowSubTotalLabel); // 添加小计标识 } const flatRowSubTotalKey = rowSubTotalKeyParts.join(this.stringJoinChar); - - // 添加行小计与当前列的交叉点 - if (this.tree[flatRowSubTotalKey]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColKey][indicatorIndex], - flatRowSubTotalKey, - flatColKey - ); - } - - // 添加行小计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[flatRowSubTotalKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColSubTotalKey][indicatorIndex], - flatRowSubTotalKey, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行小计与列总计的交叉点 - if (this.totals?.column?.showGrandTotals) { - if (this.tree[flatRowSubTotalKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][this.colGrandTotalLabel][indicatorIndex], - flatRowSubTotalKey, - this.colGrandTotalLabel - ); - } + if (rowAggregateKeys.indexOf(flatRowSubTotalKey) === -1) { + rowAggregateKeys.push(flatRowSubTotalKey); } } } } - // 处理行总计 if (this.totals?.row?.showGrandTotals) { - // 添加行总计与当前列的交叉点 - if (this.tree[this.rowGrandTotalLabel]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColKey - ); - - // 添加行总计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[this.rowGrandTotalLabel]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColSubTotalKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行总计与列总计的交叉点(表格右下角的总计单元格) - if (this.totals?.column?.showGrandTotals) { - if (this.tree[this.rowGrandTotalLabel]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][this.colGrandTotalLabel][indicatorIndex], - this.rowGrandTotalLabel, - this.colGrandTotalLabel - ); - } + const rowGrandTotalKeys = this.getGrandTotalKeys( + rowKeyParts, + this.rows, + this.totals.row.grandTotalDimensions, + this.rowGrandTotalLabel, + this.rowSubTotalLabel + ); + for (const rowGrandTotalKey of rowGrandTotalKeys) { + if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { + rowAggregateKeys.push(rowGrandTotalKey); } } - //#endregion + } - //#region 处理列维度的小计和总计 - // 2. 找出受影响的列小计 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - // 构建列小计的键 - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - // 添加当前行与列小计的交叉点 - if (this.tree[flatRowKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][flatColSubTotalKey][indicatorIndex], - flatRowKey, - flatColSubTotalKey - ); - } + // 找出受影响的列小计 + if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { + for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { + const colDimension = this.totals.column.subTotalsDimensions[j]; + const colDimensionIndex = this.columns.indexOf(colDimension); + if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { + const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); + colSubTotalKeyParts.push(this.colSubTotalLabel); + const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); + if (colAggregateKeys.indexOf(flatColSubTotalKey) === -1) { + colAggregateKeys.push(flatColSubTotalKey); } } } + } - // 处理列总计 - if (this.totals?.column?.showGrandTotals) { - // 添加当前行与列总计的交叉点 - if (this.tree[flatRowKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][this.colGrandTotalLabel][indicatorIndex], - flatRowKey, - this.colGrandTotalLabel - ); + if (this.totals?.column?.showGrandTotals) { + const colGrandTotalKeys = this.getGrandTotalKeys( + colKeyParts, + this.columns, + this.totals.column.grandTotalDimensions, + this.colGrandTotalLabel, + this.colSubTotalLabel + ); + for (const colGrandTotalKey of colGrandTotalKeys) { + if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { + colAggregateKeys.push(colGrandTotalKey); } } - //#endregion + } - //#region 重新计算受影响的聚合器 - // 重置并重新计算受影响的聚合器 - aggregatorsToReset.forEach(agg => { - // 只对行小计和列小计添加新的aggregator(直接相关的小计) - if (newCellAggregator && subTotalAggregators.has(agg)) { - agg.push(newCellAggregator); // 将新的单元格聚合器添加到小计聚合器中 - } - agg.recalculate(); // 重新计算聚合值 + // 先重算直接依赖当前单元格的行/列汇总,再重算行列汇总交叉点。 + rowAggregateKeys.forEach(rowAggregateKey => { + addAggregatorToResetSet(this.tree[rowAggregateKey]?.[flatColKey]?.[indicatorIndex], rowAggregateKey, flatColKey); + }); + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet(this.tree[flatRowKey]?.[colAggregateKey]?.[indicatorIndex], flatRowKey, colAggregateKey); + }); + rowAggregateKeys.forEach(rowAggregateKey => { + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet( + this.tree[rowAggregateKey]?.[colAggregateKey]?.[indicatorIndex], + rowAggregateKey, + colAggregateKey + ); }); - //#endregion - } + }); + + aggregatorsToReset.forEach(agg => { + if (newCellAggregator && subTotalAggregators.has(agg)) { + agg.push(newCellAggregator); + } + agg.recalculate(); + }); } changeRecordFieldValue(fieldName: string, oldValue: string | number, value: string | number) { diff --git a/packages/vtable/src/ts-types/new-data-set.ts b/packages/vtable/src/ts-types/new-data-set.ts index 22332c25c5..4b135291ef 100644 --- a/packages/vtable/src/ts-types/new-data-set.ts +++ b/packages/vtable/src/ts-types/new-data-set.ts @@ -48,6 +48,8 @@ export interface Total { // calcSubTotals?: CalcTotals; /** 小计汇总维度定义 */ subTotalsDimensions?: string[]; + /** 总计中保留的下级维度,用于按维度分组显示总计 */ + grandTotalDimensions?: string[]; /** 汇总节点显示名称 默认'总计' */ grandTotalLabel?: string; /** 汇总节点显示名称 默认'小计' */ From cb9432da2a1a1feef3fc5006c0c43edc13904d06 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 16:01:23 +0800 Subject: [PATCH 48/65] fix: address grouped grand total review issues Isolate grouped total keys from display labels. Cover edge cases introduced by grandTotalDimensions. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../en/data-analysis/pivot-analysis-total.md | 2 +- .../zh/data-analysis/pivot-analysis-total.md | 2 +- .../data_analysis/pivot_table_dataAnalysis.md | 4 +- .../data_analysis/pivot_table_dataAnalysis.md | 4 +- .../__tests__/pivotTable-analysis.test.ts | 175 ++++++++++++++++ packages/vtable/src/PivotChart.ts | 12 +- packages/vtable/src/PivotTable.ts | 56 ++--- packages/vtable/src/dataset/dataset.ts | 191 ++++++++++++++---- .../vtable/src/layout/pivot-header-layout.ts | 4 + .../vtable/src/tools/get-data-path/index.ts | 4 +- packages/vtable/src/ts-types/common.ts | 2 + 11 files changed, 373 insertions(+), 83 deletions(-) diff --git a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md index 278ba495b2..86cb588a8a 100644 --- a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # Pivot Analysis - Subtotal Total -Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Totals appear at the bottom or on the right by default; use `showGrandTotalsOnTop` to move them to the top or left. [Configuration reference](../../option/PivotTable#dataConfig.totals) +Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Row grand totals appear at the bottom by default and can be moved to the top with `showGrandTotalsOnTop`; column grand totals appear on the right and can be moved to the left with `showGrandTotalsOnLeft`. [Configuration reference](../../option/PivotTable#dataConfig.totals) ## Key Configurations diff --git a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md index 44791e0a3d..8c3c1b88b4 100644 --- a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # 透视分析——小计总计 -透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。汇总默认显示在底部或右侧,也可以通过 `showGrandTotalsOnTop` 调整到顶部或左侧。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。行总计默认显示在底部,可通过 `showGrandTotalsOnTop` 调整到顶部;列总计默认显示在右侧,可通过 `showGrandTotalsOnLeft` 调整到左侧。[配置参考](../../option/PivotTable#dataConfig.totals) ## 关键配置 diff --git a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md index 05858ce3d0..ca00ea1703 100644 --- a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md @@ -82,7 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], - grandTotalDimensions: ['city'], // Group the grand total by city and append an overall subtotal + grandTotalDimensions: ['province'], // Group the grand total by province and append an overall subtotal grandTotalLabel: 'row total', subTotalLabel: 'Subtotal', showGrandTotalsOnTop: true, //totals show on top @@ -100,7 +100,7 @@ dataConfig: { }, ``` -`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, when the row dimensions are `['province', 'city']`, setting `grandTotalDimensions: ['city']` on the row total displays a total for each city under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. +`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, with the row dimensions `['region', 'province']` shown above, setting `grandTotalDimensions: ['province']` displays a total for each province under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. Online demo:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total diff --git a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md index 1df878dea2..e15a652dfc 100644 --- a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md @@ -82,7 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], - grandTotalDimensions: ['city'], // 总计区域继续按 city 分组,并追加整体小计 + grandTotalDimensions: ['province'], // 总计区域继续按 province 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -100,7 +100,7 @@ dataConfig: { }, ``` -`grandTotalDimensions` 用于在总计区域中保留下级维度。比如行维度为 `['province', 'city']` 时,将行总计配置为 `grandTotalDimensions: ['city']`,会在“行总计”下按城市分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如上述行维度为 `['region', 'province']` 时,将行总计配置为 `grandTotalDimensions: ['province']`,会在“行总计”下按省份分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index cd08c14f9c..23ab65c571 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -467,6 +467,7 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => { organization: '公司二', type: '信用证', balance: 400 } ], dataConfig: { + updateAggregationOnEditCell: true, totals: { column: { showGrandTotals: true, @@ -486,6 +487,180 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => ); expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + + pivotTable.dataset.changeTreeNodeValue([], ['公司一', '银票'], 'balance', 150); + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(450); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable grouped grand total edge cases', () => { + function createPivotTable( + records: any[], + options: { + rows?: string[]; + rowHierarchyType?: 'grid' | 'tree'; + grandTotalDimensions?: string[]; + showSubTotalsOnTop?: boolean; + aggregationRules?: any[]; + } = {} + ) { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + return new PivotTable(containerDom, { + rows: options.rows ?? ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + rowHierarchyType: options.rowHierarchyType, + records, + dataConfig: { + aggregationRules: options.aggregationRules, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: options.grandTotalDimensions ?? ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计', + showSubTotalsOnTop: options.showSubTotalsOnTop + } + } + } + }); + } + + test('keeps records whose dimension values match total labels separate from grouped totals', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '小计', balance: 100 }, + { organization: '公司二', type: '小计', balance: 300 } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + const groupedNode = grandTotalNode.children[0]; + const overallNode = grandTotalNode.children[1]; + + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(100); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, groupedNode.dataValue], [], 'balance').value() + ).toBe(400); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, overallNode.dataValue], [], 'balance').value() + ).toBe(400); + pivotTable.release(); + }); + + test('uses active dimension indexes when an intermediate dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + { + rows: ['organization', 'unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('keeps column values matching the grand total label separate from grouped totals', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.role === 'grand-total'); + + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(100); + expect( + pivotTable.dataset + .getAggregator([], [grandTotalNode.dataValue, grandTotalNode.children[0].dataValue], 'balance') + .value() + ).toBe(400); + pivotTable.release(); + }); + + test('passes the custom aggregation function to grouped and overall totals', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length + } + ] + } + ); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(200); + pivotTable.release(); + }); + + test('keeps custom overall total records effective with grouped grand totals', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(999); + pivotTable.release(); + }); + + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { + const pivotTable = createPivotTable( + [ + { organization: 'A', type: 'A', balance: 100 }, + { organization: 'A', type: 'B', balance: 200 }, + { organization: 'B', type: 'A', balance: 300 } + ], + { + rowHierarchyType: 'tree', + showSubTotalsOnTop: true + } + ); + pivotTable.updateSortRules([ + { sortField: 'organization', sortType: VTable.TYPES.SortType.ASC }, + { sortField: 'type', sortType: VTable.TYPES.SortType.DESC } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['小计', 'B', 'A']); + expect(pivotTable.dataset.getAggregator(['合计', 'B'], [], 'balance').value()).toBe(200); pivotTable.release(); }); }); diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index dce0beee77..feffb0cd0b 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -674,7 +674,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -683,7 +683,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -740,7 +740,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -749,7 +749,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -791,7 +791,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -800,7 +800,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index fcfbb1a5f4..09b28c6efa 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -687,7 +687,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -700,7 +700,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -720,10 +720,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -779,7 +779,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -792,7 +792,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -816,10 +816,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const valueNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -859,7 +859,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -872,7 +872,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -889,10 +889,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -932,7 +932,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -945,7 +945,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -961,10 +961,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1001,7 +1001,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1014,7 +1014,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1031,10 +1031,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1069,7 +1069,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1082,7 +1082,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1099,10 +1099,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -2221,7 +2221,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -2234,7 +2234,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.dataset.changeTreeNodeValue( rowKeys, @@ -2262,10 +2262,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { ); } else { const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.flatDataToObjects.changeTreeNodeValue( rowKeys, diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 7a5fd81770..e5abadd9bc 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -107,6 +107,8 @@ export class Dataset { indicatorStatistics: { max: Aggregator; min: Aggregator; total: Aggregator }[] = []; stringJoinChar = String.fromCharCode(0); + private readonly groupedGrandTotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total'; + private readonly groupedGrandTotalSubtotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total_subtotal'; //缓存rows对应每个值是否为汇总字段 private rowsIsTotal: boolean[] = []; private colsIsTotal: boolean[] = []; @@ -330,7 +332,8 @@ export class Dataset { this.rowGrandTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - this.rowSubTotalLabel + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false ); } else { this.rowHeaderTree = this.ArrToTree( @@ -345,7 +348,8 @@ export class Dataset { this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + true ); } } @@ -380,7 +384,8 @@ export class Dataset { this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + false ); // } } @@ -738,7 +743,11 @@ export class Dataset { return rk in record; }) ) { - rowKey.push(this.rowGrandTotalLabel); + if (this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length) { + rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + rowKey.push(this.rowGrandTotalLabel); + } isToTalRecord = true; break; } else if ( @@ -798,7 +807,14 @@ export class Dataset { return ck in record; }) ) { - colKey.push(this.colGrandTotalLabel); + if ( + this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) + .length + ) { + colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + colKey.push(this.colGrandTotalLabel); + } isToTalRecord = true; break; } else if ( @@ -1068,7 +1084,8 @@ export class Dataset { this.rowGrandTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - this.rowSubTotalLabel + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1083,7 +1100,8 @@ export class Dataset { this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + true ); } } @@ -1101,7 +1119,8 @@ export class Dataset { this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + false ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1193,8 +1212,8 @@ export class Dataset { } //#endregion // let agg; - let flatRowKey; - let flatColKey; + let flatRowKey: string; + let flatColKey: string; if (typeof rowKey === 'string') { flatRowKey = rowKey; } else { @@ -1249,6 +1268,39 @@ export class Dataset { // flatColKey = colKey.join(this.stringJoinChar); flatColKey = join(colKey, this.stringJoinChar); } + + if ( + Array.isArray(rowKey) && + rowKey[0] === this.rowGrandTotalLabel && + !this.tree[flatRowKey] && + this.getGrandTotalDimensionIndexes( + this.rows.filter((_, index) => this.rowsHasValue[index]), + this.totals?.row?.grandTotalDimensions + ).length + ) { + const internalRowKey = rowKey.slice(); + internalRowKey[0] = this.groupedGrandTotalKey; + if (internalRowKey.length === 2 && internalRowKey[1] === this.rowSubTotalLabel) { + internalRowKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatRowKey = join(internalRowKey, this.stringJoinChar); + } + if ( + Array.isArray(colKey) && + colKey[0] === this.colGrandTotalLabel && + !Object.keys(this.tree).some(rowTreeKey => this.tree[rowTreeKey]?.[flatColKey]) && + this.getGrandTotalDimensionIndexes( + this.columns.filter((_, index) => this.columnsHasValue[index]), + this.totals?.column?.grandTotalDimensions + ).length + ) { + const internalColKey = colKey.slice(); + internalColKey[0] = this.groupedGrandTotalKey; + if (internalColKey.length === 2 && internalColKey[1] === this.colSubTotalLabel) { + internalColKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatColKey = join(internalColKey, this.stringJoinChar); + } //TODO 原有逻辑 但这里先强制跳过 // if ( rowKey.length === 0 && colKey.length === 0) { // agg = this.allTotal; @@ -1661,13 +1713,12 @@ export class Dataset { } } } - if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== that.colGrandTotalLabel) { + if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== this.groupedGrandTotalKey) { const flatColTotalKeys = this.getGrandTotalKeys( colKey, - that.columns, + that.columns.filter((_, index) => that.columnsHasValue[index]), that.totals?.column?.grandTotalDimensions, - that.colGrandTotalLabel, - that.colSubTotalLabel + that.colGrandTotalLabel ); for (const flatColTotalKey of flatColTotalKeys) { if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { @@ -1713,6 +1764,7 @@ export class Dataset { ]({ key: toComputeIndicatorKeys[i], field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, formatFun: aggRule?.formatFun ?? ( @@ -1726,7 +1778,14 @@ export class Dataset { }); } if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; + if (this.tree[flatRowKey][flatColTotalKey][i].type === AggregationType.CUSTOM) { + sourceAggregator?.records.forEach(record => { + this.tree[flatRowKey][flatColTotalKey][i].push(record); + }); + } else { + this.tree[flatRowKey][flatColTotalKey][i].push(sourceAggregator); + } } } } @@ -1835,14 +1894,13 @@ export class Dataset { if ( (that.totals?.row?.showGrandTotals || this.columns.length === 0) && - rowKey[0] !== that.rowGrandTotalLabel + rowKey[0] !== this.groupedGrandTotalKey ) { const flatRowTotalKeys = this.getGrandTotalKeys( rowKey, - that.rows, + that.rows.filter((_, index) => that.rowsHasValue[index]), that.totals?.row?.grandTotalDimensions, - that.rowGrandTotalLabel, - that.rowSubTotalLabel + that.rowGrandTotalLabel ); for (const flatRowTotalKey of flatRowTotalKeys) { if (!this.tree[flatRowTotalKey]) { @@ -1882,6 +1940,7 @@ export class Dataset { ]({ key: toComputeIndicatorKeys[i], field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, formatFun: aggRule?.formatFun ?? ( @@ -1896,7 +1955,14 @@ export class Dataset { } } if (flatRowTotalKey !== flatRowKey) { - this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; + if (this.tree[flatRowTotalKey][flatColKey][i].type === AggregationType.CUSTOM) { + sourceAggregator?.records.forEach(record => { + this.tree[flatRowTotalKey][flatColKey][i].push(record); + }); + } else { + this.tree[flatRowTotalKey][flatColKey][i].push(sourceAggregator); + } } } } @@ -1967,19 +2033,44 @@ export class Dataset { .sort((a, b) => a - b); } + private getGroupedGrandTotalPaths( + keys: string[][], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + isRow: boolean = true + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const groupedPaths = new Map(); + keys.forEach(key => { + const path = dimensionIndexes.map(index => key[index]); + groupedPaths.set(join(path, this.stringJoinChar), path); + }); + return Array.from(groupedPaths.values()).sort( + this.arrSort( + dimensionIndexes.map(index => dimensions[index]), + isRow + ) + ); + } + private getGrandTotalKeys( dimensionKey: string[], dimensions: string[], grandTotalDimensions: string[] | undefined, - grandTotalLabel: string, - subTotalLabel: string + grandTotalLabel: string ) { const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); if (!dimensionIndexes.length) { return [grandTotalLabel]; } - const groupedKey = [grandTotalLabel, ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex])]; - return [join(groupedKey, this.stringJoinChar), join([grandTotalLabel, subTotalLabel], this.stringJoinChar)]; + const groupedKey = [ + this.groupedGrandTotalKey, + ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) + ]; + return [ + join(groupedKey, this.stringJoinChar), + join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) + ]; } private ArrToTree1( @@ -1990,7 +2081,8 @@ export class Dataset { grandTotalLabel: string, showGrandTotalsOnTop: boolean, grandTotalDimensions?: string[], - subTotalLabel?: string + subTotalLabel?: string, + showSubTotalsOnTop?: boolean ) { /** * @@ -2069,22 +2161,24 @@ export class Dataset { } else { const grandTotalNode: any = { value: grandTotalLabel, + dataValue: this.groupedGrandTotalKey, dimensionKey: rows[0], children: [], role: 'grand-total' }; const grandTotalMap = new Map(); - arr.forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true).forEach(list => { let parent = grandTotalNode; - const path = [grandTotalLabel]; + const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { - const value = list[dimensionIndex]; + const value = list[index]; path.push(value); const flatKey = join(path, concatStr); let item = grandTotalMap.get(flatKey); if (!item) { item = { value, + dataValue: value, dimensionKey: rows[dimensionIndex], children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] }; @@ -2094,11 +2188,17 @@ export class Dataset { parent = item; }); }); - grandTotalNode.children.push({ + const totalChild = { value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, dimensionKey: rows[grandTotalDimensionIndexes[0]], children: createIndicatorChildren() - }); + }; + if (showSubTotalsOnTop) { + grandTotalNode.children.unshift(totalChild); + } else { + grandTotalNode.children.push(totalChild); + } if (showGrandTotalsOnTop) { result.unshift(grandTotalNode); } else { @@ -2124,7 +2224,8 @@ export class Dataset { subTotalLabel: string, showGrandTotalsOnTop: boolean, showSubTotalsOnTop: boolean, - grandTotalDimensions?: string[] + grandTotalDimensions?: string[], + isRow?: boolean ) { /** * @@ -2246,8 +2347,16 @@ export class Dataset { value: indicator.title }; }) ?? []; - const node: { value: string; dimensionKey: string; children: any[]; levelSpan: number; role: string } = { + const node: { + value: string; + dataValue?: string; + dimensionKey: string; + children: any[]; + levelSpan: number; + role: string; + } = { value: grandTotalLabel, // getId(item?.id, 1), + dataValue: grandTotalDimensionIndexes.length ? this.groupedGrandTotalKey : undefined, dimensionKey: rows[0], levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, children: [], @@ -2255,11 +2364,11 @@ export class Dataset { }; if (grandTotalDimensionIndexes.length) { const grandTotalMap = new Map(); - arr.forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow).forEach(list => { let parent = node; - const path = [grandTotalLabel]; + const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { - const value = list[dimensionIndex]; + const value = list[index]; path.push(value); const flatKey = join(path, concatStr); let item = grandTotalMap.get(flatKey); @@ -2267,6 +2376,7 @@ export class Dataset { const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; item = { value, + dataValue: value, dimensionKey: rows[dimensionIndex], levelSpan: nextDimensionIndex - dimensionIndex, children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] @@ -2279,6 +2389,7 @@ export class Dataset { }); const totalChild = { value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, dimensionKey: rows[grandTotalDimensionIndexes[0]], levelSpan: rows.length - grandTotalDimensionIndexes[0], children: createIndicatorChildren() @@ -2538,10 +2649,9 @@ export class Dataset { if (this.totals?.row?.showGrandTotals) { const rowGrandTotalKeys = this.getGrandTotalKeys( rowKeyParts, - this.rows, + this.rows.filter((_, index) => this.rowsHasValue[index]), this.totals.row.grandTotalDimensions, - this.rowGrandTotalLabel, - this.rowSubTotalLabel + this.rowGrandTotalLabel ); for (const rowGrandTotalKey of rowGrandTotalKeys) { if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { @@ -2569,10 +2679,9 @@ export class Dataset { if (this.totals?.column?.showGrandTotals) { const colGrandTotalKeys = this.getGrandTotalKeys( colKeyParts, - this.columns, + this.columns.filter((_, index) => this.columnsHasValue[index]), this.totals.column.grandTotalDimensions, - this.colGrandTotalLabel, - this.colSubTotalLabel + this.colGrandTotalLabel ); for (const colGrandTotalKey of colGrandTotalKeys) { if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index c05a9dacb6..50e61966f1 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -2348,6 +2348,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2359,6 +2360,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (colHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(colHeader.indicatorKey)?.title ?? '' : colHeader.value); + colHeaderPath.dataValue = colHeader.dataValue; colHeaderPath.virtual = colHeader.virtual; colHeaderPath.role = colHeader.role; headerPaths.colHeaderPaths!.push(colHeaderPath); @@ -2370,6 +2372,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2381,6 +2384,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (rowHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(rowHeader.indicatorKey)?.title ?? '' : rowHeader.value); + rowHeaderPath.dataValue = rowHeader.dataValue; rowHeaderPath.virtual = rowHeader.virtual; rowHeaderPath.role = rowHeader.role; headerPaths.rowHeaderPaths!.push(rowHeaderPath); diff --git a/packages/vtable/src/tools/get-data-path/index.ts b/packages/vtable/src/tools/get-data-path/index.ts index dcf5eb4f17..b2662f2933 100644 --- a/packages/vtable/src/tools/get-data-path/index.ts +++ b/packages/vtable/src/tools/get-data-path/index.ts @@ -69,10 +69,10 @@ export function getDataCellPath( const cellDimensionPath = layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); // const aggregator = dataset.getAggregator( // rowKey[rowKey.length - 1], diff --git a/packages/vtable/src/ts-types/common.ts b/packages/vtable/src/ts-types/common.ts index a05a30cd9e..aea73b3083 100644 --- a/packages/vtable/src/ts-types/common.ts +++ b/packages/vtable/src/ts-types/common.ts @@ -104,6 +104,8 @@ export type IPivotTableCellHeaderPaths = { export interface IDimensionInfo { dimensionKey?: string; value?: string; + /** Internal value used to locate aggregated data when it differs from the displayed value. */ + dataValue?: string; indicatorKey?: string; isPivotCorner?: boolean; virtual?: boolean; From dc255243af3a7c7e7c8d70868bdf0b66af4be80b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 16:51:17 +0800 Subject: [PATCH 49/65] fix: keep pivot header width default during updateOption Keep defaultHeaderColWidth aligned with defaultColWidth when unset. This prevents updateOption from resetting pivot row headers to 80. Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/__tests__/pivotTable.test.ts | 11 +++++++++++ packages/vtable/src/core/BaseTable.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/vtable/__tests__/pivotTable.test.ts b/packages/vtable/__tests__/pivotTable.test.ts index 9fcf5b10c1..491c9a07c8 100644 --- a/packages/vtable/__tests__/pivotTable.test.ts +++ b/packages/vtable/__tests__/pivotTable.test.ts @@ -675,6 +675,17 @@ describe('pivotTable init test', () => { pivotTable.stateManager.endMoveCol(); expect(pivotTable.getCellValue(3, 1)).toEqual('利润'); }); + test('pivotTable updateOption keeps default header col width aligned with default col width', () => { + pivotTable.updateOption( + { + ...option, + defaultColWidth: 120 + }, + { clearColWidthCache: false } + ); + + expect(pivotTable.internalProps.defaultHeaderColWidth).toBe(120); + }); test('pivotTable updateOption hideIndicatorName&format', () => { indicators[0].format = rec => { return rec?.['利润'] ?? '0' + '元'; diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 25c3d24752..c43a2b34cc 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -2796,7 +2796,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { defaultRowHeight = 40, defaultHeaderRowHeight, defaultColWidth = 80, - defaultHeaderColWidth = 80, + defaultHeaderColWidth, keyboardOptions, eventOptions, rowSeriesNumber, From 93721897c78d53b29821c949e317ead7f065fa1d Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 17:03:46 +0800 Subject: [PATCH 50/65] fix: complete grouped grand total integrations Handle grouped totals across chart paths, custom edits, and indicator sorting. Support external totals and grid-tree lookup. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/pivotTable-analysis.test.ts | 198 ++++++++++++++-- packages/vtable/src/dataset/dataset.ts | 221 +++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 4 +- .../src/ts-types/dataset/aggregation.ts | 24 ++ 4 files changed, 334 insertions(+), 113 deletions(-) diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index 23ab65c571..e8477f00a8 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -410,13 +410,37 @@ describe('pivotTable grand total grouped by lower-level dimension', () => { } }); + function findBodyCell(rowValues: string[], colValues: string[] = [], role?: 'normal' | 'grand-total') { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const rowRole = pivotTable.getCellPivotRole(col, row).rowRole; + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + (!role || rowRole === role) + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${rowValues.join('/')}`); + } + test('creates grouped grand total headers and values', () => { const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); expect(grandTotalNode.levelSpan).toBe(1); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(200); expect(pivotTable.dataset.getAggregator(['合计', '信用证'], [], 'balance').value()).toBe(400); @@ -432,18 +456,18 @@ describe('pivotTable grand total grouped by lower-level dimension', () => { ]); const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '信用证', '商票', '小计']); }); test('recalculates grouped and overall grand totals after editing', () => { - pivotTable.dataset.changeTreeNodeValue(['公司一', '银票'], [], 'balance', 150); + const detailCell = findBodyCell(['公司一', '银票'], [], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], [], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], [], 'grand-total'); - const groupedGrandTotal = pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance'); - expect(groupedGrandTotal.children.map(child => child.value())).toEqual([150, 300]); - expect(groupedGrandTotal.value()).toBe(450); - expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1050); + expect(pivotTable.getCellOriginValue(detailCell.col, detailCell.row)).toBe(150); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); pivotTable.release(); }); }); @@ -482,15 +506,32 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.value === '合计'); expect(grandTotalNode.levelSpan).toBe(1); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); - pivotTable.dataset.changeTreeNodeValue([], ['公司一', '银票'], 'balance', 150); - expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(450); - expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1050); + const findBodyCell = (colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const values = paths.colHeaderPaths.filter(path => path.dimensionKey).map(path => path.value as string); + if (values.join('|') === colValues.join('|') && pivotTable.getCellPivotRole(col, row).colRole === role) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${colValues.join('/')}`); + }; + const detailCell = findBodyCell(['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], 'grand-total'); + + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); pivotTable.release(); }); }); @@ -500,10 +541,12 @@ describe('pivotTable grouped grand total edge cases', () => { records: any[], options: { rows?: string[]; - rowHierarchyType?: 'grid' | 'tree'; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; grandTotalDimensions?: string[]; showSubTotalsOnTop?: boolean; + showSubTotalsOnTreeNode?: boolean; aggregationRules?: any[]; + sortRules?: any[]; } = {} ) { const containerDom: HTMLElement = createDiv(); @@ -516,9 +559,12 @@ describe('pivotTable grouped grand total edge cases', () => { indicators: ['balance'], indicatorsAsCol: true, rowHierarchyType: options.rowHierarchyType, + rowExpandLevel: 3, records, dataConfig: { + updateAggregationOnEditCell: true, aggregationRules: options.aggregationRules, + sortRules: options.sortRules, totals: { row: { showGrandTotals: true, @@ -526,13 +572,42 @@ describe('pivotTable grouped grand total edge cases', () => { grandTotalDimensions: options.grandTotalDimensions ?? ['type'], grandTotalLabel: '合计', subTotalLabel: '小计', - showSubTotalsOnTop: options.showSubTotalsOnTop + showSubTotalsOnTop: options.showSubTotalsOnTop, + showSubTotalsOnTreeNode: options.showSubTotalsOnTreeNode } } } }); } + function findBodyCell( + pivotTable: PivotTable, + rowValues: string[], + role?: 'normal' | 'grand-total' + ): { col: number; row: number } { + const candidates: string[] = []; + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const values = pivotTable + .getCellHeaderPaths(col, row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.value as string); + candidates.push(`${values.join('/')} (${pivotTable.getCellPivotRole(col, row).rowRole})`); + if ( + values.join('|') === rowValues.join('|') && + (!role || pivotTable.getCellPivotRole(col, row).rowRole === role) + ) { + return { col, row }; + } + } + } + const availableCells = Array.from(new Set(candidates)).join(', '); + throw new Error(`Unable to find body cell for ${rowValues.join('/')}: ${availableCells}`); + } + test('keeps records whose dimension values match total labels separate from grouped totals', () => { const pivotTable = createPivotTable([ { organization: '合计', type: '小计', balance: 100 }, @@ -549,6 +624,8 @@ describe('pivotTable grouped grand total edge cases', () => { expect( pivotTable.dataset.getAggregator([grandTotalNode.dataValue, overallNode.dataValue], [], 'balance').value() ).toBe(400); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '小计'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(400); pivotTable.release(); }); @@ -610,7 +687,9 @@ describe('pivotTable grouped grand total edge cases', () => { const pivotTable = createPivotTable( [ { organization: '公司一', type: '银票', balance: 100 }, - { organization: '公司二', type: '银票', balance: 300 } + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 }, + { organization: '公司二', type: '商票', balance: 500 } ], { aggregationRules: [ @@ -624,8 +703,14 @@ describe('pivotTable grouped grand total edge cases', () => { } ); - expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(200); - expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(150); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(275); + + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBeCloseTo(433.33, 2); pivotTable.release(); }); @@ -641,6 +726,75 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('uses custom grouped total records for retained dimensions', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(999); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('sorts grouped totals by indicator values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 200 } + ], + { + sortRules: [ + { + sortField: 'type', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '信用证', '银票', '小计']); + pivotTable.release(); + }); + + test('supports multiple retained dimensions and grid-tree lookup', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 300 }, + { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 } + ], + { + rows: ['organization', 'category', 'type'], + rowHierarchyType: 'grid-tree', + grandTotalDimensions: ['category', 'type'], + showSubTotalsOnTreeNode: true + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); + expect(grandTotalNode.children[0].levelSpan).toBe(1); + expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); + const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); + const groupedPath = pivotTable + .getCellHeaderPaths(groupedCell.col, groupedCell.row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.dataValue ?? path.value) + .join(pivotTable.dataset.stringJoinChar); + expect((pivotTable.internalProps.layoutMap as any).getRowKeysPath(groupedCell.col, groupedCell.row)).toBe( + groupedPath + ); + pivotTable.release(); + }); + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { const pivotTable = createPivotTable( [ diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index e5abadd9bc..6c963a18e6 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -728,39 +728,51 @@ export class Dataset { } else { const rowKey: string[] = []; rowKeys.push({ rowKey, indicatorKey: assignedIndicatorKey }); - for (let l = 0, len1 = this.rows.length; l < len1; l++) { - const rowAttr = this.rows[l]; - if (rowAttr in record) { - this.rowsHasValue[l] = true; - rowKey.push(record[rowAttr]); - } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.row?.showGrandTotals && - l === 0 && - !this.rows.find((rk: string) => { - // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 - return rk in record; - }) - ) { - if (this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length) { - rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); - } else { - rowKey.push(this.rowGrandTotalLabel); - } - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.row?.showSubTotals && - this.dataConfig?.totals?.row?.subTotalsDimensions && - this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 - ) { - if (this.rowHierarchyType !== 'tree') { - //如果是tree的话 不附加标签'小计' - rowKey.push(this.rowSubTotalLabel); + const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( + record, + this.rows, + this.dataConfig?.totals?.row?.grandTotalDimensions + ); + if (customGroupedGrandTotalKey) { + rowKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let l = 0, len1 = this.rows.length; l < len1; l++) { + const rowAttr = this.rows[l]; + if (rowAttr in record) { + this.rowsHasValue[l] = true; + rowKey.push(record[rowAttr]); + } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 + if ( + this.dataConfig?.totals?.row?.showGrandTotals && + l === 0 && + !this.rows.find((rk: string) => { + // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 + return rk in record; + }) + ) { + if ( + this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length + ) { + rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + rowKey.push(this.rowGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.row?.showSubTotals && + this.dataConfig?.totals?.row?.subTotalsDimensions && + this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 + ) { + if (this.rowHierarchyType !== 'tree') { + //如果是tree的话 不附加标签'小计' + rowKey.push(this.rowSubTotalLabel); + } + isToTalRecord = true; + break; } - isToTalRecord = true; - break; } } } @@ -792,41 +804,51 @@ export class Dataset { } else { const colKey: string[] = []; colKeys.push({ colKey, indicatorKey: assignedIndicatorKey }); - for (let n = 0, len2 = this.columns.length; n < len2; n++) { - const colAttr = this.columns[n]; - if (colAttr in record) { - this.columnsHasValue[n] = true; - colKey.push(record[colAttr]); - } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.column?.showGrandTotals && - n === 0 && - !this.columns.find((ck: string) => { - // 判断没有其他字段在record中 - return ck in record; - }) - ) { + const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( + record, + this.columns, + this.dataConfig?.totals?.column?.grandTotalDimensions + ); + if (customGroupedGrandTotalKey) { + colKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let n = 0, len2 = this.columns.length; n < len2; n++) { + const colAttr = this.columns[n]; + if (colAttr in record) { + this.columnsHasValue[n] = true; + colKey.push(record[colAttr]); + } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 if ( - this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) - .length + this.dataConfig?.totals?.column?.showGrandTotals && + n === 0 && + !this.columns.find((ck: string) => { + // 判断没有其他字段在record中 + return ck in record; + }) ) { - colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); - } else { - colKey.push(this.colGrandTotalLabel); + if ( + this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) + .length + ) { + colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + colKey.push(this.colGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.column?.showSubTotals && + this.dataConfig?.totals?.column?.subTotalsDimensions && + this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 + ) { + // if (this.columnHierarchyType === 'grid') { + colKey.push(this.colSubTotalLabel); + // } + isToTalRecord = true; + break; } - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.column?.showSubTotals && - this.dataConfig?.totals?.column?.subTotalsDimensions && - this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 - ) { - // if (this.columnHierarchyType === 'grid') { - colKey.push(this.colSubTotalLabel); - // } - isToTalRecord = true; - break; } } } @@ -1227,7 +1249,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -1254,7 +1276,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && @@ -1452,7 +1474,7 @@ export class Dataset { * @param fieldArr 排序维度名称 如行rows 列columns * @returns */ - arrSort(fieldArr: string[], isRow: boolean) { + arrSort(fieldArr: string[], isRow: boolean, pathPrefix?: string[]) { let field; const that = this; const sortersArr: any[] = function (_this: any) { @@ -1499,7 +1521,7 @@ export class Dataset { if (sorter.sortRule?.sortByIndicator) { let aChanged = a; let bChanged = b; - if (sorter.fieldIndex < fieldArr.length - 1) { + if (!pathPrefix?.length && sorter.fieldIndex < fieldArr.length - 1) { aChanged = a.slice(0, sorter.fieldIndex + 1); if (that.rowHierarchyType === 'grid' && isRow) { aChanged.push(that.rowSubTotalLabel); @@ -1513,6 +1535,10 @@ export class Dataset { bChanged.push(that.colSubTotalLabel); } } + if (pathPrefix?.length) { + aChanged = [...pathPrefix, ...aChanged]; + bChanged = [...pathPrefix, ...bChanged]; + } comparison = sorter.func(aChanged, bChanged, sorter.sortRule?.sortType); } else { comparison = sorter.func?.(a[sorter.fieldIndex], b[sorter.fieldIndex], sorter.sortRule?.sortType); @@ -1550,14 +1576,16 @@ export class Dataset { that.rowHierarchyType === 'grid' && rowKey.length < that.rows.length && rowKey[rowKey.length - 1] !== that.rowSubTotalLabel && - rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel + rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel && + rowKey[0] !== that.groupedGrandTotalKey ) { rowKey.push(that.rowSubTotalLabel); } if ( colKey.length < that.columns.length && colKey[colKey.length - 1] !== that.colSubTotalLabel && - colKey[colKey.length - 1] !== that.colGrandTotalLabel + colKey[colKey.length - 1] !== that.colGrandTotalLabel && + colKey[0] !== that.groupedGrandTotalKey ) { colKey.push(that.colSubTotalLabel); } @@ -1776,16 +1804,15 @@ export class Dataset { }) as IIndicator )?.format }); + if ( + this.tree[flatRowKey][flatColTotalKey][i] instanceof CustomAggregator && + flatColTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowKey][flatColTotalKey][i] as CustomAggregator).recalculateFromChildren = true; + } } if (flatColTotalKey !== flatColKey) { - const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; - if (this.tree[flatRowKey][flatColTotalKey][i].type === AggregationType.CUSTOM) { - sourceAggregator?.records.forEach(record => { - this.tree[flatRowKey][flatColTotalKey][i].push(record); - }); - } else { - this.tree[flatRowKey][flatColTotalKey][i].push(sourceAggregator); - } + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -1952,17 +1979,16 @@ export class Dataset { }) as IIndicator )?.format }); + if ( + this.tree[flatRowTotalKey][flatColKey][i] instanceof CustomAggregator && + flatRowTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowTotalKey][flatColKey][i] as CustomAggregator).recalculateFromChildren = true; + } } } if (flatRowTotalKey !== flatRowKey) { - const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; - if (this.tree[flatRowTotalKey][flatColKey][i].type === AggregationType.CUSTOM) { - sourceAggregator?.records.forEach(record => { - this.tree[flatRowTotalKey][flatColKey][i].push(record); - }); - } else { - this.tree[flatRowTotalKey][flatColKey][i].push(sourceAggregator); - } + this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -2033,6 +2059,22 @@ export class Dataset { .sort((a, b) => a - b); } + private getCustomGroupedGrandTotalKey( + record: any, + dimensions: string[], + grandTotalDimensions?: string[] + ): string[] | undefined { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if ( + !dimensionIndexes.length || + !dimensionIndexes.every(index => dimensions[index] in record) || + dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) + ) { + return undefined; + } + return [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])]; + } + private getGroupedGrandTotalPaths( keys: string[][], dimensions: string[], @@ -2048,7 +2090,8 @@ export class Dataset { return Array.from(groupedPaths.values()).sort( this.arrSort( dimensionIndexes.map(index => dimensions[index]), - isRow + isRow, + [this.groupedGrandTotalKey] ) ); } @@ -2472,7 +2515,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -2499,7 +2542,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 50e61966f1..2caaf4de79 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -4362,7 +4362,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.colHeaderPaths.length) { path.colHeaderPaths.forEach(path => { if (path.dimensionKey) { - colKey.push(path.value); + colKey.push(path.dataValue ?? path.value); } }); } @@ -4378,7 +4378,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.rowHeaderPaths.length) { path.rowHeaderPaths.forEach(path => { if (path.dimensionKey) { - rowKey.push(path.value); + rowKey.push(path.dataValue ?? path.value); } }); } diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index e6e4df96d9..078a32ae4a 100644 --- a/packages/vtable/src/ts-types/dataset/aggregation.ts +++ b/packages/vtable/src/ts-types/dataset/aggregation.ts @@ -212,6 +212,7 @@ export class NoneAggregator extends Aggregator { export class CustomAggregator extends Aggregator { type: string = AggregationType.CUSTOM; isRecord?: boolean = true; + recalculateFromChildren?: boolean; declare field?: string; aggregationFun?: Function; values: (string | number)[] = []; @@ -222,6 +223,19 @@ export class CustomAggregator extends Aggregator { } push(record: any): void { if (record) { + if (this.recalculateFromChildren && record.isAggregator) { + if (this.isRecord && this.records) { + this.records.push(...record.records); + } + if (this.children) { + this.children.push(record); + } + if (this.field) { + this.values.push(...record.records.map((item: any) => item[this.field as string])); + } + this.clearCacheValue(); + return; + } if (this.isRecord && this.records) { if (record.isAggregator) { this.records.push(...record.records); @@ -290,6 +304,16 @@ export class CustomAggregator extends Aggregator { this.fieldValue = undefined; } recalculate() { + if (this.recalculateFromChildren && this.children?.length && this.field) { + this.values = []; + this.children.forEach(child => { + if (isValid(child.changedValue)) { + this.values.push(child.value()); + } else { + this.values.push(...child.records.map(record => record[this.field as string])); + } + }); + } this.fieldValue = undefined; this._formatedValue = undefined; // do nothing From 9c5858cd1b2392b9a1ceffecce0f21a359c2d016 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 1 Sep 2026 17:01:49 +0800 Subject: [PATCH 51/65] fix: complete grouped grand total edge cases Keep nested totals and chart ranges consistent after edits. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/option/en/table/pivotChart.md | 6 +- docs/assets/option/zh/table/pivotChart.md | 6 +- packages/vtable/__tests__/pivotChart.test.ts | 54 ++++ .../__tests__/pivotTable-analysis.test.ts | 215 ++++++++++++++ packages/vtable/src/PivotChart.ts | 4 +- packages/vtable/src/dataset/dataset.ts | 271 +++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 23 +- .../src/ts-types/dataset/aggregation.ts | 12 +- packages/vtable/src/ts-types/table-engine.ts | 2 + 9 files changed, 477 insertions(+), 116 deletions(-) diff --git a/docs/assets/option/en/table/pivotChart.md b/docs/assets/option/en/table/pivotChart.md index 785e1cc019..e4988ef5a1 100644 --- a/docs/assets/option/en/table/pivotChart.md +++ b/docs/assets/option/en/table/pivotChart.md @@ -49,6 +49,10 @@ The currently supported data formats are, taking the sales of large supermarkets ] ``` +## dataConfig(IPivotTableDataConfig) + +PivotChart supports the PivotTable data analysis configuration. When totals are configured, use `totals.row.grandTotalDimensions` or `totals.column.grandTotalDimensions` to create grouped grand totals by retained lower-level dimensions. + ## columnTree(Array) List header tree, type:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`. Among them, IDimensionHeaderNode refers to the dimension value node of the dimension non-indicator, and IIndicatorHeaderNode refers to the indicator name node. @@ -240,4 +244,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/docs/assets/option/zh/table/pivotChart.md b/docs/assets/option/zh/table/pivotChart.md index 76ca917c79..5ee93965d8 100644 --- a/docs/assets/option/zh/table/pivotChart.md +++ b/docs/assets/option/zh/table/pivotChart.md @@ -49,6 +49,10 @@ ] ``` +## dataConfig(IPivotTableDataConfig) + +透视图支持复用透视表的数据分析配置。配置总计时可使用 `totals.row.grandTotalDimensions` 或 `totals.column.grandTotalDimensions`,按保留的下级维度生成分组总计。 + ## columnTree(Array) 列表头树,类型为:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`。其中 IDimensionHeaderNode 指的是维度非指标的维度值节点,IIndicatorHeaderNode 指的是指标名称节点。 @@ -247,4 +251,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index 729075a0b8..bba9a014b4 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9249,6 +9249,60 @@ function createTable(containerDom) { const tableInstance = new VTable.PivotChart(option); return tableInstance; } + +describe('pivotChart grouped grand total collected values', () => { + test('collects chart ranges under grouped grand total paths', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotChart = new VTable.PivotChart({ + container: containerDom, + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => + key.startsWith(String.fromCharCode(1)) + ); + + expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ + min: 400, + max: 400 + }); + pivotChart.release(); + }); +}); + describe('pivotChart init test', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index e8477f00a8..e039dbc6aa 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -646,6 +646,23 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('keeps a configured lower-level total when the first dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + rows: ['unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); + pivotTable.release(); + }); + test('keeps column values matching the grand total label separate from grouped totals', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; @@ -683,6 +700,62 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('does not classify retained-dimension records as totals when totals are hidden', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: false, + grandTotalDimensions: ['type'] + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['银票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + + test('resolves column grouped totals independently for each row', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['region'], + columns: ['organization', 'type'], + indicators: ['balance'], + records: [ + { region: '华北', organization: '合计', type: '银票', balance: 100 }, + { region: '华东', organization: '公司一', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['华东'], ['合计', '银票'], 'balance').value()).toBe(300); + pivotTable.release(); + }); + test('passes the custom aggregation function to grouped and overall totals', () => { const pivotTable = createPivotTable( [ @@ -714,6 +787,109 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('recalculates custom aggregation records after editing a multi-record cell', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ] + } + ); + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(800); + pivotTable.release(); + }); + + test('recalculates custom grouped total intersections after editing', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: ['category', 'channel'], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 100 }, + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 200 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ], + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + }, + column: { + showGrandTotals: true, + grandTotalDimensions: ['channel'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const findCell = (rowValues: string[], colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const roles = pivotTable.getCellPivotRole(col, row); + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + roles.rowRole === role && + roles.colRole === role + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find ${rowValues.join('/')} and ${colValues.join('/')}`); + }; + const detailCell = findCell(['公司一', '银票'], ['票据', '线上'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + const intersection = findCell(['合计', '银票'], ['合计', '线上'], 'grand-total'); + expect(pivotTable.getCellOriginValue(intersection.col, intersection.row)).toBe(500); + pivotTable.release(); + }); + test('keeps custom overall total records effective with grouped grand totals', () => { const pivotTable = createPivotTable([ { organization: '公司一', type: '银票', balance: 100 }, @@ -781,6 +957,7 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); expect(grandTotalNode.children[0].levelSpan).toBe(1); + expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); @@ -795,6 +972,44 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('sorts non-leaf grouped totals by their own aggregated values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 600 }, + { organization: '公司三', category: '贷款', type: '信用贷', balance: 500 } + ], + { + rows: ['organization', 'category', 'type'], + grandTotalDimensions: ['category', 'type'], + sortRules: [ + { + sortField: 'category', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + pivotTable.release(); + }); + + test('round-trips grouped total header paths when labels collide with data', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ]); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '银票'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); + pivotTable.release(); + }); + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { const pivotTable = createPivotTable( [ diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index feffb0cd0b..4f41a5024c 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -134,7 +134,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { this.setCustomStateNameToSpec(); this.internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; this.internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - this.internalProps.dataConfig = { isPivotChart: true }; + this.internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; @@ -356,7 +356,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { // 更新protectedSpace internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - internalProps.dataConfig = { isPivotChart: true }; + internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 6c963a18e6..679beff3b4 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -333,7 +333,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, this.rowSubTotalLabel, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -349,7 +350,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - true + true, + this.rows ); } } @@ -385,7 +387,8 @@ export class Dataset { this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, this.totals?.column?.grandTotalDimensions, - false + false, + this.columns ); // } } @@ -560,6 +563,9 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } + const records = Array.isArray(this.records) ? this.records : Object.values(this.records).flat(); + this.rowsHasValue = this.rows.map(row => records.some(record => record && row in record)); + this.columnsHasValue = this.columns.map(column => records.some(record => record && column in record)); //常规records是数组的情况 if (Array.isArray(this.records)) { if (!this.filteredRecords) { @@ -618,6 +624,93 @@ export class Dataset { * @param assignedIndicatorKey 指定要计算的指标key 外部用户 用指标做records的key 分别存储不同指标对应的数据时 会传入这个参数 * @returns */ + private collectValue(field: string, collectKeys: string, record: any) { + if (!this.collectedValues[field]) { + this.collectedValues[field] = {}; + } + if (!this.collectedValues[field][collectKeys]) { + if (this.collectValuesBy[field].sumBy) { + this.collectedValues[field][collectKeys] = {}; + } else if (this.collectValuesBy[field].range) { + this.collectedValues[field][collectKeys] = { + min: Number.MAX_SAFE_INTEGER, + max: Number.MIN_SAFE_INTEGER + }; + } else { + this.collectedValues[field][collectKeys] = []; + } + } + + if (this.collectValuesBy[field].sumBy) { + const sumByKeys: string = this.collectValuesBy[field].sumBy + ?.map(byField => record[byField]) + .join(this.stringJoinChar); + if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { + (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[AggregationType.SUM]({ + key: field, + field, + isRecord: undefined, + needSplitPositiveAndNegative: this.needSplitPositiveAndNegative + }); + } + (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); + } else if (this.collectValuesBy[field].range) { + const fieldRange = this.collectedValues[field][collectKeys] as { + max: number; + min: number; + }; + let max = Math.max(record[field], fieldRange.max); + let min = Math.min(record[field], fieldRange.min); + if (this.collectValuesBy[field].considerFields) { + for (const considerField of this.collectValuesBy[field].considerFields) { + if (record[considerField]) { + if (typeof record[considerField] === 'number') { + max = Math.max(record[considerField], max); + min = Math.min(record[considerField], min); + } else if (Array.isArray(record[considerField])) { + max = Math.max(...record[considerField], max); + min = Math.min(...record[considerField], min); + } + } + } + } + if (!isNaN(max)) { + fieldRange.max = max; + fieldRange.min = min; + } + } else { + const fieldValues = this.collectedValues[field][collectKeys] as Array; + if (fieldValues.indexOf(record[field]) === -1) { + fieldValues.push(record[field]); + } + } + } + + private getGroupedCollectedValueKeys(by: string[], record: any): string[] { + if (!(this.dataConfig as IPivotChartDataConfig)?.isPivotChart) { + return []; + } + const keys = new Set(); + [ + { dimensions: this.rows, totals: this.totals?.row }, + { dimensions: this.columns, totals: this.totals?.column } + ].forEach(({ dimensions, totals }) => { + if (!totals?.showGrandTotals) { + return; + } + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, totals.grandTotalDimensions); + if (dimensionIndexes.length && dimensionIndexes.every(index => by.indexOf(dimensions[index]) !== -1)) { + keys.add( + join( + [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])], + this.stringJoinChar + ) + ); + } + }); + return Array.from(keys); + } + private processRecord(record: any, assignedIndicatorKey?: string) { //这个派生字段的计算位置有待确定,是否应该放到filter之前 this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { @@ -628,69 +721,11 @@ export class Dataset { //#region 按照collectValuesBy 收集维度值 for (const field in this.collectValuesBy) { if (isValid(record[field])) { - if (!this.collectedValues[field]) { - this.collectedValues[field] = {}; - } const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); - if (!this.collectedValues[field][collectKeys]) { - if (this.collectValuesBy[field].sumBy) { - this.collectedValues[field][collectKeys] = {}; - } else if (this.collectValuesBy[field].range) { - this.collectedValues[field][collectKeys] = { - min: Number.MAX_SAFE_INTEGER, - max: Number.MIN_SAFE_INTEGER - }; - } else { - this.collectedValues[field][collectKeys] = []; - } - } - - if (this.collectValuesBy[field].sumBy) { - const sumByKeys: string = this.collectValuesBy[field].sumBy - ?.map(byField => record[byField]) - .join(this.stringJoinChar); - if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { - (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[ - AggregationType.SUM - ]({ - key: field, - field: field, - isRecord: undefined, - needSplitPositiveAndNegative: this.needSplitPositiveAndNegative - }); - } - (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); - } else if (this.collectValuesBy[field].range) { - const fieldRange = this.collectedValues[field][collectKeys] as { - max: number; - min: number; - }; - let max = Math.max(record[field], fieldRange.max); - let min = Math.min(record[field], fieldRange.min); - // 处理considerFields - if (this.collectValuesBy[field].considerFields) { - for (const considerField of this.collectValuesBy[field].considerFields) { - if (record[considerField]) { - if (typeof record[considerField] === 'number') { - max = Math.max(record[considerField], max); - min = Math.min(record[considerField], min); - } else if (Array.isArray(record[considerField])) { - max = Math.max(...record[considerField], max); - min = Math.min(...record[considerField], min); - } - } - } - } - if (!isNaN(max)) { - fieldRange.max = max; - fieldRange.min = min; - } - } else { - const fieldRange = this.collectedValues[field][collectKeys] as Array; - if (fieldRange.indexOf(record[field]) === -1) { - fieldRange.push(record[field]); - } - } + this.collectValue(field, collectKeys, record); + this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { + this.collectValue(field, groupedCollectKeys, record); + }); } } //#endregion @@ -728,11 +763,10 @@ export class Dataset { } else { const rowKey: string[] = []; rowKeys.push({ rowKey, indicatorKey: assignedIndicatorKey }); - const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( - record, - this.rows, - this.dataConfig?.totals?.row?.grandTotalDimensions - ); + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.row?.showGrandTotals && this.rowsHasValue[0] + ? this.getCustomGroupedGrandTotalKey(record, this.rows, this.dataConfig?.totals?.row?.grandTotalDimensions) + : undefined; if (customGroupedGrandTotalKey) { rowKey.push(...customGroupedGrandTotalKey); isToTalRecord = true; @@ -804,11 +838,14 @@ export class Dataset { } else { const colKey: string[] = []; colKeys.push({ colKey, indicatorKey: assignedIndicatorKey }); - const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( - record, - this.columns, - this.dataConfig?.totals?.column?.grandTotalDimensions - ); + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.column?.showGrandTotals && this.columnsHasValue[0] + ? this.getCustomGroupedGrandTotalKey( + record, + this.columns, + this.dataConfig?.totals?.column?.grandTotalDimensions + ) + : undefined; if (customGroupedGrandTotalKey) { colKey.push(...customGroupedGrandTotalKey); isToTalRecord = true; @@ -1107,7 +1144,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, this.rowSubTotalLabel, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1123,7 +1161,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - true + true, + this.rows ); } } @@ -1142,7 +1181,8 @@ export class Dataset { this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, this.totals?.column?.grandTotalDimensions, - false + false, + this.columns ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1297,7 +1337,8 @@ export class Dataset { !this.tree[flatRowKey] && this.getGrandTotalDimensionIndexes( this.rows.filter((_, index) => this.rowsHasValue[index]), - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + this.rows ).length ) { const internalRowKey = rowKey.slice(); @@ -1310,10 +1351,11 @@ export class Dataset { if ( Array.isArray(colKey) && colKey[0] === this.colGrandTotalLabel && - !Object.keys(this.tree).some(rowTreeKey => this.tree[rowTreeKey]?.[flatColKey]) && + !this.tree[flatRowKey]?.[flatColKey] && this.getGrandTotalDimensionIndexes( this.columns.filter((_, index) => this.columnsHasValue[index]), - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + this.columns ).length ) { const internalColKey = colKey.slice(); @@ -1521,17 +1563,17 @@ export class Dataset { if (sorter.sortRule?.sortByIndicator) { let aChanged = a; let bChanged = b; - if (!pathPrefix?.length && sorter.fieldIndex < fieldArr.length - 1) { + if (sorter.fieldIndex < fieldArr.length - 1) { aChanged = a.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { aChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { aChanged.push(that.colSubTotalLabel); } bChanged = b.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { bChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { bChanged.push(that.colSubTotalLabel); } } @@ -1746,7 +1788,8 @@ export class Dataset { colKey, that.columns.filter((_, index) => that.columnsHasValue[index]), that.totals?.column?.grandTotalDimensions, - that.colGrandTotalLabel + that.colGrandTotalLabel, + that.columns ); for (const flatColTotalKey of flatColTotalKeys) { if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { @@ -1927,7 +1970,8 @@ export class Dataset { rowKey, that.rows.filter((_, index) => that.rowsHasValue[index]), that.totals?.row?.grandTotalDimensions, - that.rowGrandTotalLabel + that.rowGrandTotalLabel, + that.rows ); for (const flatRowTotalKey of flatRowTotalKeys) { if (!this.tree[flatRowTotalKey]) { @@ -2049,13 +2093,26 @@ export class Dataset { * @param arr * @returns */ - private getGrandTotalDimensionIndexes(dimensions: string[], grandTotalDimensions?: string[]) { + private getGrandTotalDimensionIndexes( + dimensions: string[], + grandTotalDimensions?: string[], + configuredDimensions: string[] = dimensions + ) { if (!grandTotalDimensions?.length) { return []; } return grandTotalDimensions - .map(dimension => dimensions.indexOf(dimension)) - .filter((index, position, indexes) => index > 0 && indexes.indexOf(index) === position) + .map(dimension => ({ + configuredIndex: configuredDimensions.indexOf(dimension), + activeIndex: dimensions.indexOf(dimension) + })) + .filter( + ({ configuredIndex, activeIndex }, position, indexes) => + configuredIndex > 0 && + activeIndex >= 0 && + indexes.findIndex(item => item.activeIndex === activeIndex) === position + ) + .map(({ activeIndex }) => activeIndex) .sort((a, b) => a - b); } @@ -2079,9 +2136,10 @@ export class Dataset { keys: string[][], dimensions: string[], grandTotalDimensions: string[] | undefined, - isRow: boolean = true + isRow: boolean = true, + configuredDimensions: string[] = dimensions ) { - const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); const groupedPaths = new Map(); keys.forEach(key => { const path = dimensionIndexes.map(index => key[index]); @@ -2100,9 +2158,10 @@ export class Dataset { dimensionKey: string[], dimensions: string[], grandTotalDimensions: string[] | undefined, - grandTotalLabel: string + grandTotalLabel: string, + configuredDimensions: string[] = dimensions ) { - const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); if (!dimensionIndexes.length) { return [grandTotalLabel]; } @@ -2111,7 +2170,7 @@ export class Dataset { ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) ]; return [ - join(groupedKey, this.stringJoinChar), + ...groupedKey.slice(1).map((_, index) => join(groupedKey.slice(0, index + 2), this.stringJoinChar)), join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) ]; } @@ -2125,7 +2184,8 @@ export class Dataset { showGrandTotalsOnTop: boolean, grandTotalDimensions?: string[], subTotalLabel?: string, - showSubTotalsOnTop?: boolean + showSubTotalsOnTop?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2198,7 +2258,7 @@ export class Dataset { arr.forEach(item => addList(item, false)); if (isGrandTotal) { - const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); if (!grandTotalDimensionIndexes.length) { addList([grandTotalLabel], isGrandTotal); } else { @@ -2210,7 +2270,7 @@ export class Dataset { role: 'grand-total' }; const grandTotalMap = new Map(); - this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true).forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true, configuredRows).forEach(list => { let parent = grandTotalNode; const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { @@ -2268,7 +2328,8 @@ export class Dataset { showGrandTotalsOnTop: boolean, showSubTotalsOnTop: boolean, grandTotalDimensions?: string[], - isRow?: boolean + isRow?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2376,7 +2437,7 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { - const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); const createIndicatorChildren = () => indicators?.map(indicator => { if (typeof indicator === 'string') { @@ -2407,7 +2468,7 @@ export class Dataset { }; if (grandTotalDimensionIndexes.length) { const grandTotalMap = new Map(); - this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow).forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow, configuredRows).forEach(list => { let parent = node; const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { @@ -2694,7 +2755,8 @@ export class Dataset { rowKeyParts, this.rows.filter((_, index) => this.rowsHasValue[index]), this.totals.row.grandTotalDimensions, - this.rowGrandTotalLabel + this.rowGrandTotalLabel, + this.rows ); for (const rowGrandTotalKey of rowGrandTotalKeys) { if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { @@ -2724,7 +2786,8 @@ export class Dataset { colKeyParts, this.columns.filter((_, index) => this.columnsHasValue[index]), this.totals.column.grandTotalDimensions, - this.colGrandTotalLabel + this.colGrandTotalLabel, + this.columns ); for (const colGrandTotalKey of colGrandTotalKeys) { if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 2caaf4de79..19b587b9bc 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3423,10 +3423,14 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { const isMatch = (!isValid(currentPath.indicatorKey) && dimension.dimensionKey === currentPath.dimensionKey && - dimension.value === currentPath.value) || + (isValid(currentPath.dataValue) + ? ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue + : dimension.value === currentPath.value)) || (isValid(currentPath.indicatorKey) && dimension.indicatorKey === currentPath.indicatorKey && - ((isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || + ((isValid(currentPath.dataValue) && + ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue) || + (isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || !isValid(dimension.value) || !isValid(currentPath.value))); @@ -3625,7 +3629,9 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { .filter((hd: HeaderData) => { return ( (hd?.field === rowDimension.dimensionKey || hd?.field === rowDimension.indicatorKey) && - hd?.title === rowDimension.value + (isValid(rowDimension.dataValue) + ? ((hd as any).dataValue ?? hd.title) === rowDimension.dataValue + : hd?.title === rowDimension.value) ); }) .map((hd: HeaderData) => { @@ -3643,7 +3649,12 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { fullCellIds.every(id => { const curHd = this._headerObjectMap[id]; return rowHeaderPaths.find(rowDimensionPath => { - return rowDimensionPath.dimensionKey === curHd.field && rowDimensionPath.value === curHd.title; + return ( + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? curHd.title) === rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title) + ); }); }) ); @@ -4258,7 +4269,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; @@ -4291,7 +4302,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index 078a32ae4a..f1aad2d0ae 100644 --- a/packages/vtable/src/ts-types/dataset/aggregation.ts +++ b/packages/vtable/src/ts-types/dataset/aggregation.ts @@ -306,11 +306,19 @@ export class CustomAggregator extends Aggregator { recalculate() { if (this.recalculateFromChildren && this.children?.length && this.field) { this.values = []; + this.records = []; this.children.forEach(child => { if (isValid(child.changedValue)) { - this.values.push(child.value()); + const value = child.value(); + this.values.push(value); + this.records.push({ [this.field as string]: value }); + } else if (child instanceof CustomAggregator && child.recalculateFromChildren) { + this.values.push(...child.values); + this.records.push(...child.records); } else { - this.values.push(...child.records.map(record => record[this.field as string])); + const records = child.records; + this.values.push(...records.map(record => record[this.field as string])); + this.records.push(...records); } }); } diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 6e1c48afc4..0bc3afebb5 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -631,6 +631,8 @@ export interface PivotChartConstructorOptions extends BaseTableConstructorOption * 数据集合, 平坦数据集合。另外一种特殊方式是传入分组后的数据,分组依据为指标 */ records?: any[] | Record; + /** 数据分析相关配置 */ + dataConfig?: IPivotTableDataConfig; /** * @deprecated 请使用resize.columnResizeType */ From a0fdfd90f02d26669b27e089ca5b8b5544862913 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 1 Sep 2026 18:19:20 +0800 Subject: [PATCH 52/65] fix: preserve data config in data path lookup Keep grouped grand total paths available to PivotChart data lookup. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../layout/get-data-cell-path.test.ts | 45 +++++++++++++++++++ .../src/tools/get-data-path/create-dataset.ts | 6 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts index beeac72e7a..2fff7b8ed2 100644 --- a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts +++ b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts @@ -7184,4 +7184,49 @@ describe('get-data-cell-path test', () => { cellLocation: 'body' }); }); + + test('get-data-cell-path returns the grouped grand total path', () => { + const data = { organization: '公司一', type: '银票', balance: 100 }; + const path = getDataCellPath( + { + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [data], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + showGrandTotalsOnTop: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }, + data + ); + + expect(path?.rowHeaderPaths[0]).toMatchObject({ + dimensionKey: 'organization', + value: '合计' + }); + expect(path?.rowHeaderPaths[0].dataValue).toContain('grouped_grand_total'); + }); }); diff --git a/packages/vtable/src/tools/get-data-path/create-dataset.ts b/packages/vtable/src/tools/get-data-path/create-dataset.ts index 30bf7fc551..3a5ed34d24 100644 --- a/packages/vtable/src/tools/get-data-path/create-dataset.ts +++ b/packages/vtable/src/tools/get-data-path/create-dataset.ts @@ -15,7 +15,7 @@ import type { IChartColumnIndicator } from '../../ts-types/pivot-table/indicator export function createDataset(options: PivotChartConstructorOptions) { const layoutNodeId = { seqId: 0 }; - const dataConfig: IPivotChartDataConfig = { isPivotChart: true }; + const dataConfig: IPivotChartDataConfig = { ...options.dataConfig, isPivotChart: true }; let columnDimensionTree; let rowDimensionTree; let columnTree; @@ -32,7 +32,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } rowDimensionTree = new DimensionTree((options.rowTree as ITreeLayoutHeadNode[]) ?? [], layoutNodeId); } - const rowKeys = rowDimensionTree.dimensionKeys?.count + const rowKeys = rowDimensionTree?.dimensionKeys?.count ? rowDimensionTree.dimensionKeys.valueArr() : options.rows?.reduce((keys, rowObj) => { if (typeof rowObj === 'string') { @@ -42,7 +42,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } return keys; }, []) ?? []; - const columnKeys = columnDimensionTree.dimensionKeys?.count + const columnKeys = columnDimensionTree?.dimensionKeys?.count ? columnDimensionTree.dimensionKeys.valueArr() : options.columns?.reduce((keys, columnObj) => { if (typeof columnObj === 'string') { From 9df5d486b23406d87b36e6f7102ea0fd499ee247 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 1 Sep 2026 18:37:00 +0800 Subject: [PATCH 53/65] fix: preserve resized columns during adaptive width Keep manually resized columns fixed during adaptive width redistribution. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../adaptive-width-resized-col.test.ts | 28 +++++++++++++++++++ .../scenegraph/layout/compute-col-width.ts | 8 ++++-- 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 packages/vtable/__tests__/adaptive-width-resized-col.test.ts diff --git a/packages/vtable/__tests__/adaptive-width-resized-col.test.ts b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts new file mode 100644 index 0000000000..e87baeead1 --- /dev/null +++ b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts @@ -0,0 +1,28 @@ +import { getAdaptiveWidth } from '../src/scenegraph/layout/compute-col-width'; + +describe('adaptive width with resized columns', () => { + test('keeps manually resized columns fixed while distributing remaining width', () => { + const widths = [120, 100, 100, 100]; + const table: any = { + internalProps: { + _widthResizedColMap: new Set([0]) + }, + options: {}, + defaultColWidth: 80, + getColWidth: jest.fn((col: number) => widths[col]), + getMaxColWidth: jest.fn(() => Number.MAX_SAFE_INTEGER), + getMinColWidth: jest.fn(() => 10), + getBodyColumnType: jest.fn(() => 'text'), + _adjustColWidth: jest.fn((_col: number, width: number) => width), + _setColWidth: jest.fn((col: number, width: number) => { + widths[col] = width; + }) + }; + + getAdaptiveWidth(800, 0, 4, false, [], table); + + expect(widths[0]).toBe(120); + expect(widths.slice(1)).toEqual([227, 227, 226]); + expect(widths.reduce((sum, width) => sum + width, 0)).toBe(800); + }); +}); diff --git a/packages/vtable/src/scenegraph/layout/compute-col-width.ts b/packages/vtable/src/scenegraph/layout/compute-col-width.ts index b7940eefe0..bcb502471d 100644 --- a/packages/vtable/src/scenegraph/layout/compute-col-width.ts +++ b/packages/vtable/src/scenegraph/layout/compute-col-width.ts @@ -493,9 +493,7 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { return ( - table.isListTable() && - !table.isHeader(col, row) && - !(table.internalProps.dataSource as any)?.dataSourceObj?.records + table.isListTable() && !table.isHeader(col, row) && !(table.internalProps.dataSource as any)?.dataSourceObj?.records ); } @@ -719,6 +717,10 @@ export function getAdaptiveWidth( const minWidthColumnMap = new Map(); for (let col = startCol; col < endColPlus1; col++) { const width = update ? newWidths[col] ?? table.getColWidth(col) : table.getColWidth(col); + if (table.internalProps._widthResizedColMap.has(col)) { + totalDrawWidth -= width; + continue; + } const maxWidth = table.getMaxColWidth(col); const minWidth = table.getMinColWidth(col); // if (width !== maxWidth && width !== minWidth) { From e82a378b91ea1221ed3f693ef57e7a6aa77c9179 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 14:15:37 +0800 Subject: [PATCH 54/65] fix: keep autoFillWidth resized columns fillable Limit resized-column freezing to adaptive width mode. Co-authored-by: Claude Sonnet 4.6 --- .../adaptive-width-resized-col.test.ts | 28 +++++++++++++++++++ .../scenegraph/layout/compute-col-width.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/vtable/__tests__/adaptive-width-resized-col.test.ts b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts index e87baeead1..169f960bec 100644 --- a/packages/vtable/__tests__/adaptive-width-resized-col.test.ts +++ b/packages/vtable/__tests__/adaptive-width-resized-col.test.ts @@ -8,6 +8,7 @@ describe('adaptive width with resized columns', () => { _widthResizedColMap: new Set([0]) }, options: {}, + widthMode: 'adaptive', defaultColWidth: 80, getColWidth: jest.fn((col: number) => widths[col]), getMaxColWidth: jest.fn(() => Number.MAX_SAFE_INTEGER), @@ -25,4 +26,31 @@ describe('adaptive width with resized columns', () => { expect(widths.slice(1)).toEqual([227, 227, 226]); expect(widths.reduce((sum, width) => sum + width, 0)).toBe(800); }); + + test('keeps autoFillWidth resized columns in width distribution', () => { + const widths = [85, 100, 180, 100]; + const table: any = { + internalProps: { + _widthResizedColMap: new Set([0]) + }, + options: {}, + widthMode: 'standard', + autoFillWidth: true, + defaultColWidth: 80, + getColWidth: jest.fn((col: number) => widths[col]), + getMaxColWidth: jest.fn(() => Number.MAX_SAFE_INTEGER), + getMinColWidth: jest.fn(() => 10), + getBodyColumnType: jest.fn(() => 'text'), + _adjustColWidth: jest.fn((_col: number, width: number) => width), + _setColWidth: jest.fn((col: number, width: number) => { + widths[col] = width; + }) + }; + + getAdaptiveWidth(800, 0, 4, false, [], table); + + expect(widths[0]).toBe(146); + expect(widths.slice(1)).toEqual([172, 310, 172]); + expect(widths.reduce((sum, width) => sum + width, 0)).toBe(800); + }); }); diff --git a/packages/vtable/src/scenegraph/layout/compute-col-width.ts b/packages/vtable/src/scenegraph/layout/compute-col-width.ts index bcb502471d..0999948ec0 100644 --- a/packages/vtable/src/scenegraph/layout/compute-col-width.ts +++ b/packages/vtable/src/scenegraph/layout/compute-col-width.ts @@ -717,7 +717,7 @@ export function getAdaptiveWidth( const minWidthColumnMap = new Map(); for (let col = startCol; col < endColPlus1; col++) { const width = update ? newWidths[col] ?? table.getColWidth(col) : table.getColWidth(col); - if (table.internalProps._widthResizedColMap.has(col)) { + if (table.widthMode === 'adaptive' && table.internalProps._widthResizedColMap.has(col)) { totalDrawWidth -= width; continue; } From 9047f5f5c0c64e671b5b7cb5352cb539b5c84e9c Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 15:46:22 +0800 Subject: [PATCH 55/65] fix: complete grouped grand total chart paths Preserve chart ranges and header spans for grouped grand totals. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/__tests__/pivotChart.test.ts | 9 ++++++++- packages/vtable/__tests__/pivotTable-analysis.test.ts | 1 + packages/vtable/src/dataset/dataset.ts | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index bba9a014b4..a21afd8943 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9292,13 +9292,20 @@ describe('pivotChart grouped grand total collected values', () => { } }); const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => - key.startsWith(String.fromCharCode(1)) + key.startsWith(`${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}`) ); + const groupedSubtotalKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${ + pivotChart.dataset.stringJoinChar + }${String.fromCharCode(1)}vtable_grouped_grand_total_subtotal`; expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ min: 400, max: 400 }); + expect(pivotChart.dataset.collectedValues.balance[groupedSubtotalKey]).toMatchObject({ + min: 400, + max: 400 + }); pivotChart.release(); }); }); diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index e039dbc6aa..4b70a45a99 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -658,6 +658,7 @@ describe('pivotTable grouped grand total edge cases', () => { ); const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + expect(grandTotalNode.levelSpan).toBe(1); expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); pivotTable.release(); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 679beff3b4..69da760950 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -706,6 +706,7 @@ export class Dataset { this.stringJoinChar ) ); + keys.add(join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar)); } }); return Array.from(keys); @@ -2462,7 +2463,7 @@ export class Dataset { value: grandTotalLabel, // getId(item?.id, 1), dataValue: grandTotalDimensionIndexes.length ? this.groupedGrandTotalKey : undefined, dimensionKey: rows[0], - levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, + levelSpan: Math.max(1, grandTotalDimensionIndexes[0] ?? subTotalFlags.length), children: [], role: 'grand-total' }; From 4d0f7b5487c4118ce978f71d0bf06e79729efbeb Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 18:13:12 +0800 Subject: [PATCH 56/65] fix: handle grouped grand total review regressions Use filtered records and ordered internal paths so grouped totals retain correct dimensions, chart ranges, custom values, and tree cell lookups. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/__tests__/pivotChart.test.ts | 9 ++-- .../__tests__/pivotTable-analysis.test.ts | 28 +++++++++++- packages/vtable/src/dataset/dataset.ts | 45 ++++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 26 ++++++----- 4 files changed, 74 insertions(+), 34 deletions(-) diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index a21afd8943..f61199a49c 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9251,7 +9251,7 @@ function createTable(containerDom) { } describe('pivotChart grouped grand total collected values', () => { - test('collects chart ranges under grouped grand total paths', () => { + test('collects chart ranges under grouped grand total paths while ignoring external total records', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; containerDom.style.width = '500px'; @@ -9278,7 +9278,8 @@ describe('pivotChart grouped grand total collected values', () => { indicatorsAsCol: false, records: [ { organization: '公司一', type: '银票', balance: 100 }, - { organization: '公司二', type: '银票', balance: 300 } + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } ], dataConfig: { totals: { @@ -9291,9 +9292,7 @@ describe('pivotChart grouped grand total collected values', () => { } } }); - const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => - key.startsWith(`${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}`) - ); + const groupedKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}银票`; const groupedSubtotalKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${ pivotChart.dataset.stringJoinChar }${String.fromCharCode(1)}vtable_grouped_grand_total_subtotal`; diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index 4b70a45a99..95cf45e311 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -547,6 +547,7 @@ describe('pivotTable grouped grand total edge cases', () => { showSubTotalsOnTreeNode?: boolean; aggregationRules?: any[]; sortRules?: any[]; + filterRules?: any[]; } = {} ) { const containerDom: HTMLElement = createDiv(); @@ -565,6 +566,7 @@ describe('pivotTable grouped grand total edge cases', () => { updateAggregationOnEditCell: true, aggregationRules: options.aggregationRules, sortRules: options.sortRules, + filterRules: options.filterRules, totals: { row: { showGrandTotals: true, @@ -664,6 +666,24 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('only uses filtered records to determine active grouped total dimensions', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + filterRules: [{ filterFunc: record => record.organization !== '公司一' }] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + test('keeps column values matching the grand total label separate from grouped totals', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; @@ -944,7 +964,8 @@ describe('pivotTable grouped grand total edge cases', () => { [ { organization: '公司一', category: '票据', type: '银票', balance: 100 }, { organization: '公司二', category: '票据', type: '商票', balance: 300 }, - { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 } + { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 }, + { category: '票据', balance: 999 } ], { rows: ['organization', 'category', 'type'], @@ -958,7 +979,7 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); expect(grandTotalNode.children[0].levelSpan).toBe(1); - expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(999); expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); @@ -1031,6 +1052,9 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['小计', 'B', 'A']); expect(pivotTable.dataset.getAggregator(['合计', 'B'], [], 'balance').value()).toBe(200); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', 'B'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); pivotTable.release(); }); }); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 69da760950..bafb5c4cb1 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -563,7 +563,9 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } - const records = Array.isArray(this.records) ? this.records : Object.values(this.records).flat(); + const records = (Array.isArray(this.records) ? this.records : Object.values(this.records).flat()).filter( + record => !isNeedFilter || this.filterRecord(record) + ); this.rowsHasValue = this.rows.map(row => records.some(record => record && row in record)); this.columnsHasValue = this.columns.map(column => records.some(record => record && column in record)); //常规records是数组的情况 @@ -719,18 +721,6 @@ export class Dataset { record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); } }); - //#region 按照collectValuesBy 收集维度值 - for (const field in this.collectValuesBy) { - if (isValid(record[field])) { - const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); - this.collectValue(field, collectKeys, record); - this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { - this.collectValue(field, groupedCollectKeys, record); - }); - } - } - //#endregion - let isToTalRecord = false; //#region 收集rowKey colKey // 原先的逻辑不关心customRowTree 只是根据rows 从record上收集维度path。现在考虑了rowTree和colTree的传入,需要依据colTree的真实定义的path来给数据做对应关系。 @@ -892,6 +882,19 @@ export class Dataset { } } //#endregion + //#region 按照collectValuesBy 收集维度值 + if (!isToTalRecord) { + for (const field in this.collectValuesBy) { + if (isValid(record[field])) { + const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); + this.collectValue(field, collectKeys, record); + this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { + this.collectValue(field, groupedCollectKeys, record); + }); + } + } + } + //#endregion //#region 对path的数组 rowKeys和colKeys 做双重循环 for (let row_i = 0; row_i < rowKeys.length; row_i++) { const rowKey = rowKeys[row_i].rowKey; @@ -2125,12 +2128,24 @@ export class Dataset { const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); if ( !dimensionIndexes.length || - !dimensionIndexes.every(index => dimensions[index] in record) || dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) ) { return undefined; } - return [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])]; + const prefixIndexes: number[] = []; + for (const index of dimensionIndexes) { + if (!(dimensions[index] in record)) { + break; + } + prefixIndexes.push(index); + } + if ( + !prefixIndexes.length || + dimensionIndexes.slice(prefixIndexes.length).some(index => dimensions[index] in record) + ) { + return undefined; + } + return [this.groupedGrandTotalKey, ...prefixIndexes.map(index => record[dimensions[index]])]; } private getGroupedGrandTotalPaths( diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 19b587b9bc..37b07a80fc 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3630,7 +3630,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { return ( (hd?.field === rowDimension.dimensionKey || hd?.field === rowDimension.indicatorKey) && (isValid(rowDimension.dataValue) - ? ((hd as any).dataValue ?? hd.title) === rowDimension.dataValue + ? ((hd as any).dataValue ?? (hd as any).define?.dataValue ?? hd.title) === rowDimension.dataValue : hd?.title === rowDimension.value) ); }) @@ -3646,20 +3646,22 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { const fullCellIds = this.findFullCellIds(pathIds); return ( fullCellIds.length === rowHeaderPaths.length && - fullCellIds.every(id => { + fullCellIds.every((id, index) => { const curHd = this._headerObjectMap[id]; - return rowHeaderPaths.find(rowDimensionPath => { - return ( - rowDimensionPath.dimensionKey === curHd.field && - (isValid(rowDimensionPath.dataValue) - ? ((curHd as any).dataValue ?? curHd.title) === rowDimensionPath.dataValue - : rowDimensionPath.value === curHd.title) - ); - }); + const rowDimensionPath = rowHeaderPaths[index]; + return ( + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === + rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title) + ); }) ); }); - row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + if (findedCellIdPath) { + row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + } } else { rowDimensionFinded = this.matchDimensionPath(rowHeaderPaths, this.rowTree, needLowestLevel_rowPaths, true) as | ITreeLayoutHeadNode @@ -3674,7 +3676,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { } } // 通过dimension获取col和row - if (rowDimensionFinded || forceBody) { + if (rowDimensionFinded || (forceBody && !isValid(row))) { row = this.columnHeaderLevelCount; const { startInTotal, afterSpanLevel } = (rowDimensionFinded as ITreeLayoutHeadNode) ?? defaultDimension; row += startInTotal ?? 0; From 8e5a5341d7d34f28c2a68a144ec8cace8cafda47 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 19:18:16 +0800 Subject: [PATCH 57/65] fix: preserve tree header path lookup compatibility Retain legacy value-only tree matching while using ordered internal values for grouped grand total header paths. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../vtable/src/layout/pivot-header-layout.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 37b07a80fc..4220b87f9b 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3644,19 +3644,19 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { // 从上述过程中找到的pathCellIds中找到正确匹配完整路径rowHeaderPaths的一个 然后计算row行号 const findedCellIdPath = findedCellIdPaths.find(pathIds => { const fullCellIds = this.findFullCellIds(pathIds); + const matchHeaderPath = (curHd: HeaderData, rowDimensionPath: IDimensionInfo) => + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === + rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title); return ( fullCellIds.length === rowHeaderPaths.length && - fullCellIds.every((id, index) => { - const curHd = this._headerObjectMap[id]; - const rowDimensionPath = rowHeaderPaths[index]; - return ( - rowDimensionPath.dimensionKey === curHd.field && - (isValid(rowDimensionPath.dataValue) - ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === - rowDimensionPath.dataValue - : rowDimensionPath.value === curHd.title) - ); - }) + (rowHeaderPaths.some(rowDimensionPath => isValid(rowDimensionPath.dataValue)) + ? fullCellIds.every((id, index) => matchHeaderPath(this._headerObjectMap[id], rowHeaderPaths[index])) + : fullCellIds.every(id => + rowHeaderPaths.some(rowDimensionPath => matchHeaderPath(this._headerObjectMap[id], rowDimensionPath)) + )) ); }); if (findedCellIdPath) { From d3555ce634a9762d2ceb43e2a11183747ab13d3a Mon Sep 17 00:00:00 2001 From: kitee0325 <30969393+kitee0325@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:34:49 +0800 Subject: [PATCH 58/65] =?UTF-8?q?feat(theme):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...at-set-default-theme_2026-09-09-03-47.json | 11 +++ docs/assets/guide/en/theme_and_style/theme.md | 31 ++++++++ docs/assets/guide/zh/theme_and_style/theme.md | 31 ++++++++ .../__tests__/default-theme-table.test.ts | 69 ++++++++++++++++++ .../vtable/__tests__/default-theme.test.ts | 72 +++++++++++++++++++ packages/vtable/src/themes.ts | 20 ++++-- 6 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json create mode 100644 packages/vtable/__tests__/default-theme-table.test.ts create mode 100644 packages/vtable/__tests__/default-theme.test.ts diff --git a/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json b/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json new file mode 100644 index 0000000000..d55e6aee08 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: add themes.setDefaultTheme for application-wide default styling", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "30969393+kitee0325@users.noreply.github.com" +} \ No newline at end of file diff --git a/docs/assets/guide/en/theme_and_style/theme.md b/docs/assets/guide/en/theme_and_style/theme.md index 93f24c29fd..c94eb48dbf 100644 --- a/docs/assets/guide/en/theme_and_style/theme.md +++ b/docs/assets/guide/en/theme_and_style/theme.md @@ -1198,3 +1198,34 @@ const tableInstance = new VTable.PivotTable(option); ``` In short, in VTable, by flexibly using Theme configuration items, we can easily create an exclusive data table style that meets our needs. Please refer to this tutorial to make reasonable configurations based on actual scenarios, and give full play to VTable's powerful Theme customization capabilities. + +## Set the default theme + +Use `VTable.themes.setDefaultTheme(theme)` during application or component-library initialization to configure the theme used when a table has no explicit `theme`. This applies to ListTable, PivotTable and PivotChart. + +```javascript +const originalDefault = VTable.themes.DEFAULT; +VTable.themes.setDefaultTheme( + originalDefault.extends({ + defaultStyle: { fontFamily: 'Arial, sans-serif' }, + bodyStyle: { color: '#243447' }, + headerStyle: { bgColor: '#f2f4f7' } + }) +); + +// Uses the configured default without specifying option.theme. +const table = new VTable.ListTable({ container, columns, records }); + +// Extends the configured default using the existing theme API. +const customTheme = VTable.themes.DEFAULT.extends({ headerStyle: { color: '#345678' } }); + +// Restore the original default for subsequent operations. +VTable.themes.setDefaultTheme(originalDefault); +``` + +The argument is an `ITableThemeDefine` object or a `TableTheme`. It **replaces** the previous default; use `themes.DEFAULT.extends(...)` explicitly to merge with the current default. The function returns `void`. + +- `themes.DEFAULT`, the internal default fallback and the built-in `DEFAULT` name resolve to the new theme. User-registered names retain their existing lookup priority, including a user registration named `DEFAULT`. +- Existing instances and themes created earlier with `extends()` retain their theme objects and are not automatically redrawn. A later `updateOption()` without `theme` uses the current default. Use `table.updateTheme(VTable.themes.DEFAULT)` to explicitly apply it to an existing instance. +- Explicit instance themes, other built-in themes, theme registration and `updateTheme()` keep their existing behavior. Standalone partial themes do not inherit the configured default; use `extends()` when inheritance is intended. +- The setting is shared by callers using the same VTable runtime. Configure it before creating tables. It is not an instance-scoped setting or a mechanism for automatically switching all existing tables. diff --git a/docs/assets/guide/zh/theme_and_style/theme.md b/docs/assets/guide/zh/theme_and_style/theme.md index 3fa9bf4614..b3e70bedb1 100644 --- a/docs/assets/guide/zh/theme_and_style/theme.md +++ b/docs/assets/guide/zh/theme_and_style/theme.md @@ -1197,3 +1197,34 @@ const tableInstance = new VTable.PivotTable(document.getElementById(CONTAINER_ID ``` 总之,在 VTable 中,通过灵活运用主题配置项,我们可以轻松打造出专属的、符合需求的数据表格样式。请参照本教程,结合实际场景进行合理配置,发挥 VTable 强大的主题定制能力。 + +## 设置默认主题 + +应用或组件库可以在初始化时调用 `VTable.themes.setDefaultTheme(theme)`,配置表格未显式指定 `theme` 时使用的默认主题。该设置适用于 ListTable、PivotTable 和 PivotChart。 + +```javascript +const originalDefault = VTable.themes.DEFAULT; +VTable.themes.setDefaultTheme( + originalDefault.extends({ + defaultStyle: { fontFamily: 'Arial, sans-serif' }, + bodyStyle: { color: '#243447' }, + headerStyle: { bgColor: '#f2f4f7' } + }) +); + +// 无需传入 option.theme,即可使用配置后的默认主题。 +const table = new VTable.ListTable({ container, columns, records }); + +// 通过原有主题 API 继承配置后的默认主题。 +const customTheme = VTable.themes.DEFAULT.extends({ headerStyle: { color: '#345678' } }); + +// 恢复原始默认主题,供后续操作使用。 +VTable.themes.setDefaultTheme(originalDefault); +``` + +参数为 `ITableThemeDefine` 对象或 `TableTheme`,会**替换**上一次设置的默认主题;如需与当前默认主题合并,请显式使用 `themes.DEFAULT.extends(...)`。函数返回 `void`。 + +- `themes.DEFAULT`、内部默认回退和内置名称 `DEFAULT` 使用新的主题。用户注册的主题名称仍遵循原有查询优先级,包括用户注册的同名 `DEFAULT`。 +- 已有实例和此前通过 `extends()` 创建的主题保留其主题对象,不会自动重绘。之后调用未指定 `theme` 的 `updateOption()` 时使用当前默认主题;如需显式应用到已有实例,可调用 `table.updateTheme(VTable.themes.DEFAULT)`。 +- 显式指定的实例主题、其他内置主题、主题注册和 `updateTheme()` 保持原有行为。独立的局部主题不会继承配置后的默认值;需要继承时请使用 `extends()`。 +- 此设置由使用同一份 VTable 运行时的调用方共享,建议在创建表格前完成配置。它不是实例级设置,也不会自动切换所有已有表格的主题。 diff --git a/packages/vtable/__tests__/default-theme-table.test.ts b/packages/vtable/__tests__/default-theme-table.test.ts new file mode 100644 index 0000000000..1af75f5c8e --- /dev/null +++ b/packages/vtable/__tests__/default-theme-table.test.ts @@ -0,0 +1,69 @@ +import { ListTable, PivotTable, PivotChart, themes } from '../src'; +import { createDiv, removeDom } from './dom'; + +describe('default theme instance behavior', () => { + const original = themes.DEFAULT; + const tables: (ListTable | PivotTable | PivotChart)[] = []; + const containers: HTMLElement[] = []; + const columns = [{ field: 'name', title: 'Name' }]; + const records = [{ name: 'Example' }]; + + function container() { + const element = createDiv(); + element.style.width = '600px'; + element.style.height = '400px'; + containers.push(element); + return element; + } + + afterEach(() => { + tables.forEach(table => table.release()); + tables.length = 0; + containers.forEach(removeDom); + containers.length = 0; + themes.setDefaultTheme(original); + }); + + test('uses the configured default for all table types and both constructor forms', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + tables.push( + new ListTable(container(), { columns, records }), + new ListTable({ container: container(), columns, records }), + new PivotTable(container(), { records: [] }), + new PivotTable({ container: container(), records: [] }), + new PivotChart(container(), { records: [] }), + new PivotChart({ container: container(), records: [] }) + ); + tables.forEach(table => expect(table.theme.bodyStyle.color).toBe('#123456')); + }); + + test('leaves existing instances unchanged and uses the new default on updateOption', () => { + const table = new ListTable(container(), { columns, records }); + tables.push(table); + const previous = table.theme; + const previousColor = previous.bodyStyle.color; + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + + expect(table.theme).toBe(previous); + expect(table.theme.bodyStyle.color).toBe(previousColor); + table.updateOption({ columns, records }); + expect(table.theme.bodyStyle.color).toBe('#123456'); + }); + + test('preserves explicit themes, replacement updates and property assignment', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + const custom = themes.DARK.extends({ bodyStyle: { color: '#654321' } }); + const table = new ListTable(container(), { columns, records, theme: custom }); + tables.push(table); + + expect(table.theme.bodyStyle.color).toBe('#654321'); + table.updateTheme({ headerStyle: { color: '#abcdef' } }); + expect(table.theme.bodyStyle.color).toBe(themes.of({}).bodyStyle.color); + table.theme = custom; + expect(table.theme.bodyStyle.color).toBe('#654321'); + table.updateTheme(themes.DEFAULT); + expect(table.theme.bodyStyle.color).toBe('#123456'); + table.updateOption({ columns, records, theme: custom }); + expect(table.theme.bodyStyle.color).toBe('#654321'); + }); +}); diff --git a/packages/vtable/__tests__/default-theme.test.ts b/packages/vtable/__tests__/default-theme.test.ts new file mode 100644 index 0000000000..345479546f --- /dev/null +++ b/packages/vtable/__tests__/default-theme.test.ts @@ -0,0 +1,72 @@ +import internalThemes, * as themes from '../src/themes'; +import { theme as registerTheme, clearAll } from '../src/register'; + +describe('setDefaultTheme', () => { + const original = themes.DEFAULT; + + afterEach(() => { + clearAll(); + themes.setDefaultTheme(original); + }); + + test('keeps the public export, internal fallback and name lookup in sync', () => { + const registry = themes.get(); + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + + expect(internalThemes.DEFAULT).toBe(themes.DEFAULT); + expect(registry.DEFAULT).toBe(themes.DEFAULT); + expect(themes.of('default')).toBe(themes.DEFAULT); + expect(themes.DEFAULT.bodyStyle.color).toBe('#123456'); + expect(themes.DEFAULT.extends({ headerStyle: { color: '#654321' } }).bodyStyle.color).toBe('#123456'); + }); + + test('replaces styles without reusing previously resolved caches or accumulating overrides', () => { + themes.setDefaultTheme(original.extends({ bodyStyle: { color: '#123456' } })); + const previous = themes.DEFAULT; + const extended = previous.extends({ headerStyle: { color: '#654321' } }); + expect(previous.bodyStyle.color).toBe('#123456'); + + themes.setDefaultTheme(original.extends({ headerStyle: { color: '#abcdef' } })); + + expect(themes.DEFAULT).not.toBe(previous); + expect(themes.DEFAULT.bodyStyle.color).toBe(original.bodyStyle.color); + expect(themes.DEFAULT.headerStyle.color).toBe('#abcdef'); + expect(previous.bodyStyle.color).toBe('#123456'); + expect(extended.bodyStyle.color).toBe('#123456'); + }); + + test('preserves inherited values and callback styles when installing a TableTheme', () => { + const bgColor = () => '#abcdef'; + const parent = original.extends({ bodyStyle: { color: '#123456', bgColor } }); + const child = parent.extends({ headerStyle: { color: '#654321' } }); + themes.setDefaultTheme(child); + + expect(themes.DEFAULT).not.toBe(child); + expect(themes.DEFAULT.bodyStyle.color).toBe('#123456'); + expect(themes.DEFAULT.bodyStyle.bgColor).toBe(bgColor); + expect(themes.DEFAULT.headerStyle.color).toBe('#654321'); + }); + + test('does not merge the default into standalone or registered themes', () => { + const custom = { headerStyle: { color: '#654321' } }; + const fallbackColor = themes.of(custom).bodyStyle.color; + registerTheme('custom-default-test', custom); + themes.setDefaultTheme({ defaultStyle: { color: '#123456' } }); + + expect(themes.of(custom).bodyStyle.color).toBe(fallbackColor); + expect(themes.of('custom-default-test').bodyStyle.color).toBe(fallbackColor); + expect(themes.of('DARK')).toBe(themes.DARK); + expect(themes.of('missing-default-test')).toBeNull(); + }); + + test('keeps the existing registered-name precedence and clear behavior', () => { + const registered = original.extends({ bodyStyle: { color: '#654321' } }); + registerTheme('DEFAULT', registered); + themes.setDefaultTheme({ defaultStyle: { color: '#123456' } }); + + expect(themes.of('DEFAULT')).toBe(registered); + expect(internalThemes.DEFAULT.bodyStyle.color).toBe('#123456'); + clearAll(); + expect(themes.of('DEFAULT')).toBe(themes.DEFAULT); + }); +}); diff --git a/packages/vtable/src/themes.ts b/packages/vtable/src/themes.ts index 4b36d241af..1a5c1afa4c 100644 --- a/packages/vtable/src/themes.ts +++ b/packages/vtable/src/themes.ts @@ -11,7 +11,7 @@ import type { ITableThemeDefine } from './ts-types'; export const DARK = new TableTheme(darkTheme, darkTheme); export const BRIGHT = new TableTheme(brightTheme, brightTheme); export const ARCO = new TableTheme(arcoTheme, arcoTheme); -export const DEFAULT = new TableTheme(defaultTheme, defaultTheme); +export let DEFAULT = new TableTheme(defaultTheme, defaultTheme); export const SIMPLIFY = new TableTheme(materialDesignTheme, materialDesignTheme); const builtin: { [key: string]: TableTheme } = { @@ -21,7 +21,16 @@ const builtin: { [key: string]: TableTheme } = { DARK, BRIGHT }; -// let defTheme = DEFAULT; +/** + * Replace the default theme used when no instance theme is specified. + * Existing instances and previously extended themes are not updated. + * The supplied theme replaces the default; use DEFAULT.extends() to merge styles. + */ +export function setDefaultTheme(theme: ITableThemeDefine): void { + DEFAULT = theme instanceof TableTheme ? theme.extends({}) : new TableTheme(theme, theme); + builtin.DEFAULT = DEFAULT; +} + export const theme = { TableTheme }; export function of(value: ITableThemeDefine | string | undefined | null): TableTheme | null { if (!value) { @@ -52,9 +61,12 @@ export default { DARK, BRIGHT, ARCO, - DEFAULT, + get DEFAULT() { + return DEFAULT; + }, SIMPLIFY, theme, of, - get + get, + setDefaultTheme }; From ac47c7def5448a4572a58fee1260c94b26708d28 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 10 Sep 2026 18:22:08 +0800 Subject: [PATCH 59/65] test: ensure grouped total sorting is exercised Reverse the fixture order so the assertion fails unless indicator sorting actually runs. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/__tests__/pivotTable-analysis.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index 95cf45e311..980936d15c 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -997,9 +997,9 @@ describe('pivotTable grouped grand total edge cases', () => { test('sorts non-leaf grouped totals by their own aggregated values', () => { const pivotTable = createPivotTable( [ + { organization: '公司三', category: '贷款', type: '信用贷', balance: 500 }, { organization: '公司一', category: '票据', type: '银票', balance: 100 }, - { organization: '公司二', category: '票据', type: '商票', balance: 600 }, - { organization: '公司三', category: '贷款', type: '信用贷', balance: 500 } + { organization: '公司二', category: '票据', type: '商票', balance: 600 } ], { rows: ['organization', 'category', 'type'], From 4243b54c09d7df7fe81882ea69fa71f94572b4c3 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 11 Sep 2026 15:04:01 +0800 Subject: [PATCH 60/65] test: add issue 4186 nested field edit demo Expose whether edits update the nested source object or create a flat dotted key. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../debug/issue-4186-nested-field-edit.ts | 94 +++++++++++++++++++ packages/vtable/examples/menu.ts | 4 + 2 files changed, 98 insertions(+) create mode 100644 packages/vtable/examples/debug/issue-4186-nested-field-edit.ts diff --git a/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts new file mode 100644 index 0000000000..85a42ad2d3 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts @@ -0,0 +1,94 @@ +import * as VTable from '../../src'; +import { InputEditor } from '@visactor/vtable-editors'; + +const CONTAINER_ID = 'vTable'; +const FIELD = 'facts.2025-02.qty'; +const inputEditor = new InputEditor({}); +VTable.register.editor('issue4186-input', inputEditor); + +const records = [ + { + id: 1, + facts: { + '2025-02': { + qty: 10 + } + } + } +]; + +const updateResult = (status: HTMLElement, source: HTMLElement) => { + const record = records[0] as any; + const nestedValue = record.facts?.['2025-02']?.qty; + const flatValue = record[FIELD]; + const pass = nestedValue === 18 && !Object.prototype.hasOwnProperty.call(record, FIELD); + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | nested=${nestedValue}, flat=${String(flatValue)}`; + status.style.color = pass ? '#237804' : '#a8071a'; + source.textContent = JSON.stringify(record, null, 2); + return pass; +}; + +const createControls = () => { + const container = document.getElementById(CONTAINER_ID)!; + const controls = document.createElement('div'); + controls.style.cssText = 'margin-bottom: 12px; font: 13px/1.5 sans-serif;'; + + const runButton = document.createElement('button'); + runButton.textContent = 'Run #4186 check'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset'; + resetButton.style.marginLeft = '8px'; + + const status = document.createElement('span'); + status.id = 'issue4186Status'; + status.style.marginLeft = '12px'; + status.textContent = 'READY | nested=10'; + + const source = document.createElement('pre'); + source.id = 'issue4186Source'; + source.style.cssText = 'margin: 8px 0 0; padding: 8px; background: #f5f5f5;'; + source.textContent = JSON.stringify(records[0], null, 2); + + controls.append(runButton, resetButton, status, source); + container.parentElement?.insertBefore(controls, container); + + return { runButton, resetButton, status, source }; +}; + +export function createTable() { + const controls = createControls(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, { + records, + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: FIELD, title: '2025-02 Quantity', width: 220, editor: 'issue4186-input' } + ], + editCellTrigger: 'doubleclick', + widthMode: 'standard', + defaultRowHeight: 40 + }); + + const runCheck = async () => { + tableInstance.changeCellValue(1, tableInstance.columnHeaderLevelCount, '18'); + await new Promise(resolve => requestAnimationFrame(resolve)); + const pass = updateResult(controls.status, controls.source); + (window as any).BUGSERVER_SCREENSHOT?.(); + return pass; + }; + + controls.runButton.onclick = runCheck; + controls.resetButton.onclick = () => { + records[0].facts['2025-02'].qty = 10; + delete (records[0] as any)[FIELD]; + tableInstance.setRecords(records); + controls.status.textContent = 'READY | nested=10'; + controls.status.style.color = ''; + controls.source.textContent = JSON.stringify(records[0], null, 2); + }; + tableInstance.on('change_cell_value', () => updateResult(controls.status, controls.source)); + + (window as any).tableInstance = tableInstance; + (window as any).issue4186Run = runCheck; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6a43a63cee..e8e5c4d953 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -74,6 +74,10 @@ export const menus = [ path: 'debug', name: 'issue-3752-large-merge-auto-height' }, + { + path: 'debug', + name: 'issue-4186-nested-field-edit' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' From 2397f10e5ee5cf8f00617c38771c58c903735f9c Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 12:07:21 +0800 Subject: [PATCH 61/65] fix(vtable): update nested fields by path --- ...-nested-field-update_2026-09-04-10-30.json | 11 ++ .../listTable-nested-field-update.test.ts | 80 +++++++++++++ .../data-source-nested-field-update.test.ts | 106 +++++++++++++++++ packages/vtable/src/ListTable.ts | 28 +++-- packages/vtable/src/data/DataSource.ts | 112 +++++++++++++++--- 5 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json create mode 100644 packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts create mode 100644 packages/vtable/__tests__/data/data-source-nested-field-update.test.ts diff --git a/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json b/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json new file mode 100644 index 0000000000..a59754ebe9 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: update nested fields by path", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "biukam.w@gmail.com" +} diff --git a/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts new file mode 100644 index 0000000000..36eafe3ebd --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts @@ -0,0 +1,80 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv } from '../dom'; + +describe('ListTable nested field updates', () => { + test('updates a nested field and reports its old and changed values', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, field, '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + row: 1, + recordIndex: 0, + field, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); + + test('updates nested fields in a batch change and reports the aggregate event', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + + table.changeCellValuesByRecords([{ recordIndex: 0, field, value: '12' }], { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(cellEvents).toHaveLength(1); + expect(cellEvents[0].changedValue).toBe(12); + expect(batchEvents).toHaveLength(1); + expect(batchEvents[0].values).toEqual(cellEvents); + table.release(); + }); + + test('updates an array field path and reports its old and changed values', () => { + const field = ['facts', '2025-02', 'qty']; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, [...field], '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + recordIndex: 0, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts new file mode 100644 index 0000000000..b8ad980685 --- /dev/null +++ b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts @@ -0,0 +1,106 @@ +// @ts-nocheck +import { DataSource, getField, getRecordFieldValue } from '../../src/data/DataSource'; + +describe('DataSource nested field updates', () => { + test('updates a dotted field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('keeps array paths distinct from comma-delimited literal keys', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts,2025-02,qty': 11 }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + const table = { leftRowSeriesNumberCount: 0 } as any; + + dataSource.changeFieldValueByRecordIndex(18, 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBe(11); + expect(getRecordFieldValue(records[0], field)).toBe(18); + expect(getField(records[0], field, 0, 0, table, () => undefined)).toBe(18); + dataSource.release(); + }); + + test('updates a dotted field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('creates missing objects while updating a dotted field path', () => { + const records = [{}]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]).toEqual({ facts: { '2025-02': { qty: 18 } } }); + dataSource.release(); + }); + + test('prefers an existing literal dotted field over path traversal', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts.2025-02.qty': 11 }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]['facts.2025-02.qty']).toBe(18); + expect(records[0].facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('does not mutate inherited objects while creating a nested field path', () => { + const inherited = { facts: { '2025-02': { qty: 10 } } }; + const record = Object.create(inherited); + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(record).toHaveProperty('facts', { '2025-02': { qty: 18 } }); + expect(inherited.facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('recognizes nested fields in hasField', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 'facts.2025-02.qty')).toBe(true); + dataSource.release(); + }); +}); diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index b3bb28e128..85c3fb7e76 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -18,7 +18,7 @@ import type { } from './ts-types'; import { HierarchyState } from './ts-types'; import { SimpleHeaderLayoutMap } from './layout'; -import { isArray, isValid } from '@visactor/vutils'; +import { arrayEqual, isArray, isValid } from '@visactor/vutils'; import { _setDataSource, _setRecords, @@ -49,7 +49,7 @@ import type { IEmptyTipComponent } from './components/empty-tip/empty-tip'; import { Factory } from './core/factory'; import { getGroupByDataConfig } from './core/group-helper'; import { DataSource, type CachedDataSource } from './data'; -import { getValueFromDeepArray } from './data/DataSource'; +import { getRecordFieldValue, getValueFromDeepArray } from './data/DataSource'; import { listTableAddRecord, listTableAddRecords, @@ -100,6 +100,10 @@ function clearLayoutColumnState(columns: ColumnsDefine | undefined) { }); } +function isSameField(left: FieldDef, right: FieldDef): boolean { + return left === right || (Array.isArray(left) && Array.isArray(right) && arrayEqual(left, right)); +} + // registerAxis(); // registerEmptyTip(); // registerLegend(); @@ -703,7 +707,7 @@ export class ListTable extends BaseTable implements ListTableAPI { return this.dataSource.getTableIndex(recordIndex) + this.columnHeaderLevelCount; } getTableIndexByField(field: FieldDef) { - const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (!colObj) { return -1; } @@ -1163,7 +1167,7 @@ export class ListTable extends BaseTable implements ListTableAPI { */ getCellRangeByField(field: FieldDef, index: number): CellRange | null { const { layoutMap } = this.internalProps; - const colObj = layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (colObj) { const layoutRange = layoutMap.getBodyLayoutRangeById(colObj.id); let startRow; @@ -1938,9 +1942,9 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); @@ -1950,7 +1954,9 @@ export class ListTable extends BaseTable implements ListTableAPI { } const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const cellAddr = this.getCellAddrByFieldRecord(field, recordIndex); @@ -2028,16 +2034,18 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); if (triggerEvent) { const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const changeValue = { col: (this.getCellAddrByFieldRecord(field, recordIndex)?.col ?? -1) as number, diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index d24368e13f..b154167e5f 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -100,6 +100,10 @@ export function getField( const colIndex = col - table.leftRowSeriesNumberCount; return record[colIndex]; } + if (Array.isArray(fieldGet)) { + const fieldResult = getValueByPath(record, [...fieldGet]); + return getValue(fieldResult, promiseCallBack); + } if (isObject(record) && fieldGet in (record as any)) { const fieldResult = (record as any)[fieldGet]; @@ -109,10 +113,6 @@ export function getField( const fieldResult = fieldGet(record, col, row, table); return getValue(fieldResult, promiseCallBack); } - if (Array.isArray(fieldGet)) { - const fieldResult = getValueByPath(record, [...fieldGet]); - return getValue(fieldResult, promiseCallBack); - } const fieldArray = `${fieldGet}`.split('.'); if (fieldArray.length <= 1) { const fieldResult = (record as any)[fieldGet]; @@ -126,6 +126,90 @@ export function getField( return getValue(fieldResult, promiseCallBack); } +function getRecordFieldPath(field: FieldDef | number): string[] | undefined { + if (Array.isArray(field)) { + return field; + } + if (typeof field === 'string' && field.includes('.')) { + return field.split('.'); + } + return undefined; +} + +function hasRecordField(record: any, field: FieldDef | number): boolean { + if (!isObject(record)) { + return false; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && (field as any) in record) { + return true; + } + if (!path) { + return false; + } + let target = record; + for (const key of path) { + if (!isObject(target) || !(key in target)) { + return false; + } + target = target[key]; + } + return true; +} + +export function getRecordFieldValue(record: any, field: FieldDef | number): any { + if (record === null || record === undefined) { + return undefined; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && isObject(record) && (field as any) in record) { + return record[field as any]; + } + if (!path) { + return record[field as any]; + } + return path.reduce((current, key) => { + return current === null || current === undefined ? undefined : current[key]; + }, record); +} + +function setRecordProperty(record: any, key: string, value: any): void { + if (key === '__proto__') { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true + }); + return; + } + record[key] = value; +} + +export function setRecordFieldValue(record: any, field: FieldDef | number, value: FieldData): void { + if (record === null || record === undefined) { + return; + } + const path = getRecordFieldPath(field); + if (!path || (!Array.isArray(field) && isObject(record) && (field as any) in record)) { + record[field as any] = value; + return; + } + + if (path.length === 0) { + return; + } + let target = record; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!Object.prototype.hasOwnProperty.call(target, key) || target[key] === null || typeof target[key] !== 'object') { + setRecordProperty(target, key, {}); + } + target = target[key]; + } + setRecordProperty(target, path[path.length - 1], value); +} + function _getIndex(sortedIndexMap: null | (number | number[])[], index: number): number | number[] { if (!sortedIndexMap) { return index; @@ -744,8 +828,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (field === undefined || field === '') { field = col - table.leftRowSeriesNumberCount; } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(dataIndex.toString())?.[field as any]; // this.getOriginalField(index, field, col, row, table); + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(dataIndex.toString()), field); const record = this.getOriginalRecord(dataIndex); let formatValue = value; if (typeof beforeChangedValue === 'number' && isAllDigits(value)) { @@ -754,7 +838,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (isPromise(record)) { return record .then(record => { - record[field as string | number] = formatValue; + setRecordFieldValue(record, field, formatValue); return formatValue; }) .catch((err: Error) => { @@ -763,10 +847,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { }); } if (record) { - record[field] = formatValue; + setRecordFieldValue(record, field, formatValue); } else { this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; - this.records[dataIndex as number][field] = formatValue; + setRecordFieldValue(this.records[dataIndex as number], field, formatValue); } } } @@ -802,8 +886,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { ); } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(rawKey)?.[field as any]; + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(rawKey), field); const rawRecords = Array.isArray((this.dataSourceObj as any)?.records) ? (this.dataSourceObj as any).records : null; @@ -817,10 +901,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { formatValue = parseFloat(value); } if (record) { - record[field] = formatValue; + setRecordFieldValue(record, field, formatValue); } else if (rawRecords && typeof recordIndex === 'number') { rawRecords[recordIndex] = this.addRecordRule === 'Array' ? [] : {}; - rawRecords[recordIndex][field] = formatValue; + setRecordFieldValue(rawRecords[recordIndex], field, formatValue); } } } @@ -1651,7 +1735,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { return true; } const record = this.getOriginalRecord(index); - return Boolean(record && (field as any) in (record as any)); + return hasRecordField(record, field); } protected fieldPromiseCallBack( From 5fdbc5d7a09faf2c6c84cf945111526cf6cac8d9 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 11 Sep 2026 15:17:59 +0800 Subject: [PATCH 62/65] fix: preserve nested field record compatibility Support array records in field detection and align array-path reads with nested updates without changing existing dotted-path behavior. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../data-source-nested-field-update.test.ts | 27 +++++++++++++++++++ .../debug/issue-4186-nested-field-edit.ts | 2 ++ packages/vtable/src/data/DataSource.ts | 24 ++++++++++------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts index b8ad980685..6d13d02fec 100644 --- a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts +++ b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts @@ -103,4 +103,31 @@ describe('DataSource nested field updates', () => { expect(dataSource.hasField(0, 'facts.2025-02.qty')).toBe(true); dataSource.release(); }); + + test('recognizes numeric fields in array records', () => { + const records = [[10, 20]]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 0)).toBe(true); + expect(dataSource.hasField(0, 1)).toBe(true); + expect(dataSource.hasField(0, 2)).toBe(false); + dataSource.release(); + }); + + test('keeps array-path reads aligned when an intermediate value is falsy', () => { + const table = { leftRowSeriesNumberCount: 0 } as any; + const record = { + nested: { + zero: 0, + disabled: false, + empty: '' + } + }; + + for (const key of ['zero', 'disabled', 'empty']) { + const field = ['nested', key, 'missing']; + expect(getRecordFieldValue(record, field)).toBeUndefined(); + expect(getField(record, field, 0, 0, table, () => undefined)).toBeUndefined(); + } + }); }); diff --git a/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts index 85a42ad2d3..d8a7ce5a2c 100644 --- a/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts +++ b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts @@ -30,8 +30,10 @@ const updateResult = (status: HTMLElement, source: HTMLElement) => { }; const createControls = () => { + document.getElementById('issue4186Controls')?.remove(); const container = document.getElementById(CONTAINER_ID)!; const controls = document.createElement('div'); + controls.id = 'issue4186Controls'; controls.style.cssText = 'margin-bottom: 12px; font: 13px/1.5 sans-serif;'; const runButton = document.createElement('button'); diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index b154167e5f..5e4d29ca67 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -17,9 +17,9 @@ import type { import { AggregationType, HierarchyState } from '../ts-types'; import { applyChainSafe, getOrApply, obj, isPromise, emptyFn } from '../tools/helper'; import { EventTarget } from '../event/EventTarget'; -import { computeChildrenNodeLength, getValueByPath, isAllDigits } from '../tools/util'; +import { computeChildrenNodeLength, isAllDigits } from '../tools/util'; import { calculateArrayDiff } from '../tools/diff-cell'; -import { arrayEqual, cloneDeep, isArray, isNumber, isObject, isValid } from '@visactor/vutils'; +import { arrayEqual, cloneDeep, isArray, isNumber, isValid } from '@visactor/vutils'; import type { BaseTableAPI } from '../ts-types/base-table'; import { RecordAggregator, @@ -101,11 +101,11 @@ export function getField( return record[colIndex]; } if (Array.isArray(fieldGet)) { - const fieldResult = getValueByPath(record, [...fieldGet]); + const fieldResult = getRecordFieldValue(record, fieldGet); return getValue(fieldResult, promiseCallBack); } - if (isObject(record) && fieldGet in (record as any)) { - const fieldResult = (record as any)[fieldGet]; + if (isRecordContainer(record) && fieldGet in record) { + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } @@ -115,7 +115,7 @@ export function getField( } const fieldArray = `${fieldGet}`.split('.'); if (fieldArray.length <= 1) { - const fieldResult = (record as any)[fieldGet]; + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } const fieldResult = applyChainSafe( @@ -126,6 +126,10 @@ export function getField( return getValue(fieldResult, promiseCallBack); } +function isRecordContainer(value: any): boolean { + return value !== null && typeof value === 'object'; +} + function getRecordFieldPath(field: FieldDef | number): string[] | undefined { if (Array.isArray(field)) { return field; @@ -137,7 +141,7 @@ function getRecordFieldPath(field: FieldDef | number): string[] | undefined { } function hasRecordField(record: any, field: FieldDef | number): boolean { - if (!isObject(record)) { + if (!isRecordContainer(record)) { return false; } const path = getRecordFieldPath(field); @@ -149,7 +153,7 @@ function hasRecordField(record: any, field: FieldDef | number): boolean { } let target = record; for (const key of path) { - if (!isObject(target) || !(key in target)) { + if (!isRecordContainer(target) || !(key in target)) { return false; } target = target[key]; @@ -162,7 +166,7 @@ export function getRecordFieldValue(record: any, field: FieldDef | number): any return undefined; } const path = getRecordFieldPath(field); - if (!Array.isArray(field) && isObject(record) && (field as any) in record) { + if (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record) { return record[field as any]; } if (!path) { @@ -191,7 +195,7 @@ export function setRecordFieldValue(record: any, field: FieldDef | number, value return; } const path = getRecordFieldPath(field); - if (!path || (!Array.isArray(field) && isObject(record) && (field as any) in record)) { + if (!path || (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record)) { record[field as any] = value; return; } From 2c0e346982d27d2bcf490a8c1f96873586128627 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 11 Sep 2026 16:35:55 +0800 Subject: [PATCH 63/65] fix: harden nested field updates Keep nested writes safe for sensitive keys. Align array-field matching and emitted values with final stored data. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../listTable-nested-field-update.test.ts | 44 +++++++++++++++++++ .../data-source-nested-field-update.test.ts | 31 +++++++++++++ packages/vtable/src/ListTable.ts | 14 ++++-- packages/vtable/src/data/DataSource.ts | 4 +- 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts index 36eafe3ebd..b1782f5d96 100644 --- a/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts +++ b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts @@ -77,4 +77,48 @@ describe('ListTable nested field updates', () => { }); table.release(); }); + + test('matches copied array fields when resolving a custom sort function', () => { + const field = ['facts', 'qty']; + const orderFn = jest.fn(); + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity', sort: orderFn }], + records: [{ facts: { qty: 10 } }] + }); + + expect(table._getSortFuncFromHeaderOption(undefined, [...field])).toBe(orderFn); + table.release(); + }); + + test('reports stored values when updates create records', () => { + const field = ['facts', 'qty']; + const records: any[] = []; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + table.dataSource.beforeChangedRecordsMap.set('0', { facts: { qty: 0 } }); + + table.changeCellValueByRecord(0, [...field], '12', { + autoRefresh: false, + noTriggerChangeCellValuesEvent: true + }); + + expect(records[0].facts.qty).toBe(12); + expect(cellEvents[0].changedValue).toBe(12); + + table.dataSource.beforeChangedRecordsMap.set('1', { facts: { qty: 0 } }); + table.changeCellValuesByRecords([{ recordIndex: 1, field: [...field], value: '14' }], { autoRefresh: false }); + + expect(records[1].facts.qty).toBe(14); + expect(cellEvents[1].changedValue).toBe(14); + expect(batchEvents[0].values).toEqual([cellEvents[1]]); + table.release(); + }); }); diff --git a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts index 6d13d02fec..beef2f3b89 100644 --- a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts +++ b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts @@ -130,4 +130,35 @@ describe('DataSource nested field updates', () => { expect(getField(record, field, 0, 0, table, () => undefined)).toBeUndefined(); } }); + + test('writes a literal __proto__ field without changing the record prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, '__proto__'); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value).toBe('safe'); + dataSource.release(); + }); + + test('creates sensitive nested path keys without polluting Object.prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, 'constructor.prototype.polluted'); + + expect(Object.prototype.hasOwnProperty.call(record, 'constructor')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(record.constructor, 'prototype')).toBe(true); + expect(record.constructor.prototype.polluted).toBe('safe'); + expect(Object.prototype.polluted).toBeUndefined(); + + dataSource.changeFieldValueByRecordIndex('nested-safe', 0, ['__proto__', 'polluted']); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value.polluted).toBe('nested-safe'); + expect(Object.prototype.polluted).toBeUndefined(); + dataSource.release(); + }); }); diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 85c3fb7e76..7fa7b25716 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1415,7 +1415,7 @@ export class ListTable extends BaseTable implements ListTableAPI { for (let i = 0; i < columns.length; i++) { const header = columns[i]; if ( - ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && header.field === field)) && + ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && isSameField(header.field, field))) && header.sort && typeof header.sort === 'function' ) { @@ -1451,7 +1451,9 @@ export class ListTable extends BaseTable implements ListTableAPI { this.dataSource.sort( normalizedSortState.map((item: any) => { const sortFunc = this._getSortFuncFromHeaderOption(this.internalProps.columns, item.field); - const hd = this.internalProps.layoutMap.headerObjects.find((col: any) => col && col.field === item.field); + const hd = this.internalProps.layoutMap.headerObjects.find( + (col: any) => col && isSameField(col.field, item.field) + ); return { field: item.field, order: item.order, @@ -1656,7 +1658,7 @@ export class ListTable extends BaseTable implements ListTableAPI { const sortFunc = this._getSortFuncFromHeaderOption(undefined, item.field); // 如果sort传入的信息不能生成正确的sortFunc,直接更新表格,避免首次加载无法正常显示内容 const hd = this.internalProps.layoutMap.headerObjectsIncludeHided.find( - (col: any) => col && col.field === item.field + (col: any) => col && isSameField(col.field, item.field) ); return { field: item.field, @@ -1948,6 +1950,9 @@ export class ListTable extends BaseTable implements ListTableAPI { } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (!triggerEvent) { return; @@ -2040,6 +2045,9 @@ export class ListTable extends BaseTable implements ListTableAPI { } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (triggerEvent) { const changedValue = diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 5e4d29ca67..99e3a183da 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -178,7 +178,7 @@ export function getRecordFieldValue(record: any, field: FieldDef | number): any } function setRecordProperty(record: any, key: string, value: any): void { - if (key === '__proto__') { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { Object.defineProperty(record, key, { configurable: true, enumerable: true, @@ -196,7 +196,7 @@ export function setRecordFieldValue(record: any, field: FieldDef | number, value } const path = getRecordFieldPath(field); if (!path || (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record)) { - record[field as any] = value; + setRecordProperty(record, field as any, value); return; } From 843598bf07b4da580832267241c92b9507cd3f3b Mon Sep 17 00:00:00 2001 From: fangsmile Date: Fri, 11 Sep 2026 10:04:43 +0000 Subject: [PATCH 64/65] build: release version 1.26.8 [skip ci] --- ...at-set-default-theme_2026-09-09-03-47.json | 11 ---- ...0-image-null-preview_2026-08-25-17-50.json | 11 ---- ...-nested-field-update_2026-09-04-10-30.json | 11 ---- ...issue-5274-http-copy_2026-08-24-15-00.json | 11 ---- ...lear-col-width-cache_2026-08-28-00-00.json | 11 ---- common/config/rush/pnpm-lock.yaml | 58 +++++++++---------- common/config/rush/version-policies.json | 2 +- docs/assets/changelog/en/release.md | 19 ++++++ docs/assets/changelog/zh/release.md | 19 ++++++ docs/package.json | 18 +++--- packages/openinula-vtable/package.json | 4 +- packages/react-vtable/package.json | 4 +- packages/vtable-calendar/package.json | 4 +- packages/vtable-editors/package.json | 2 +- packages/vtable-export/package.json | 4 +- packages/vtable-gantt/package.json | 6 +- packages/vtable-plugins/package.json | 8 +-- packages/vtable-search/package.json | 4 +- packages/vtable-sheet/package.json | 8 +-- packages/vtable/CHANGELOG.json | 26 +++++++++ packages/vtable/CHANGELOG.md | 16 ++++- packages/vtable/package.json | 4 +- packages/vue-vtable/package.json | 4 +- tools/bugserver-trigger/package.json | 10 ++-- 24 files changed, 149 insertions(+), 126 deletions(-) delete mode 100644 common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json delete mode 100644 common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json diff --git a/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json b/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json deleted file mode 100644 index d55e6aee08..0000000000 --- a/common/changes/@visactor/vtable/feat-set-default-theme_2026-09-09-03-47.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "comment": "feat: add themes.setDefaultTheme for application-wide default styling", - "type": "none", - "packageName": "@visactor/vtable" - } - ], - "packageName": "@visactor/vtable", - "email": "30969393+kitee0325@users.noreply.github.com" -} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json b/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json deleted file mode 100644 index f2900f0db4..0000000000 --- a/common/changes/@visactor/vtable/fix-5290-image-null-preview_2026-08-25-17-50.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "comment": "fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290)", - "type": "patch", - "packageName": "@visactor/vtable" - } - ], - "packageName": "@visactor/vtable", - "email": "108231307+dajiaohuang@users.noreply.github.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json b/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json deleted file mode 100644 index a59754ebe9..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "comment": "fix: update nested fields by path", - "type": "patch", - "packageName": "@visactor/vtable" - } - ], - "packageName": "@visactor/vtable", - "email": "biukam.w@gmail.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json deleted file mode 100644 index 5f13961b03..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json b/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json deleted file mode 100644 index 5c3ba761db..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-5298-clear-col-width-cache_2026-08-28-00-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: preserve pivot table column widths after updateOption (#5298)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "biukam.w@gmail.com" -} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e266a022cb..d37d1fc8c5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -27,34 +27,34 @@ importers: specifier: ^2.11.0 version: 2.57.0(vue@3.5.31(typescript@4.9.5)) '@visactor/openinula-vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/openinula-vtable '@visactor/react-vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/react-vtable '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-editors '@visactor/vtable-export': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-export '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-gantt '@visactor/vtable-search': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-search '@visactor/vtable-sheet': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vtable-sheet '@visactor/vue-vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../packages/vue-vtable '@visactor/vutils': specifier: ~1.0.17 @@ -148,7 +148,7 @@ importers: ../../packages/openinula-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -278,7 +278,7 @@ importers: ../../packages/react-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -456,7 +456,7 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -640,7 +640,7 @@ importers: ../../packages/vtable-calendar: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -863,7 +863,7 @@ importers: specifier: 2.0.7 version: 2.0.7 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1023,10 +1023,10 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -1259,13 +1259,13 @@ importers: specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-gantt '@vitejs/plugin-react': specifier: 3.1.0 @@ -1364,7 +1364,7 @@ importers: ../../packages/vtable-search: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1518,13 +1518,13 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-editors '@visactor/vtable-plugins': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable-plugins '@visactor/vutils': specifier: ~1.0.17 @@ -1663,7 +1663,7 @@ importers: ../../packages/vue-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1844,19 +1844,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vtable': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-gantt '@visactor/vtable-plugins': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-plugins '@visactor/vtable-sheet': - specifier: workspace:1.26.7 + specifier: workspace:1.26.8 version: link:../../packages/vtable-sheet devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 2fa348fd6c..a482f07a95 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.7","mainProject":"@visactor/vtable","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.8","mainProject":"@visactor/vtable","nextBump":"patch"}] diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index f5f4f054d8..14e3e16a85 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -1,3 +1,22 @@ +# v1.26.8 + +2026-09-11 + +**🆕 New Features** + +- **@visactor/vtable**: add themes.setDefaultTheme for application-wide default styling + +**🐛 Bug Fixes** + +- **@visactor/vtable**: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) +- **@visactor/vtable**: update nested fields by path +- **@visactor/vtable**: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) +- **@visactor/vtable**: preserve pivot table column widths after updateOption (#5298) + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 + +[more detail about v1.26.8](https://github.com/VisActor/VTable/releases/tag/v1.26.8) + # v1.26.7 2026-08-20 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 6664a95646..4ca8e5ef4a 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -1,3 +1,22 @@ +# v1.26.8 + +2026-09-11 + +**🆕 新增功能** + +- **@visactor/vtable**: 新增 themes.setDefaultTheme for application-wide default styling + +**🐛 问题修复** + +- **@visactor/vtable**: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) +- **@visactor/vtable**: 更新 nested fields by path +- **@visactor/vtable**: prefer event clipboard data for copy and cut, fall back it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) +- **@visactor/vtable**: preserve pivot table column widths after updateOption (#5298) + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 + +[更多详情请查看 v1.26.8](https://github.com/VisActor/VTable/releases/tag/v1.26.8) + # v1.26.7 2026-08-20 diff --git a/docs/package.json b/docs/package.json index cbab95dc3f..ded2797e0a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,15 +12,15 @@ "dependencies": { "@arco-design/web-react": "2.66.12", "@arco-design/web-vue": "^2.11.0", - "@visactor/vtable": "workspace:1.26.7", - "@visactor/vtable-gantt": "workspace:1.26.7", - "@visactor/react-vtable": "workspace:1.26.7", - "@visactor/vue-vtable": "workspace:1.26.7", - "@visactor/openinula-vtable": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-export": "workspace:1.26.7", - "@visactor/vtable-search": "workspace:1.26.7", - "@visactor/vtable-sheet": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", + "@visactor/react-vtable": "workspace:1.26.8", + "@visactor/vue-vtable": "workspace:1.26.8", + "@visactor/openinula-vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-export": "workspace:1.26.8", + "@visactor/vtable-search": "workspace:1.26.8", + "@visactor/vtable-sheet": "workspace:1.26.8", "buble": "^0.20.0", "@visactor/vchart": "2.1.3", "markdown-it": "^13.0.0", diff --git a/packages/openinula-vtable/package.json b/packages/openinula-vtable/package.json index 78be8cbcd4..e6e2554334 100644 --- a/packages/openinula-vtable/package.json +++ b/packages/openinula-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/openinula-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The openinula version of VTable", "keywords": [ "openinula", @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 2df5245aa8..f3b9cce987 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The react version of VTable", "keywords": [ "react", @@ -58,7 +58,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "react-is": "^18.2.0", "react-reconciler": "0.29.0" diff --git a/packages/vtable-calendar/package.json b/packages/vtable-calendar/package.json index 38dc37ccae..e3f49260f9 100644 --- a/packages/vtable-calendar/package.json +++ b/packages/vtable-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-calendar", - "version": "1.26.7", + "version": "1.26.8", "description": "The calendar component of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "date-fns": "3.6.0" }, diff --git a/packages/vtable-editors/package.json b/packages/vtable-editors/package.json index 24c8edb847..5edf8d77eb 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-editors", - "version": "1.26.7", + "version": "1.26.8", "description": "", "sideEffects": false, "main": "cjs/index.js", diff --git a/packages/vtable-export/package.json b/packages/vtable-export/package.json index a431f3c962..7423cb5d94 100644 --- a/packages/vtable-export/package.json +++ b/packages/vtable-export/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-export", - "version": "1.26.7", + "version": "1.26.8", "description": "The export util of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "file-saver": "2.0.5", "@types/file-saver": "2.0.7", diff --git a/packages/vtable-gantt/package.json b/packages/vtable-gantt/package.json index baca2d03d9..cf57b8dc9a 100644 --- a/packages/vtable-gantt/package.json +++ b/packages/vtable-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-gantt", - "version": "1.26.7", + "version": "1.26.8", "description": "canvas table width high performance", "keywords": [ "vtable-gantt", @@ -50,8 +50,8 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-plugins/package.json b/packages/vtable-plugins/package.json index 0486e0cc86..0f1f63cafa 100644 --- a/packages/vtable-plugins/package.json +++ b/packages/vtable-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-plugins", - "version": "1.26.7", + "version": "1.26.8", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -61,9 +61,9 @@ "devDependencies": { "cross-env": "^7.0.3", "increase-memory-limit": "^1.0.7", - "@visactor/vtable": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-gantt": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", "@visactor/vchart": "2.1.3", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 7d0ff32ce1..98003b3fe8 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-search", - "version": "1.26.7", + "version": "1.26.8", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vtable-sheet/package.json b/packages/vtable-sheet/package.json index f25091d3ef..6be894095c 100644 --- a/packages/vtable-sheet/package.json +++ b/packages/vtable-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-sheet", - "version": "1.26.7", + "version": "1.26.8", "description": "Lightweight editable spreadsheet component based on VTable", "keywords": [ "vtable-sheet", @@ -50,9 +50,9 @@ "build:es": "bundle --clean -f es --ignorePostTasks" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-plugins": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-plugins": "workspace:1.26.8", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable/CHANGELOG.json b/packages/vtable/CHANGELOG.json index 34e307d147..68e3291770 100644 --- a/packages/vtable/CHANGELOG.json +++ b/packages/vtable/CHANGELOG.json @@ -1,6 +1,32 @@ { "name": "@visactor/vtable", "entries": [ + { + "version": "1.26.8", + "tag": "@visactor/vtable_v1.26.8", + "date": "Fri, 11 Sep 2026 09:49:37 GMT", + "comments": { + "none": [ + { + "comment": "feat: add themes.setDefaultTheme for application-wide default styling" + } + ], + "patch": [ + { + "comment": "fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290)" + }, + { + "comment": "fix: update nested fields by path" + }, + { + "comment": "fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274)" + }, + { + "comment": "fix: preserve pivot table column widths after updateOption (#5298)" + } + ] + } + }, { "version": "1.26.7", "tag": "@visactor/vtable_v1.26.7", diff --git a/packages/vtable/CHANGELOG.md b/packages/vtable/CHANGELOG.md index 8c454c62c8..4bfb9d6029 100644 --- a/packages/vtable/CHANGELOG.md +++ b/packages/vtable/CHANGELOG.md @@ -1,6 +1,20 @@ # Change Log - @visactor/vtable -This log was last generated on Thu, 20 Aug 2026 08:02:48 GMT and should not be manually modified. +This log was last generated on Fri, 11 Sep 2026 09:49:37 GMT and should not be manually modified. + +## 1.26.8 +Fri, 11 Sep 2026 09:49:37 GMT + +### Patches + +- fix: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) +- fix: update nested fields by path +- fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) +- fix: preserve pivot table column widths after updateOption (#5298) + +### Updates + +- feat: add themes.setDefaultTheme for application-wide default styling ## 1.26.7 Thu, 20 Aug 2026 08:02:48 GMT diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 1f2ae411ea..aea9cb8e3f 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "canvas table width high performance", "keywords": [ "grid", @@ -52,7 +52,7 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.8", "@visactor/vrender": "~1.1.8", "@visactor/vrender-core": "~1.1.8", "@visactor/vrender-kits": "~1.1.8", diff --git a/packages/vue-vtable/package.json b/packages/vue-vtable/package.json index c9337f18ee..cbb9c469bc 100644 --- a/packages/vue-vtable/package.json +++ b/packages/vue-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vue-vtable", - "version": "1.26.7", + "version": "1.26.8", "description": "The vue version of VTable", "keywords": [ "vue", @@ -59,7 +59,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable": "workspace:1.26.8", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index a744555a17..97dabfa9a4 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.7", - "@visactor/vtable-gantt": "workspace:1.26.7", - "@visactor/vtable-editors": "workspace:1.26.7", - "@visactor/vtable-plugins": "workspace:1.26.7", - "@visactor/vtable-sheet": "workspace:1.26.7" + "@visactor/vtable": "workspace:1.26.8", + "@visactor/vtable-gantt": "workspace:1.26.8", + "@visactor/vtable-editors": "workspace:1.26.8", + "@visactor/vtable-plugins": "workspace:1.26.8", + "@visactor/vtable-sheet": "workspace:1.26.8" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4", From da6a74645c17a1b753ddd4d291937e605898166c Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 11 Sep 2026 18:57:12 +0800 Subject: [PATCH 65/65] docs: refine v1.26.8 release notes Translate the Chinese release summary. Move the vtable-sheet change into both release documents. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../fix-vtable-sheet-compile_2026-08-25-09-30.json | 11 ----------- docs/assets/changelog/en/release.md | 1 + docs/assets/changelog/zh/release.md | 13 +++++++------ 3 files changed, 8 insertions(+), 17 deletions(-) delete mode 100644 common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json diff --git a/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json deleted file mode 100644 index 19d4cb2339..0000000000 --- a/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-compile_2026-08-25-09-30.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable-sheet", - "comment": "fix: keep test-only plugin sources out of the vtable-sheet package compile", - "type": "patch" - } - ], - "packageName": "@visactor/vtable-sheet", - "email": "108231307+dajiaohuang@users.noreply.github.com" -} diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 14e3e16a85..831366d188 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -12,6 +12,7 @@ - **@visactor/vtable**: update nested fields by path - **@visactor/vtable**: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) - **@visactor/vtable**: preserve pivot table column widths after updateOption (#5298) +- **@visactor/vtable-sheet**: keep test-only plugin sources out of the package compile **Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 4ca8e5ef4a..1c36af6110 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -4,16 +4,17 @@ **🆕 新增功能** -- **@visactor/vtable**: 新增 themes.setDefaultTheme for application-wide default styling +- **@visactor/vtable**: 新增 `themes.setDefaultTheme`,支持配置应用级默认主题样式 **🐛 问题修复** -- **@visactor/vtable**: prevent empty image, audio, and video cells from opening media previews (GitHub #5290) -- **@visactor/vtable**: 更新 nested fields by path -- **@visactor/vtable**: prefer event clipboard data for copy and cut, fall back it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274) -- **@visactor/vtable**: preserve pivot table column widths after updateOption (#5298) +- **@visactor/vtable**: 阻止空图片、音频和视频单元格打开媒体预览(GitHub #5290) +- **@visactor/vtable**: 支持按路径更新嵌套字段 +- **@visactor/vtable**: 复制和剪切时优先使用事件的剪贴板数据;异步 Clipboard API 不可用时为粘贴提供回退;粘贴成功前保留剪切源单元格,并规范化粘贴的 HTML 单元格内容(GitHub #5274) +- **@visactor/vtable**: 调用 `updateOption` 后保留透视表列宽(#5298) +- **@visactor/vtable-sheet**: 修复编译配置,避免将仅用于测试的插件源码纳入包编译 -**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 +**完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.7...v1.26.8 [更多详情请查看 v1.26.8](https://github.com/VisActor/VTable/releases/tag/v1.26.8)