From 389f8de55a167887f439cb3c3755b57bbc270a46 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 3 Aug 2026 18:45:31 +0800 Subject: [PATCH 01/44] fix(bundler): inline dynamic imports for umd output --- tools/bundler/src/tasks/umd.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/bundler/src/tasks/umd.ts b/tools/bundler/src/tasks/umd.ts index a45f6e4213..b4a4f04c8b 100644 --- a/tools/bundler/src/tasks/umd.ts +++ b/tools/bundler/src/tasks/umd.ts @@ -34,6 +34,7 @@ export async function buildUmd(config: Config, projectRoot: string, rawPackageJs file: minify ? `${dest}/${config.umdOutputFilename || packageNameToPath(rawPackageJson.name)}.min.js` : `${dest}/${config.umdOutputFilename || packageNameToPath(rawPackageJson.name)}.js`, + inlineDynamicImports: true, exports: 'named', globals: { react: 'React', ...config.globals } } From 8f747f4b35b4c2ea37caffcf7fa2742f0da1731f Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 4 Aug 2026 15:10:53 +0800 Subject: [PATCH 02/44] fix(vtable): avoid loading lazy data in custom render sizing --- ...ble-custom-render-async-dataSource.test.ts | 83 +++++++++++++++++++ .../scenegraph/layout/compute-col-width.ts | 13 ++- .../scenegraph/layout/compute-row-height.ts | 13 ++- 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts diff --git a/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts new file mode 100644 index 0000000000..b95653e6f0 --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts @@ -0,0 +1,83 @@ +import { ListTable, data } from '../../src'; +import { createDiv, removeDom } from '../dom'; + +(global as any).__VERSION__ = 'none'; + +describe('ListTable customRender with lazy dataSource', () => { + let containerDom: HTMLElement; + + beforeEach(() => { + containerDom = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '300px'; + containerDom.style.height = '200px'; + }); + + afterEach(() => { + removeDom(containerDom); + }); + + test('does not load all lazy records when customRender reads value during auto size computation', () => { + const loadedIndexes = new Set(); + let computationBodyValueCount = 0; + const recordsLength = 1000; + const lazyDataSource = new data.CachedDataSource({ + get(index: number) { + loadedIndexes.add(index); + return { + icon: `https://example.com/${index}.svg`, + name: `name-${index}` + }; + }, + length: recordsLength + }); + + const table = new ListTable(containerDom, { + dataSource: lazyDataSource, + columns: [ + { + field: 'icon', + title: 'Icon', + width: 'auto' + }, + { + field: 'name', + title: 'Name', + width: 120 + } + ], + heightMode: 'autoHeight', + limitMaxAutoWidth: 600, + customRender(args) { + const { row, value, forComputation } = args; + if (row === 0) { + return null; + } + if (forComputation && value !== undefined) { + computationBodyValueCount++; + } + return { + renderDefault: false, + expectedHeight: 40, + expectedWidth: 120, + elements: [ + { + type: 'image', + src: value, + width: 20, + height: 20, + x: 35, + y: 10 + } + ] + }; + } + }); + + expect(computationBodyValueCount).toBe(0); + expect(loadedIndexes.has(recordsLength - 1)).toBe(false); + expect(loadedIndexes.size).toBeLessThan(recordsLength); + + table.release(); + }); +}); diff --git a/packages/vtable/src/scenegraph/layout/compute-col-width.ts b/packages/vtable/src/scenegraph/layout/compute-col-width.ts index a2f83fbade..b7940eefe0 100644 --- a/packages/vtable/src/scenegraph/layout/compute-col-width.ts +++ b/packages/vtable/src/scenegraph/layout/compute-col-width.ts @@ -436,11 +436,12 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) cellRange = table.getCellRange(col, row); spanCol = cellRange.end.col - cellRange.start.col + 1; } + const skipCellValue = shouldSkipCustomRenderCellValueForComputation(col, row, table); const arg = { col: cellRange?.start.col ?? col, row: cellRange?.start.row ?? row, - dataValue: table.getCellOriginValue(col, row), - value: table.getCellValue(col, row), + dataValue: skipCellValue ? undefined : table.getCellOriginValue(col, row), + value: skipCellValue ? undefined : table.getCellValue(col, row), rect: getCellRect(col, row, table), table, originCol: col, @@ -490,6 +491,14 @@ function computeCustomRenderWidth(col: number, row: number, table: BaseTableAPI) return undefined; } +function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { + return ( + table.isListTable() && + !table.isHeader(col, row) && + !(table.internalProps.dataSource as any)?.dataSourceObj?.records + ); +} + /** * @description: 计算指标相关列宽 * @param {number} col diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index dade83a49b..52fb3d5c33 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -640,11 +640,12 @@ function computeCustomRenderHeight(col: number, row: number, table: BaseTableAPI cellRange = table.getCellRange(col, row); spanRow = cellRange.end.row - cellRange.start.row + 1; } + const skipCellValue = shouldSkipCustomRenderCellValueForComputation(col, row, table); const arg = { col: cellRange?.start.col ?? col, row: cellRange?.start.row ?? row, - dataValue: table.getCellOriginValue(col, row), - value: table.getCellValue(col, row), + dataValue: skipCellValue ? undefined : table.getCellOriginValue(col, row), + value: skipCellValue ? undefined : table.getCellValue(col, row), rect: getCellRect(col, row, table), table, originCol: col, @@ -692,6 +693,14 @@ function computeCustomRenderHeight(col: number, row: number, table: BaseTableAPI return undefined; } +function shouldSkipCustomRenderCellValueForComputation(col: number, row: number, table: BaseTableAPI) { + return ( + table.isListTable() && + !table.isHeader(col, row) && + !(table.internalProps.dataSource as any)?.dataSourceObj?.records + ); +} + /** * @description: compute text height * @param {number} col From 74616a3492c41d8d9b6e5152be13f8373d954c2a Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 4 Aug 2026 15:43:22 +0800 Subject: [PATCH 03/44] docs(vtable): add issue 4964 custom render demo --- .../issue-4964-custom-render-async-value.ts | 120 ++++++++++++++++++ packages/vtable/examples/menu.ts | 4 + 2 files changed, 124 insertions(+) create mode 100644 packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts diff --git a/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts new file mode 100644 index 0000000000..06cc21ee24 --- /dev/null +++ b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts @@ -0,0 +1,120 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const RECORD_COUNT = 5000; + +function createIconDataUrl(index: number) { + const color = index % 2 === 0 ? '#1664ff' : '#00a870'; + const svg = ` + + ${index % 10} + `; + + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +function createRecord(index: number) { + return { + icon: createIconDataUrl(index), + name: `name-${index}`, + desc: `row ${index}` + }; +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '800px'; + container.style.height = '500px'; + + const status = document.createElement('div'); + status.style.cssText = 'height: 40px; line-height: 20px; font-size: 13px; color: #333;'; + container.parentElement?.insertBefore(status, container); + + const loadedIndexes = new Set(); + let computationValueCount = 0; + let customRenderCallCount = 0; + + const updateStatus = () => { + const loadedRows = loadedIndexes.size; + const lastRowLoaded = loadedIndexes.has(RECORD_COUNT - 1); + status.innerHTML = [ + `loaded rows: ${loadedRows}/${RECORD_COUNT}, last row loaded: ${lastRowLoaded}`, + `customRender calls: ${customRenderCallCount}, computation value count: ${computationValueCount}` + ].join('
'); + }; + + const dataSource = new VTable.data.CachedDataSource({ + get(index: number) { + loadedIndexes.add(index); + return createRecord(index); + }, + length: RECORD_COUNT + }); + + const option: VTable.ListTableConstructorOptions = { + container, + dataSource, + columns: [ + { + field: 'icon', + title: 'Icon', + width: 'auto' + }, + { + field: 'name', + title: 'Name', + width: 160 + }, + { + field: 'desc', + title: 'Description', + width: 220 + } + ], + heightMode: 'autoHeight', + limitMaxAutoWidth: 600, + customRender(args) { + const { row, value, forComputation } = args; + customRenderCallCount++; + + if (row === 0) { + return null; + } + + if (forComputation && value !== undefined) { + computationValueCount++; + } + + return { + renderDefault: false, + expectedHeight: 40, + expectedWidth: 120, + elements: [ + { + type: 'image', + src: value, + width: 20, + height: 20, + x: 35, + y: 10 + } + ] + }; + } + }; + + const tableInstance = new VTable.ListTable(option); + (window as any).tableInstance = tableInstance; + (window as any).issue4964Status = { + loadedIndexes, + get loadedRows() { + return loadedIndexes.size; + }, + get computationValueCount() { + return computationValueCount; + } + }; + + updateStatus(); + setTimeout(updateStatus, 0); +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6fcdf4ccf7..81435c6704 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -78,6 +78,10 @@ export const menus = [ path: 'debug', name: 'issue-4904-frozen-row-gap' }, + { + path: 'debug', + name: 'issue-4964-custom-render-async-value' + }, { path: 'debug', name: 'issue-4798-sort-icon-visible-time' From c7c3e964367f7141b0d0b1df10dc5e4f92d1ad9d Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 4 Aug 2026 15:08:31 +0800 Subject: [PATCH 04/44] feat(vtable): support tooltip appear delay --- ...tooltip-appear-delay_2026-08-04-00-00.json | 11 ++++ docs/assets/api/en/methods.md | 2 + docs/assets/api/zh/methods.md | 2 + docs/assets/guide/en/components/tooltip.md | 1 + docs/assets/guide/zh/components/tooltip.md | 1 + docs/assets/option/en/icon/base-icon.md | 3 + docs/assets/option/zh/icon/base-icon.md | 3 + .../components/listTable-tooltip.test.ts | 37 +++++++++++ .../debug/issue-5112-tooltip-appear-delay.ts | 62 ++++++++++++++++++ packages/vtable/examples/menu.ts | 4 ++ .../src/components/tooltip/TooltipHandler.ts | 64 ++++++++++++++++--- .../vtable/src/scenegraph/icon/icon-update.ts | 1 + packages/vtable/src/state/state.ts | 1 + packages/vtable/src/ts-types/icon.ts | 3 + packages/vtable/src/ts-types/tooltip.ts | 2 + 15 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json create mode 100644 packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts diff --git a/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json b/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json new file mode 100644 index 0000000000..8a142a0c16 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: support tooltip appear delay", + "type": "minor", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "github@visactor.io" +} diff --git a/docs/assets/api/en/methods.md b/docs/assets/api/en/methods.md index d24cc48048..d8aa3f4774 100644 --- a/docs/assets/api/en/methods.md +++ b/docs/assets/api/en/methods.md @@ -1492,6 +1492,8 @@ export type TooltipOptions = { padding?: number[]; arrowMark?: boolean; }; + /** Set tooltip appearance delay time */ + appearDelay?: number; /** Set tooltip disappearance time */ disappearDelay?: number; }; diff --git a/docs/assets/api/zh/methods.md b/docs/assets/api/zh/methods.md index 0168642727..b1fd29620e 100644 --- a/docs/assets/api/zh/methods.md +++ b/docs/assets/api/zh/methods.md @@ -1494,6 +1494,8 @@ export type TooltipOptions = { padding?: number[]; arrowMark?: boolean; }; + /** 设置tooltip的出现延迟时间 */ + appearDelay?: number; /** 设置tooltip的消失时间 */ disappearDelay?: number; }; diff --git a/docs/assets/guide/en/components/tooltip.md b/docs/assets/guide/en/components/tooltip.md index 9a388f188f..51a0824d4f 100644 --- a/docs/assets/guide/en/components/tooltip.md +++ b/docs/assets/guide/en/components/tooltip.md @@ -105,6 +105,7 @@ The interface showTooltip can actively display tooltip information, which is use content: 'Order ID:'+tableInstance.getCellValue(col,row), referencePosition: { rect, placement: VTable.TYPES.Placement.right }, //TODO className: 'defineTooltip', + appearDelay: 500, style: { bgColor: 'black', color: 'white', diff --git a/docs/assets/guide/zh/components/tooltip.md b/docs/assets/guide/zh/components/tooltip.md index 61ab428fa6..2077d15e5e 100644 --- a/docs/assets/guide/zh/components/tooltip.md +++ b/docs/assets/guide/zh/components/tooltip.md @@ -110,6 +110,7 @@ const tableInstance = new VTable.ListTable({ content: 'Order ID:'+tableInstance.getCellValue(col,row), referencePosition: { rect, placement: VTable.TYPES.Placement.right }, //TODO className: 'defineTooltip', + appearDelay: 500, style: { bgColor: 'black', color: 'white', diff --git a/docs/assets/option/en/icon/base-icon.md b/docs/assets/option/en/icon/base-icon.md index b681caa518..6026f1a3c9 100644 --- a/docs/assets/option/en/icon/base-icon.md +++ b/docs/assets/option/en/icon/base-icon.md @@ -113,6 +113,9 @@ Placement enumeration definition: } ``` +#${prefix} appearDelay (number) +The delay time for the tooltip to appear. + #${prefix} disappearDelay (number) The delay time for the tooltip to disappear. If you need to move the mouse to the tooltip, please configure this parameter. diff --git a/docs/assets/option/zh/icon/base-icon.md b/docs/assets/option/zh/icon/base-icon.md index 08763c0693..9901ce6604 100644 --- a/docs/assets/option/zh/icon/base-icon.md +++ b/docs/assets/option/zh/icon/base-icon.md @@ -113,6 +113,9 @@ Placement 枚举类型定义: } ``` +#${prefix} appearDelay (number) +提示框显示延迟时间。 + #${prefix} disappearDelay (number) 提示框消失延迟时间,如果有需要鼠标移动到 tooltip 上的需求,请配置上这个参数。 diff --git a/packages/vtable/__tests__/components/listTable-tooltip.test.ts b/packages/vtable/__tests__/components/listTable-tooltip.test.ts index f378fd162a..7d25ff7af9 100644 --- a/packages/vtable/__tests__/components/listTable-tooltip.test.ts +++ b/packages/vtable/__tests__/components/listTable-tooltip.test.ts @@ -110,6 +110,43 @@ describe('listTable-tooltip init test', () => { } }); }); + test('listTable-tooltip supports appearDelay', async () => { + const col = 0; + const row = 2; + const rect = listTable.getVisibleCellRangeRelativeRect({ col, row }); + + listTable.internalProps.tooltipHandler._unbindFromCell(); + listTable.showTooltip(col, row, { + content: 'Order ID:' + listTable.getCellValue(col, row), + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + appearDelay: 30 + }); + + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(listTable.internalProps.tooltipHandler._attachInfo.range).toStrictEqual({ + start: { + col: 0, + row: 2 + }, + end: { + col: 0, + row: 2 + } + }); + + listTable.internalProps.tooltipHandler._unbindFromCell(); + listTable.showTooltip(col, row, { + content: 'Order ID:' + listTable.getCellValue(col, row), + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + appearDelay: 30 + }); + listTable.internalProps.tooltipHandler._unbindFromCell(); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(listTable.internalProps.tooltipHandler._attachInfo).toBeNull(); + }); // setTimeout(() => { // listTable.release(); // }, 1000); diff --git a/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts b/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts new file mode 100644 index 0000000000..902d03d89a --- /dev/null +++ b/packages/vtable/examples/debug/issue-5112-tooltip-appear-delay.ts @@ -0,0 +1,62 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const records = [ + { orderId: 'A001', product: 'Laptop', price: 1200 }, + { orderId: 'A002', product: 'Keyboard', price: 120 }, + { orderId: 'A003', product: 'Mouse', price: 80 } + ]; + + const columns: VTable.ColumnsDefine = [ + { + field: 'orderId', + title: 'Order ID', + width: 160 + }, + { + field: 'product', + title: 'Product', + width: 180 + }, + { + field: 'price', + title: 'Price', + width: 120 + } + ]; + + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, { + records, + columns, + defaultRowHeight: 40, + tooltip: { + renderMode: 'html' + } + }); + + tableInstance.on('mouseenter_cell', args => { + const { col, row } = args; + if (col !== 0 || row < tableInstance.columnHeaderLevelCount) { + return; + } + + const rect = tableInstance.getVisibleCellRangeRelativeRect({ col, row }); + tableInstance.showTooltip(col, row, { + content: `showTooltip appearDelay=1000ms: ${tableInstance.getCellValue(col, row)}`, + referencePosition: { rect, placement: VTable.TYPES.Placement.right }, + className: 'defineTooltip', + appearDelay: 1000, + disappearDelay: 100, + style: { + bgColor: '#202328', + color: '#fff', + padding: [8, 12], + arrowMark: true + } + }); + }); + + (window as any).tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6fcdf4ccf7..a7969e9098 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -86,6 +86,10 @@ export const menus = [ path: 'debug', name: 'issue-celltype-function-undefined' }, + { + path: 'debug', + name: 'issue-5112-tooltip-appear-delay' + }, { path: 'debug', name: 'header-frame-border-null-color' diff --git a/packages/vtable/src/components/tooltip/TooltipHandler.ts b/packages/vtable/src/components/tooltip/TooltipHandler.ts index af4c3660eb..b9bbd080b9 100644 --- a/packages/vtable/src/components/tooltip/TooltipHandler.ts +++ b/packages/vtable/src/components/tooltip/TooltipHandler.ts @@ -22,6 +22,12 @@ type AttachInfo = { range: CellRange; tooltipOptions: TooltipOptions; }; +type PendingInfo = { + id: ReturnType; + col: number; + row: number; + tooltipOptions: TooltipOptions; +}; export interface ITooltipHandler { new (table: BaseTableAPI, confine: boolean): TooltipHandler; } @@ -30,6 +36,7 @@ export class TooltipHandler { private _table: BaseTableAPI; private _tooltipInstances?: { [type: string]: BaseTooltip }; private _attachInfo?: AttachInfo | null; + private _pendingInfo?: PendingInfo | null; private confine?: boolean; //弹出框是否需要限定在canvas区域 constructor(table: BaseTableAPI, confine: boolean) { this._table = table; @@ -38,14 +45,39 @@ export class TooltipHandler { this.confine = confine; } release(): void { + this._clearPendingBind(); const tooltipInstances = this._tooltipInstances; for (const k in tooltipInstances) { tooltipInstances[k]?.release?.(); } delete this._tooltipInstances; this._attachInfo = null; + this._pendingInfo = null; } _bindToCell(col: number, row: number, tooltipOptions?: TooltipOptions): void { + this._clearPendingBind(); + const appearDelay = tooltipOptions?.appearDelay; + if (tooltipOptions && appearDelay && appearDelay > 0) { + this._unbindAttachedTooltip(); + const id = setTimeout(() => { + this._pendingInfo = null; + this._bindToCellImmediately(col, row, tooltipOptions); + }, appearDelay); + this._pendingInfo = { + id, + col, + row, + tooltipOptions + }; + return; + } + this._bindToCellImmediately(col, row, tooltipOptions); + } + _bindToCellImmediately(col: number, row: number, tooltipOptions?: TooltipOptions): void { + if (!tooltipOptions) { + this._unbindAttachedTooltip(); + return; + } const info = this._attachInfo; const instance = this._getTooltipInstanceInfo(col, row); if (info && (!instance || info.instance !== instance)) { @@ -62,6 +94,21 @@ export class TooltipHandler { this._attachInfo = { range, instance, tooltipOptions }; } } + _clearPendingBind(): void { + if (this._pendingInfo) { + clearTimeout(this._pendingInfo.id); + this._pendingInfo = null; + } + } + _unbindAttachedTooltip(): void { + const info = this._attachInfo; + if (!info) { + return; + } + const { instance } = info; + instance?.unbindTooltipElement(); + this._attachInfo = null; + } _move(col: number, row: number, tooltipOptions: TooltipOptions): void { const info = this._attachInfo; if (!info || !cellInRange(info.range, col, row)) { @@ -89,15 +136,13 @@ export class TooltipHandler { instance?.locateTooltipElement(col, row, position, referencePosition, this.confine); } _unbindFromCell(): void { - const info = this._attachInfo; - if (!info) { - return; - } - const { instance } = info; - instance?.unbindTooltipElement(); - this._attachInfo = null; + this._clearPendingBind(); + this._unbindAttachedTooltip(); } _isBindCell(col: number, row: number): boolean { + if (this._pendingInfo && this._pendingInfo.col === col && this._pendingInfo.row === row) { + return true; + } const info = this._attachInfo; if (!info) { return false; @@ -245,7 +290,10 @@ export class TooltipHandler { return instance; } isBinded(tooltipOptions: TooltipOptions) { - if (JSON.stringify(tooltipOptions) === JSON.stringify(this._attachInfo?.tooltipOptions)) { + if ( + JSON.stringify(tooltipOptions) === JSON.stringify(this._attachInfo?.tooltipOptions) || + JSON.stringify(tooltipOptions) === JSON.stringify(this._pendingInfo?.tooltipOptions) + ) { return true; } return false; diff --git a/packages/vtable/src/scenegraph/icon/icon-update.ts b/packages/vtable/src/scenegraph/icon/icon-update.ts index 050f71df9a..b0fcc26291 100644 --- a/packages/vtable/src/scenegraph/icon/icon-update.ts +++ b/packages/vtable/src/scenegraph/icon/icon-update.ts @@ -195,6 +195,7 @@ export function setIconHoverStyle(baseIcon: Icon, col: number, row: number, cell }, placement: baseIcon.tooltip.placement }, + appearDelay: baseIcon.tooltip.appearDelay, disappearDelay: baseIcon.tooltip.disappearDelay, style: Object.assign({}, scene.table.internalProps.theme?.tooltipStyle, baseIcon.tooltip?.style) }; diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 88b5c974e2..e8eeac4077 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -746,6 +746,7 @@ export class StateManager { inlineIcon.tooltip?.style, inlineIcon.attribute?.tooltip?.style ), + appearDelay: inlineIcon.attribute.tooltip.appearDelay, disappearDelay: inlineIcon.attribute.tooltip.disappearDelay }; if (!this.table.internalProps.tooltipHandler.isBinded(tooltipOptions)) { diff --git a/packages/vtable/src/ts-types/icon.ts b/packages/vtable/src/ts-types/icon.ts index 29033baf90..424217d377 100644 --- a/packages/vtable/src/ts-types/icon.ts +++ b/packages/vtable/src/ts-types/icon.ts @@ -67,6 +67,9 @@ export interface IIconBase { maxWidth?: number; maxHeight?: number; }; + /** tooltip 延迟多久显示 */ + appearDelay?: number; + /** tooltip 延迟多久消失 */ disappearDelay?: number; }; /** diff --git a/packages/vtable/src/ts-types/tooltip.ts b/packages/vtable/src/ts-types/tooltip.ts index 12ff04e68a..37841c6442 100644 --- a/packages/vtable/src/ts-types/tooltip.ts +++ b/packages/vtable/src/ts-types/tooltip.ts @@ -29,6 +29,8 @@ export type TooltipOptions = { maxWidth?: number; maxHeight?: number; }; + /** 设置tooltip的出现延迟时间 */ + appearDelay?: number; /** 设置tooltip的消失时间 */ disappearDelay?: number; }; From 14525ef600c20581e72ee01ebd54f6875b6856dd Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 4 Aug 2026 16:09:26 +0800 Subject: [PATCH 05/44] docs: add changelog for issue 4964 --- ...stom-render-lazy-data-source_2026-08-04-16-08.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json diff --git a/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json b/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json new file mode 100644 index 0000000000..eb9c8a08bc --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} From c63968b5cb14aea36d7678f232ba79f39efd4dd0 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Tue, 4 Aug 2026 22:59:43 +0800 Subject: [PATCH 06/44] fix: render vue components in top frozen rows --- ...5-vue-frozen-row-dom_2026-08-04-14-53.json | 11 +++ .../__tests__/vue-stable-id.test.ts | 70 ++++++++++++++++++- .../custom/vtable-vue-attribute-plugin.ts | 6 ++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json diff --git a/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json b/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json new file mode 100644 index 0000000000..ecb1ab5af1 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: render vue components in top frozen rows", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "biukam.w@gmail.com" +} diff --git a/packages/vue-vtable/__tests__/vue-stable-id.test.ts b/packages/vue-vtable/__tests__/vue-stable-id.test.ts index a2d8c2d010..14387acdbb 100644 --- a/packages/vue-vtable/__tests__/vue-stable-id.test.ts +++ b/packages/vue-vtable/__tests__/vue-stable-id.test.ts @@ -28,6 +28,8 @@ jest.mock('@visactor/vtable', () => { addEventListener() {} } return { + CUSTOM_CONTAINER_NAME: 'custom-container', + CUSTOM_MERGE_PRE_NAME: '_custom_', CustomLayout: { Group: MockGroup, Image: MockGroup, @@ -42,7 +44,8 @@ jest.mock('@visactor/vtable', () => { // Mock vue 的 isVNode / cloneVNode jest.mock('vue', () => ({ isVNode: (val: any) => val && val.__v_isVNode === true, - cloneVNode: (vnode: any, extra: any) => ({ ...vnode, ...extra }) + cloneVNode: (vnode: any, extra: any) => ({ ...vnode, ...extra }), + render: jest.fn() })); function makeGroupChild(vueProps: any = {}, typeName = 'Group') { @@ -235,3 +238,68 @@ describe('VTableVueAttributePlugin 缓存命中同步渲染', () => { expect(plugin.scheduleRender).toHaveBeenCalled(); }); }); + +describe('VTableVueAttributePlugin 顶部冻结行容器', () => { + test.each([ + ['普通列', 1, 'headerDomContainer'], + ['左冻结列', 0, 'frozenHeaderDomContainer'], + ['右冻结列', 3, 'rightFrozenHeaderDomContainer'] + ])('顶部冻结行的%s应挂载到顶部冻结容器', (_name, col, expectedContainerKey) => { + const { VTableVueAttributePlugin } = jest.requireActual('../src/components/custom/vtable-vue-attribute-plugin'); + const plugin = new VTableVueAttributePlugin(); + plugin.htmlMap = {}; + plugin.renderId = 1; + + const table = { + bodyDomContainer: document.createElement('div'), + headerDomContainer: document.createElement('div'), + frozenBodyDomContainer: document.createElement('div'), + frozenHeaderDomContainer: document.createElement('div'), + rightFrozenBodyDomContainer: document.createElement('div'), + rightFrozenHeaderDomContainer: document.createElement('div'), + bottomFrozenBodyDomContainer: document.createElement('div'), + rightFrozenBottomDomContainer: document.createElement('div'), + frozenColCount: 1, + rightFrozenColCount: 1, + frozenRowCount: 2, + bottomFrozenRowCount: 0, + colCount: 4, + rowCount: 10, + options: {} + }; + const stage = { + table, + window: { getContainer: () => document.body } + }; + const targetGroup = { + name: 'custom-container', + col, + row: 1, + stage, + parent: {} + }; + const graphic = { + attribute: { + vue: { + id: `top-frozen-${col}`, + element: { __v_isVNode: true }, + container: table.bodyDomContainer + } + }, + stage, + parent: targetGroup + }; + const wrapContainer = document.createElement('div'); + plugin.getWrapContainer = jest.fn((_stage, container) => ({ + wrapContainer, + nativeContainer: container + })); + plugin.updateStyleOfWrapContainer = jest.fn(); + + plugin.doRenderGraphic(graphic); + + const actualContainer = plugin.getWrapContainer.mock.calls[0][1]; + expect(actualContainer).toBe(table[expectedContainerKey]); + expect(actualContainer).not.toBe(table.bodyDomContainer); + }); +}); diff --git a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts index f44dc8b665..4bc9cfc465 100644 --- a/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts +++ b/packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts @@ -842,6 +842,12 @@ function checkFrozenContainer(graphic: IGraphic) { container = table.rightFrozenBottomDomContainer; } else if (row >= table.rowCount - table.bottomFrozenRowCount) { container = table.bottomFrozenBodyDomContainer; + } else if (row < table.frozenRowCount && col < table.frozenColCount) { + container = table.frozenHeaderDomContainer; + } else if (row < table.frozenRowCount && col >= table.colCount - table.rightFrozenColCount) { + container = table.rightFrozenHeaderDomContainer; + } else if (row < table.frozenRowCount) { + container = table.headerDomContainer; } else if (col < table.frozenColCount) { container = table.frozenBodyDomContainer; } else if (col >= table.colCount - table.rightFrozenColCount) { From 3a92c445ecbbe9c64c951e4bc7b549a0ca3db5b1 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 5 Aug 2026 14:33:53 +0800 Subject: [PATCH 07/44] docs(vtable): narrow issue 4964 demo custom render --- .../options/listTable-custom-render-async-dataSource.test.ts | 4 ++-- .../examples/debug/issue-4964-custom-render-async-value.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts index b95653e6f0..3e41f3a705 100644 --- a/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts +++ b/packages/vtable/__tests__/options/listTable-custom-render-async-dataSource.test.ts @@ -49,8 +49,8 @@ describe('ListTable customRender with lazy dataSource', () => { heightMode: 'autoHeight', limitMaxAutoWidth: 600, customRender(args) { - const { row, value, forComputation } = args; - if (row === 0) { + const { col, row, value, forComputation } = args; + if (row === 0 || col !== 0) { return null; } if (forComputation && value !== undefined) { diff --git a/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts index 06cc21ee24..7a5a6a92c4 100644 --- a/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts +++ b/packages/vtable/examples/debug/issue-4964-custom-render-async-value.ts @@ -74,10 +74,10 @@ export function createTable() { heightMode: 'autoHeight', limitMaxAutoWidth: 600, customRender(args) { - const { row, value, forComputation } = args; + const { col, row, value, forComputation } = args; customRenderCallCount++; - if (row === 0) { + if (row === 0 || col !== 0) { return null; } From dd46af553930ad5df57db3a6049ff9a2c6483e78 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Wed, 5 Aug 2026 17:05:08 +0800 Subject: [PATCH 08/44] fix: prevent React plugin render loop --- ...9-react-plugins-loop_2026-08-05-16-50.json | 11 ++++ .../__tests__/list-table-plugins.test.tsx | 54 +++++++++++++++++++ packages/react-vtable/jest.config.js | 45 ++++++++++++++++ packages/react-vtable/package.json | 1 + .../react-vtable/src/tables/base-table.tsx | 17 +++--- packages/react-vtable/tscofig.eslint.json | 5 +- 6 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json create mode 100644 packages/react-vtable/__tests__/list-table-plugins.test.tsx create mode 100644 packages/react-vtable/jest.config.js diff --git a/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json new file mode 100644 index 0000000000..9c39da21f5 --- /dev/null +++ b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vtable", + "comment": "fix: prevent plugin options from causing a React render loop (GitHub #4859)", + "type": "patch" + } + ], + "packageName": "@visactor/react-vtable", + "email": "biukam.w@gmail.com" +} diff --git a/packages/react-vtable/__tests__/list-table-plugins.test.tsx b/packages/react-vtable/__tests__/list-table-plugins.test.tsx new file mode 100644 index 0000000000..02176792e1 --- /dev/null +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -0,0 +1,54 @@ +/* eslint-env jest, browser */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import { FilterPlugin } from '../../vtable-plugins/src/filter'; +import { ListColumn, ListTable } from '../src'; + +describe('ListTable plugins', () => { + let container: HTMLDivElement; + let root: Root; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + container = document.createElement('div'); + container.style.width = '800px'; + container.style.height = '400px'; + document.body.appendChild(container); + root = createRoot(container); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); + }); + + test('does not enter an update loop when a filter plugin is configured', async () => { + const onReady = jest.fn(); + + await act(async () => { + root.render( + + + + + ); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(onReady).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls.some(args => String(args[0]).includes('Maximum update depth exceeded'))).toBe(false); + }); +}); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js new file mode 100644 index 0000000000..16b084c98d --- /dev/null +++ b/packages/react-vtable/jest.config.js @@ -0,0 +1,45 @@ +const path = require('path'); +const { createVRenderModuleNameMapper } = require('../../common/config/jest/vrender-module-name-mapper'); + +module.exports = { + preset: 'ts-jest', + runner: 'jest-electron/runner', + testEnvironment: 'jest-electron/environment', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts|tsx)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: false, + isolatedModules: true, + tsconfig: { + resolveJsonModule: true, + esModuleInterop: true, + jsx: 'react', + baseUrl: '.', + paths: { + '@visactor/vtable': ['../vtable/src/index'], + '@visactor/vtable/*': ['../vtable/src/*'], + '@src/*': ['../vtable/src/*'], + '@vutils-extension': ['../vtable/src/vutil-extension-temp/index'] + } + } + }, + __DEV__: true + }, + cacheDirectory: '/.jest-cache', + moduleNameMapper: { + 'd3-color': path.resolve(__dirname, '../vtable/node_modules/d3-color/dist/d3-color.min.js'), + 'd3-array': path.resolve(__dirname, '../vtable/node_modules/d3-array/dist/d3-array.min.js'), + 'd3-geo': path.resolve(__dirname, '../vtable/node_modules/d3-geo/dist/d3-geo.min.js'), + 'd3-dsv': path.resolve(__dirname, '../vtable/node_modules/d3-dsv/dist/d3-dsv.min.js'), + 'd3-hexbin': path.resolve(__dirname, '../vtable/node_modules/d3-hexbin/build/d3-hexbin.min.js'), + 'd3-hierarchy': path.resolve(__dirname, '../vtable/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js'), + ...createVRenderModuleNameMapper('/../vtable/node_modules'), + '@visactor/vtable$': '/../vtable/src/index', + '@visactor/vtable/es/(.*)': '/../vtable/src/$1', + '@src/(.*)': '/../vtable/src/$1', + '@vutils-extension': '/../vtable/src/vutil-extension-temp/index' + }, + setupFiles: ['./setup-mock.js'] +}; diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 3295617b53..1cdc31dfb4 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -41,6 +41,7 @@ "start:react19": "npm run setup:react19-deps && vite --config ./demo/vite.config.react19.ts ./demo", "build": "npm run fix-memory-limit && bundle --clean", "compile": "tsc --noEmit", + "test": "jest --config jest.config.js", "eslint": "eslint --debug --fix src/", "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, diff --git a/packages/react-vtable/src/tables/base-table.tsx b/packages/react-vtable/src/tables/base-table.tsx index 8d96949aa2..1616be9fcd 100644 --- a/packages/react-vtable/src/tables/base-table.tsx +++ b/packages/react-vtable/src/tables/base-table.tsx @@ -139,6 +139,8 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { const optionFromChildren = useRef>(null); const prevRecords = useRef(props.records); const eventsBinded = React.useRef(null); + const latestProps = useRef(props); + const isInitialReady = useRef(true); const skipFunctionDiff = !!props.skipFunctionDiff; const keepColumnWidthChange = !!props.keepColumnWidthChange; const columnWidths = useRef>(new Map()); @@ -148,6 +150,7 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (tableContext.current) { tableContext.current.onError = props.onError; } + latestProps.current = props; const parseOption = useCallback( (props: Props) => { @@ -235,17 +238,17 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (!tableContext.current || !tableContext.current.table) { return; } + const currentProps = latestProps.current; // rebind events after render - bindEventsToTable(tableContext.current.table, props, eventsBinded.current, TABLE_EVENTS); + bindEventsToTable(tableContext.current.table, currentProps, eventsBinded.current, TABLE_EVENTS); - // to be fixed - // will cause another useEffect - setUpdateId(updateId + 1); - if (props.onReady) { - props.onReady(tableContext.current.table, updateId === 0); + setUpdateId(currentUpdateId => currentUpdateId + 1); + if (currentProps.onReady) { + currentProps.onReady(tableContext.current.table, isInitialReady.current); } + isInitialReady.current = false; } - }, [updateId, setUpdateId, props]); + }, []); const renderTable = useCallback(() => { if (tableContext.current.table) { diff --git a/packages/react-vtable/tscofig.eslint.json b/packages/react-vtable/tscofig.eslint.json index a8b2b56da8..e275387533 100644 --- a/packages/react-vtable/tscofig.eslint.json +++ b/packages/react-vtable/tscofig.eslint.json @@ -15,6 +15,7 @@ }, "include": [ "src", - "demo" + "demo", + "__tests__" ] -} \ No newline at end of file +} From e28d239aeb75ee5982889b1f5805348bd3db2b33 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Fri, 7 Aug 2026 10:33:28 +0800 Subject: [PATCH 09/44] fix(vtable): emit context menu click event Expose a typed event for ContextMenuPlugin menu item clicks. Consumers can observe plugin actions after built-in handling completes. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/guide/en/plugin/context-menu.md | 47 ++++++++++ docs/assets/guide/zh/plugin/context-menu.md | 47 ++++++++++ .../context-menu/handle-menu-helper.test.ts | 39 +++++++++ .../issue-4655-context-menu-click.ts | 85 +++++++++++++++++++ packages/vtable-plugins/demo/menu.ts | 4 + packages/vtable-plugins/src/context-menu.ts | 14 +++ packages/vtable/src/core/TABLE_EVENT_TYPE.ts | 5 ++ packages/vtable/src/ts-types/events.ts | 4 +- packages/vtable/src/ts-types/menu.ts | 13 +++ 9 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts diff --git a/docs/assets/guide/en/plugin/context-menu.md b/docs/assets/guide/en/plugin/context-menu.md index 28bb38173b..43ebf5803f 100644 --- a/docs/assets/guide/en/plugin/context-menu.md +++ b/docs/assets/guide/en/plugin/context-menu.md @@ -11,6 +11,7 @@ ContextMenu provides rich right-click menu features for VTable tables, supportin - Support custom menu items and separators - Support menu item grouping and submenus - Support custom menu item click callbacks +- Support listening to menu item click events with menu item and cell position details - Support menu item icons and shortcut hints - Support numeric input in menu items (e.g., insert multiple rows/columns) @@ -132,6 +133,48 @@ const contextMenuPlugin = new ContextMenuPlugin({ }); ``` +## Listen to Menu Item Click Events + +If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled. It is fired after the plugin finishes handling the menu item, so built-in operations such as freeze, insert, and delete have already taken effect. + +```typescript +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '@visactor/vtable-plugins'; + +const contextMenuPlugin = new ContextMenuPlugin(); + +const tableInstance = new VTable.ListTable({ + container: document.getElementById('container'), + columns, + records, + plugins: [contextMenuPlugin] +}); + +tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + const { col, row, contextMenu } = args; + + console.log('Context menu click position:', col, row); + console.log('Menu item:', contextMenu.menuKey, contextMenu.menuText); + + if (contextMenu.menuKey === 'freeze_to_this_row_and_column') { + console.log('Current frozen counts:', tableInstance.frozenRowCount, tableInstance.frozenColCount); + } +}); +``` + +Event argument description: + +| Parameter | Type | Description | +| --- | --- | --- | +| `col` | number | Column index of the menu trigger position; `-1` when there is no corresponding cell | +| `row` | number | Row index of the menu trigger position; `-1` when there is no corresponding cell | +| `contextMenu.menuKey` | string | Unique key of the clicked menu item | +| `contextMenu.menuText` | string | Display text of the clicked menu item | +| `contextMenu.rowIndex` | number | Row index of the menu trigger position | +| `contextMenu.colIndex` | number | Column index of the menu trigger position | +| `contextMenu.cellValue` | any | Cell value at the menu trigger position | +| `contextMenu.inputValue` | number \| string | Input value of an input menu item | + ## Complete Example Here is a complete example showing how to create a table with right-click menu functionality: @@ -244,6 +287,10 @@ const generateTestData = (count) => { // Create table instance const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + console.log('Context menu item clicked:', args.contextMenu); + }); ``` diff --git a/docs/assets/guide/zh/plugin/context-menu.md b/docs/assets/guide/zh/plugin/context-menu.md index 63289ec6f9..10f23b5fb9 100644 --- a/docs/assets/guide/zh/plugin/context-menu.md +++ b/docs/assets/guide/zh/plugin/context-menu.md @@ -11,6 +11,7 @@ - 支持自定义菜单项和分隔线 - 支持菜单项分组和子菜单 - 支持自定义菜单项点击回调 +- 支持监听菜单项点击事件,获取菜单项和单元格位置信息 - 支持菜单项图标和快捷键提示 - 支持菜单项中的数字输入(如插入多行/列) @@ -132,6 +133,48 @@ const contextMenuPlugin = new ContextMenuPlugin({ }); ``` +## 监听菜单项点击事件 + +如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在插件完成菜单项处理后触发,因此内置的冻结、插入、删除等菜单操作已经生效。 + +```typescript +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '@visactor/vtable-plugins'; + +const contextMenuPlugin = new ContextMenuPlugin(); + +const tableInstance = new VTable.ListTable({ + container: document.getElementById('container'), + columns, + records, + plugins: [contextMenuPlugin] +}); + +tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + const { col, row, contextMenu } = args; + + console.log('右键菜单点击位置:', col, row); + console.log('菜单项信息:', contextMenu.menuKey, contextMenu.menuText); + + if (contextMenu.menuKey === 'freeze_to_this_row_and_column') { + console.log('当前冻结行列数:', tableInstance.frozenRowCount, tableInstance.frozenColCount); + } +}); +``` + +事件参数说明: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `col` | number | 菜单触发位置对应的列号;没有对应单元格时为 `-1` | +| `row` | number | 菜单触发位置对应的行号;没有对应单元格时为 `-1` | +| `contextMenu.menuKey` | string | 被点击菜单项的唯一标识 | +| `contextMenu.menuText` | string | 被点击菜单项的展示文本 | +| `contextMenu.rowIndex` | number | 菜单触发位置对应的行号 | +| `contextMenu.colIndex` | number | 菜单触发位置对应的列号 | +| `contextMenu.cellValue` | any | 菜单触发位置对应的单元格值 | +| `contextMenu.inputValue` | number \| string | 输入型菜单项中的输入值 | + ## 完整示例 以下是一个完整的示例,展示了如何创建具有右键菜单功能的表格: @@ -244,6 +287,10 @@ const generateTestData = (count) => { // 创建表格实例 const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + console.log('右键菜单项点击:', args.contextMenu); + }); ``` diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index b9579f9562..1e4f639506 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -134,6 +134,45 @@ describe('Context menu canvas option', () => { ); }); + test('ContextMenuPlugin emits context_menu_click after menu item click', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin(); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'freeze_to_this_row_and_column', + menuText: '冻结到本行本列', + rowIndex: 1, + colIndex: 0, + cellValue: 1 + }, + table + ); + + expect(table.frozenRowCount).toBe(2); + expect(table.frozenColCount).toBe(1); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'freeze_to_this_row_and_column', + menuText: '冻结到本行本列', + rowIndex: 1, + colIndex: 0, + cellValue: 1 + } + }); + }); + test('ContextMenuPlugin init uses new options when added through updateOption', () => { const container = createDiv(); const contextMenuPlugin = new ContextMenuPlugin({ diff --git a/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts new file mode 100644 index 0000000000..cf5f5934ce --- /dev/null +++ b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts @@ -0,0 +1,85 @@ +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '../../src/context-menu'; + +const CONTAINER_ID = 'vTable'; + +function createRecords() { + return Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + name: `name-${index + 1}`, + city: ['Beijing', 'Shanghai', 'Shenzhen'][index % 3], + value: Math.round((index + 1) * 12.6) + })); +} + +function updateStatus(table: VTable.ListTable, status: HTMLElement, eventArgs?: any) { + const lines = [ + `frozenRowCount: ${table.frozenRowCount}`, + `frozenColCount: ${table.frozenColCount}`, + eventArgs + ? `last context_menu_click: ${JSON.stringify(eventArgs.contextMenu)}` + : 'last context_menu_click: waiting for menu click' + ]; + status.textContent = lines.join('\n'); +} + +export function createTableInstance(status: HTMLElement) { + const plugin = new ContextMenuPlugin(); + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records: createRecords(), + columns: [ + { field: 'id', title: 'ID', width: 80 }, + { field: 'name', title: 'Name', width: 160 }, + { field: 'city', title: 'City', width: 160 }, + { field: 'value', title: 'Value', width: 120 } + ], + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + widthMode: 'standard', + heightMode: 'standard', + plugins: [plugin] + }; + + const tableInstance = new VTable.ListTable(option); + tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { + updateStatus(tableInstance, status, args); + // eslint-disable-next-line no-console + console.log('context_menu_click', args); + }); + + updateStatus(tableInstance, status); + window.tableInstance = tableInstance; + return tableInstance; +} + +export function createTable() { + const info = document.createElement('div'); + info.style.margin = '10px'; + info.style.padding = '10px'; + info.style.border = '1px solid #ddd'; + info.style.borderRadius = '4px'; + info.style.backgroundColor = '#f9f9f9'; + info.innerHTML = ` +

Issue 4655: context menu click event

+

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

+

The table should emit context_menu_click with menu details in contextMenu.

+ `; + document.body.appendChild(info); + + const status = document.createElement('pre'); + status.style.margin = '10px'; + status.style.padding = '10px'; + status.style.border = '1px solid #ddd'; + status.style.borderRadius = '4px'; + status.style.backgroundColor = '#fff'; + document.body.appendChild(status); + + const container = document.createElement('div'); + container.id = CONTAINER_ID; + container.style.width = '100%'; + container.style.height = '460px'; + document.body.appendChild(container); + + return createTableInstance(status); +} diff --git a/packages/vtable-plugins/demo/menu.ts b/packages/vtable-plugins/demo/menu.ts index f110a5cb4b..da8063e917 100644 --- a/packages/vtable-plugins/demo/menu.ts +++ b/packages/vtable-plugins/demo/menu.ts @@ -156,6 +156,10 @@ export const menus = [ path: 'context-menu', name: 'context-menu' }, + { + path: 'context-menu', + name: 'issue-4655-context-menu-click' + }, { path: 'context-menu', name: 'issue-5215-context-menu-canvas' diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 47f9cf06bc..1e8aa5d7c3 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -249,8 +249,22 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { // 菜单项处理逻辑 this.handleMenuClick(args, table); } + this.fireContextMenuClick(args, table); }; + private fireContextMenuClick(args: MenuClickEventArgs, table: ListTable): void { + const { colIndex, rowIndex, ...contextMenu } = args; + table.fireListeners(TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: colIndex ?? -1, + row: rowIndex ?? -1, + contextMenu: { + ...contextMenu, + colIndex, + rowIndex + } + }); + } + private showContextMenu(menuItems: MenuItemOrSeparator[], x: number, y: number, col: number, row: number): void { // 显示菜单 this.menuManager.showMenu( diff --git a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts index 7011c481b0..074c4972f2 100644 --- a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts +++ b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts @@ -68,6 +68,10 @@ export interface TableEvents { * 画布右键事件 */ CONTEXTMENU_CANVAS: 'contextmenu_canvas'; + /** + * 右键菜单项点击事件 + */ + CONTEXT_MENU_CLICK: 'context_menu_click'; /** * 列宽调整事件 */ @@ -287,6 +291,7 @@ export const TABLE_EVENT_TYPE: TableEvents = { MOUSELEAVE_CELL: 'mouseleave_cell', CONTEXTMENU_CELL: 'contextmenu_cell', CONTEXTMENU_CANVAS: 'contextmenu_canvas', + CONTEXT_MENU_CLICK: 'context_menu_click', RESIZE_COLUMN: 'resize_column', RESIZE_COLUMN_END: 'resize_column_end', RESIZE_ROW: 'resize_row', diff --git a/packages/vtable/src/ts-types/events.ts b/packages/vtable/src/ts-types/events.ts index bab6a41093..5ceee38e19 100644 --- a/packages/vtable/src/ts-types/events.ts +++ b/packages/vtable/src/ts-types/events.ts @@ -7,7 +7,7 @@ import type { ListTableConstructorOptions, PivotTableConstructorOptions } from './table-engine'; -import type { DropDownMenuEventArgs, MenuListItem, PivotInfo } from './menu'; +import type { ContextMenuClickEventArgs, DropDownMenuEventArgs, MenuListItem, PivotInfo } from './menu'; import type { IDimensionInfo, MergeCellInfo, RectProps, SortOrder } from './common'; import type { IconFuncTypeEnum, CellInfo, HierarchyState, ColumnsDefine } from '.'; @@ -84,6 +84,7 @@ export interface TableEventHandlersEventArgumentMap { mouseup_cell: MousePointerCellEvent; contextmenu_cell: MousePointerMultiCellEvent; contextmenu_canvas: MousePointerCellEvent; + context_menu_click: ContextMenuClickEventArgs; before_keydown: KeydownEvent; keydown: KeydownEvent; scroll: { @@ -395,6 +396,7 @@ export interface TableEventHandlersReturnMap { mouseup_cell: void; contextmenu_cell: void; contextmenu_canvas: void; + context_menu_click: void; before_keydown: void; keydown: void; scroll: void; diff --git a/packages/vtable/src/ts-types/menu.ts b/packages/vtable/src/ts-types/menu.ts index a19dcfc0ac..1751602e1e 100644 --- a/packages/vtable/src/ts-types/menu.ts +++ b/packages/vtable/src/ts-types/menu.ts @@ -75,6 +75,19 @@ export type DropDownMenuEventArgs = { highlight: boolean; } & DropDownMenuEventInfo; +export type ContextMenuClickEventArgs = { + col: number; + row: number; + contextMenu: { + menuKey: string; + menuText: string; + rowIndex?: number; + colIndex?: number; + cellValue?: any; + inputValue?: number | string; + }; +}; + export type DropDownMenuEventInfo = { field?: FieldDef; /**format之后的值 */ From 6379b23f0e75320fc25298773b0793887d81fa56 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 7 Aug 2026 15:38:08 +0800 Subject: [PATCH 10/44] fix: stabilize React plugin Jest setup --- .../__tests__/list-table-plugins.test.tsx | 11 ++++---- packages/react-vtable/jest.config.js | 26 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/react-vtable/__tests__/list-table-plugins.test.tsx b/packages/react-vtable/__tests__/list-table-plugins.test.tsx index 02176792e1..e080bb782b 100644 --- a/packages/react-vtable/__tests__/list-table-plugins.test.tsx +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -9,7 +9,6 @@ import { ListColumn, ListTable } from '../src'; describe('ListTable plugins', () => { let container: HTMLDivElement; let root: Root; - let consoleError: jest.SpyInstance; beforeEach(() => { container = document.createElement('div'); @@ -17,7 +16,6 @@ describe('ListTable plugins', () => { container.style.height = '400px'; document.body.appendChild(container); root = createRoot(container); - consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(() => { @@ -25,11 +23,15 @@ describe('ListTable plugins', () => { root.unmount(); }); container.remove(); - consoleError.mockRestore(); }); test('does not enter an update loop when a filter plugin is configured', async () => { + let readyResolve!: () => void; + const ready = new Promise(resolve => { + readyResolve = resolve; + }); const onReady = jest.fn(); + onReady.mockImplementationOnce(() => readyResolve()); await act(async () => { root.render( @@ -45,10 +47,9 @@ describe('ListTable plugins', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); }); + await ready; expect(onReady).toHaveBeenCalledTimes(1); - expect(consoleError.mock.calls.some(args => String(args[0]).includes('Maximum update depth exceeded'))).toBe(false); }); }); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js index 16b084c98d..31f4177a67 100644 --- a/packages/react-vtable/jest.config.js +++ b/packages/react-vtable/jest.config.js @@ -29,12 +29,26 @@ module.exports = { }, cacheDirectory: '/.jest-cache', moduleNameMapper: { - 'd3-color': path.resolve(__dirname, '../vtable/node_modules/d3-color/dist/d3-color.min.js'), - 'd3-array': path.resolve(__dirname, '../vtable/node_modules/d3-array/dist/d3-array.min.js'), - 'd3-geo': path.resolve(__dirname, '../vtable/node_modules/d3-geo/dist/d3-geo.min.js'), - 'd3-dsv': path.resolve(__dirname, '../vtable/node_modules/d3-dsv/dist/d3-dsv.min.js'), - 'd3-hexbin': path.resolve(__dirname, '../vtable/node_modules/d3-hexbin/build/d3-hexbin.min.js'), - 'd3-hierarchy': path.resolve(__dirname, '../vtable/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js'), + 'd3-array': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-array@3.2.3/node_modules/d3-array/dist/d3-array.min.js' + ), + 'd3-geo': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-geo@3.1.1/node_modules/d3-geo/dist/d3-geo.min.js' + ), + 'd3-dsv': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-dsv@3.0.1/node_modules/d3-dsv/dist/d3-dsv.min.js' + ), + 'd3-hexbin': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hexbin@0.2.2/node_modules/d3-hexbin/build/d3-hexbin.min.js' + ), + 'd3-hierarchy': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hierarchy@3.1.2/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js' + ), ...createVRenderModuleNameMapper('/../vtable/node_modules'), '@visactor/vtable$': '/../vtable/src/index', '@visactor/vtable/es/(.*)': '/../vtable/src/$1', From 8824628138d0debbd7f2111ab4cac9f5a0372a34 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 10 Aug 2026 14:43:49 +0800 Subject: [PATCH 11/44] docs(vtable): clarify context menu click event behavior Clarify when context_menu_click runs and mark menu position fields as optional so the docs match plugin behavior. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/guide/en/plugin/context-menu.md | 6 +++--- docs/assets/guide/zh/plugin/context-menu.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/assets/guide/en/plugin/context-menu.md b/docs/assets/guide/en/plugin/context-menu.md index 43ebf5803f..28b549192e 100644 --- a/docs/assets/guide/en/plugin/context-menu.md +++ b/docs/assets/guide/en/plugin/context-menu.md @@ -135,7 +135,7 @@ const contextMenuPlugin = new ContextMenuPlugin({ ## Listen to Menu Item Click Events -If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled. It is fired after the plugin finishes handling the menu item, so built-in operations such as freeze, insert, and delete have already taken effect. +If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled, and it is fired after the corresponding menu item handling logic finishes. Note that when `menuClickCallback` is configured as a function, the plugin runs that callback and skips the built-in menu handling. Built-in operations such as freeze, insert, and delete are only executed when `menuClickCallback` is not configured, or when it is configured as an object and the current `menuKey` does not match a custom callback. ```typescript import * as VTable from '@visactor/vtable'; @@ -170,8 +170,8 @@ Event argument description: | `row` | number | Row index of the menu trigger position; `-1` when there is no corresponding cell | | `contextMenu.menuKey` | string | Unique key of the clicked menu item | | `contextMenu.menuText` | string | Display text of the clicked menu item | -| `contextMenu.rowIndex` | number | Row index of the menu trigger position | -| `contextMenu.colIndex` | number | Column index of the menu trigger position | +| `contextMenu.rowIndex` | number \| undefined | Row index of the menu trigger position; may be `undefined` when no row context exists | +| `contextMenu.colIndex` | number \| undefined | Column index of the menu trigger position; may be `undefined` when no column context exists | | `contextMenu.cellValue` | any | Cell value at the menu trigger position | | `contextMenu.inputValue` | number \| string | Input value of an input menu item | diff --git a/docs/assets/guide/zh/plugin/context-menu.md b/docs/assets/guide/zh/plugin/context-menu.md index 10f23b5fb9..34804adc1b 100644 --- a/docs/assets/guide/zh/plugin/context-menu.md +++ b/docs/assets/guide/zh/plugin/context-menu.md @@ -135,7 +135,7 @@ const contextMenuPlugin = new ContextMenuPlugin({ ## 监听菜单项点击事件 -如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在插件完成菜单项处理后触发,因此内置的冻结、插入、删除等菜单操作已经生效。 +如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在菜单项对应的处理逻辑完成后触发。需要注意的是,如果 `menuClickCallback` 配置为函数,插件会执行该回调并跳过内置菜单处理;如果未配置 `menuClickCallback`,或配置为对象但当前 `menuKey` 未命中自定义回调,才会执行内置的冻结、插入、删除等菜单操作。 ```typescript import * as VTable from '@visactor/vtable'; @@ -170,8 +170,8 @@ tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { | `row` | number | 菜单触发位置对应的行号;没有对应单元格时为 `-1` | | `contextMenu.menuKey` | string | 被点击菜单项的唯一标识 | | `contextMenu.menuText` | string | 被点击菜单项的展示文本 | -| `contextMenu.rowIndex` | number | 菜单触发位置对应的行号 | -| `contextMenu.colIndex` | number | 菜单触发位置对应的列号 | +| `contextMenu.rowIndex` | number \| undefined | 菜单触发位置对应的行号;没有对应行时可能为 `undefined` | +| `contextMenu.colIndex` | number \| undefined | 菜单触发位置对应的列号;没有对应列时可能为 `undefined` | | `contextMenu.cellValue` | any | 菜单触发位置对应的单元格值 | | `contextMenu.inputValue` | number \| string | 输入型菜单项中的输入值 | From 8490fb8971ff7a834d9fc01a50fac4c1323ab260 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 11 Aug 2026 11:22:17 +0800 Subject: [PATCH 12/44] fix(vtable-editors): avoid HTML injection in list editor Create list editor options with DOM APIs so configured values are treated as text instead of parsed as HTML. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable-editors/src/list-editor.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/vtable-editors/src/list-editor.ts b/packages/vtable-editors/src/list-editor.ts index b18f37d5b2..474f3381f0 100644 --- a/packages/vtable-editors/src/list-editor.ts +++ b/packages/vtable-editors/src/list-editor.ts @@ -41,14 +41,13 @@ export class ListEditor implements IEditor { // create option tags const { values } = this.editorConfig; - let opsStr = ''; values.forEach(item => { - opsStr += - item === value - ? `` - : ``; + const option = document.createElement('option'); + option.value = item; + option.textContent = item; + option.selected = item === value; + select.appendChild(option); }); - select.innerHTML = opsStr; this.container.appendChild(select); // this._bindSelectChangeEvent(); From 4d3d2b3fa9145f0672276be91a9dd110a19dd15c Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 11 Aug 2026 16:11:24 +0800 Subject: [PATCH 13/44] test(vtable-editors): cover list editor HTML injection Add a focused regression test for malicious list editor option values so the XSS fix stays protected. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/list-editor.test.ts | 48 +++++++++++++++++++ packages/vtable-editors/jest.config.js | 17 +++++++ packages/vtable-editors/tsconfig.test.json | 11 +++++ 3 files changed, 76 insertions(+) create mode 100644 packages/vtable-editors/__tests__/list-editor.test.ts create mode 100644 packages/vtable-editors/jest.config.js create mode 100644 packages/vtable-editors/tsconfig.test.json diff --git a/packages/vtable-editors/__tests__/list-editor.test.ts b/packages/vtable-editors/__tests__/list-editor.test.ts new file mode 100644 index 0000000000..96d328b5ad --- /dev/null +++ b/packages/vtable-editors/__tests__/list-editor.test.ts @@ -0,0 +1,48 @@ +import { ListEditor } from '../src/list-editor'; + +describe('ListEditor', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('treats configured option values as text instead of HTML', () => { + const values = [ + '', + 'x" onclick="alert(1)', + '' + ]; + const container = document.createElement('div'); + document.body.appendChild(container); + + const editor = new ListEditor({ values }); + editor.onStart({ + container, + value: values[1], + referencePosition: { + rect: { + left: 0, + top: 0, + width: 100, + height: 24 + } + }, + endEdit: () => undefined, + table: {}, + col: 0, + row: 0 + }); + + const select = container.querySelector('select'); + expect(select).not.toBeNull(); + if (!select) { + throw new Error('ListEditor did not create a select element'); + } + expect(container.querySelector('img#xss-image')).toBeNull(); + expect(container.querySelector('svg#xss-svg')).toBeNull(); + expect(container.querySelectorAll('select')).toHaveLength(1); + expect(select.options).toHaveLength(values.length); + expect(Array.from(select.options).map(option => option.textContent)).toEqual(values); + expect(Array.from(select.options).map(option => option.value)).toEqual(values); + expect(select.value).toBe(values[1]); + }); +}); diff --git a/packages/vtable-editors/jest.config.js b/packages/vtable-editors/jest.config.js new file mode 100644 index 0000000000..1bb6fee5b4 --- /dev/null +++ b/packages/vtable-editors/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: { + exclude: ['**'] + }, + tsconfig: './tsconfig.test.json' + }, + __DEV__: true + }, + cacheDirectory: '/.jest-cache' +}; diff --git a/packages/vtable-editors/tsconfig.test.json b/packages/vtable-editors/tsconfig.test.json new file mode 100644 index 0000000000..f46aaf69df --- /dev/null +++ b/packages/vtable-editors/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "@internal/ts-config/tsconfig.base.json", + "compilerOptions": { + "types": ["jest", "offscreencanvas", "node"], + "lib": ["DOM", "ESNext"], + "baseUrl": "./", + "rootDir": ".", + "paths": {} + }, + "include": ["src", "__tests__"] +} From 9fe868cd527b7ba896160019767e9ccf7b7e8484 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 11 Aug 2026 16:37:31 +0800 Subject: [PATCH 14/44] test(vtable-editors): add package test script Expose a standard test command so the editor regression suite can be run directly by CI and maintainers. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable-editors/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vtable-editors/package.json b/packages/vtable-editors/package.json index 600baa8154..49508217a3 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -28,6 +28,7 @@ "eslint": "eslint --debug --fix src/", "build": "npm run fix-memory-limit && bundle", "dev": "bundle --clean -f es -w", + "test": "jest --silent", "test-cov": "jest -w 16 --coverage", "test-live": "npm run test-watch __tests__/unit/", "test-watch": "DEBUG_MODE=1 jest --watch", From 7566fb25832d72ef2f18d2ebe40971f4d03a05fa Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 11 Aug 2026 17:58:00 +0800 Subject: [PATCH 15/44] feat(vtable): add audio cell type support Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- common/config/rush/pnpm-lock.yaml | 3 + docs/assets/demo/en/cell-type/multi-type.md | 22 +- docs/assets/demo/en/cell-type/progressbar.md | 2 +- docs/assets/demo/zh/cell-type/multi-type.md | 22 +- docs/assets/demo/zh/cell-type/progressbar.md | 2 +- .../en/basic_function/row_series_number.md | 3 +- docs/assets/guide/en/cell_type/cellType.md | 17 +- docs/assets/guide/en/cell_type/image_video.md | 6 +- .../guide/en/custom_define/custom_icon.md | 2 +- .../list_table_define_and_generate.md | 2 +- .../zh/basic_function/row_series_number.md | 3 +- docs/assets/guide/zh/cell_type/cellType.md | 17 +- docs/assets/guide/zh/cell_type/image_video.md | 8 +- .../guide/zh/custom_define/custom_icon.md | 2 +- .../list_table_define_and_generate.md | 2 +- .../option/en/column/audio-column-type.md | 13 ++ .../option/en/column/base-column-type.md | 2 +- docs/assets/option/en/column/column-define.md | 6 +- .../option/en/column/image-column-type.md | 4 +- .../en/dimension/audio-dimension-type.md | 13 ++ .../en/dimension/columns-dimenison-define.md | 9 +- .../en/dimension/image-dimension-type.md | 4 +- .../en/dimension/rows-dimension-define.md | 9 +- .../en/indicator/audio-indicator-type.md | 13 ++ .../en/indicator/base-indicator-type.md | 2 +- .../en/indicator/image-indicator-type.md | 4 +- .../option/en/indicator/indicator-define.md | 7 +- .../option/zh/column/audio-column-type.md | 13 ++ .../option/zh/column/base-column-type.md | 2 +- docs/assets/option/zh/column/column-define.md | 6 +- .../option/zh/column/image-column-type.md | 2 +- .../zh/dimension/audio-dimension-type.md | 13 ++ .../zh/dimension/columns-dimenison-define.md | 7 +- .../zh/dimension/image-dimension-type.md | 4 +- .../zh/dimension/rows-dimension-define.md | 6 +- .../zh/indicator/audio-indicator-type.md | 13 ++ .../zh/indicator/base-indicator-type.md | 2 +- .../zh/indicator/image-indicator-type.md | 4 +- .../option/zh/indicator/indicator-define.md | 5 +- .../cell-type/video-cell-first-frame.test.ts | 62 ++++- packages/vtable/examples/menu.ts | 4 + packages/vtable/examples/type/audio.ts | 210 +++++++++++++++++ packages/vtable/package.json | 1 + packages/vtable/src/ListTable-all.ts | 2 + packages/vtable/src/PivotChart.ts | 2 + packages/vtable/src/PivotTable-all.ts | 2 + .../vtable/src/body-helper/body-helper.ts | 3 +- packages/vtable/src/event/media-click.ts | 215 +++++++++++++++++- .../vtable/src/header-helper/header-helper.ts | 2 +- packages/vtable/src/layout/layout-helper.ts | 18 +- .../scenegraph/group-creater/cell-helper.ts | 20 ++ .../group-creater/cell-type/audio-cell.ts | 113 +++++++++ .../group-creater/cell-type/index.ts | 5 + .../group-creater/cell-type/video-cell.ts | 32 ++- .../src/scenegraph/layout/update-height.ts | 2 +- .../src/scenegraph/layout/update-width.ts | 2 +- packages/vtable/src/scenegraph/scenegraph.ts | 2 +- packages/vtable/src/tools/media.ts | 5 + packages/vtable/src/ts-types/column/type.ts | 3 +- .../list-table/define/image-define.ts | 4 +- .../src/ts-types/list-table/layout-map/api.ts | 6 +- .../pivot-table/dimension/image-dimension.ts | 2 +- .../pivot-table/indicator/image-indicator.ts | 4 +- packages/vtable/src/ts-types/table-engine.ts | 6 +- 64 files changed, 919 insertions(+), 84 deletions(-) create mode 100644 docs/assets/option/en/column/audio-column-type.md create mode 100644 docs/assets/option/en/dimension/audio-dimension-type.md create mode 100644 docs/assets/option/en/indicator/audio-indicator-type.md create mode 100644 docs/assets/option/zh/column/audio-column-type.md create mode 100644 docs/assets/option/zh/dimension/audio-dimension-type.md create mode 100644 docs/assets/option/zh/indicator/audio-indicator-type.md create mode 100644 packages/vtable/examples/type/audio.ts create mode 100644 packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts create mode 100644 packages/vtable/src/tools/media.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b36bdbfeb9..97130679ff 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@babel/core': specifier: 7.20.12 version: 7.20.12 + '@babel/plugin-transform-react-jsx': + specifier: 7.28.6 + version: 7.28.6(@babel/core@7.20.12) '@babel/preset-env': specifier: 7.20.2 version: 7.20.2(@babel/core@7.20.12) diff --git a/docs/assets/demo/en/cell-type/multi-type.md b/docs/assets/demo/en/cell-type/multi-type.md index c24d26de37..933bca5e1d 100644 --- a/docs/assets/demo/en/cell-type/multi-type.md +++ b/docs/assets/demo/en/cell-type/multi-type.md @@ -14,13 +14,14 @@ Specify the cell content type by cellType. Image video source: https://birdsofth ## Key Configurations -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'Text' | 'link' | 'image' | 'video' +| 'audio' | 'Sparkline' | 'progressbar' | 'chart'; @@ -28,12 +29,19 @@ cellType: ## Code demo ```javascript livedemo template=vtable +const audioFiles = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; + const records = [ { name: 'pigeon', introduction: 'The pigeon is a common urban bird with gray plumage and a short, stout beak', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.mp4', + audio: audioFiles[0], YoY: 50, QoQ: 10, trend: [ @@ -50,6 +58,7 @@ const records = [ introduction: 'Swallow is a kind of bird that is good at flying, usually perches near houses and buildings.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.mp4', + audio: audioFiles[1], YoY: 10, QoQ: -10, trend: [ @@ -66,6 +75,7 @@ const records = [ 'The magpie is a common small bird mainly found in Asia. They are small in size with a black head and throat, gray back and white belly. Magpies are social animals and often live in woods Breeding nests in China or in urban parks, feeding on insects, fruit and seeds. They are also highly intelligent and social, and are considered an intelligent, playful bird.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.mp4', + audio: audioFiles[2], YoY: -10, QoQ: -10, trend: [ @@ -82,6 +92,7 @@ const records = [ 'The peacock is a large, beautiful bird with brilliant blue-green plumage and a long tail. Native to South Asia, it feeds on insects, fruit, and seeds.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.mp4', + audio: audioFiles[0], YoY: -10, QoQ: -10, trend: [ @@ -98,6 +109,7 @@ const records = [ 'The flamingo is a beautiful pink bird with long legs and neck, good at swimming, and is a common bird in tropical areas.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.mp4', + audio: audioFiles[1], YoY: -1, QoQ: -6, trend: [ @@ -114,6 +126,7 @@ const records = [ 'The ostrich is a large bird that cannot fly and runs fast. It is one of the largest birds in the world', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.mp4', + audio: audioFiles[2], YoY: -3, QoQ: 10, trend: [ @@ -130,6 +143,7 @@ const records = [ 'Mandarin duck is a kind of two-winged bird. The head of the male bird is blue and the head of the female bird is brown. It usually perches and mates in pairs. It is one of the symbols in Chinese culture.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.mp4', + audio: audioFiles[0], YoY: 10, QoQ: 16, trend: [ @@ -171,6 +185,12 @@ const columns = [ width: 150 // imageSizing : 'keep-aspect-ratio', }, + { + field: 'audio', + title: 'bird audio', + cellType: 'audio', + width: 150 + }, { field: 'YoY', title: 'count Year-over-Year', diff --git a/docs/assets/demo/en/cell-type/progressbar.md b/docs/assets/demo/en/cell-type/progressbar.md index 1d61345b91..6b9d4fc209 100644 --- a/docs/assets/demo/en/cell-type/progressbar.md +++ b/docs/assets/demo/en/cell-type/progressbar.md @@ -14,7 +14,7 @@ Demonstrate multiple ways to use stripe types ## Key Configurations -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'Text' diff --git a/docs/assets/demo/zh/cell-type/multi-type.md b/docs/assets/demo/zh/cell-type/multi-type.md index 63d7fa2a21..37ef0f6337 100644 --- a/docs/assets/demo/zh/cell-type/multi-type.md +++ b/docs/assets/demo/zh/cell-type/multi-type.md @@ -14,13 +14,14 @@ option: ListTable-columns-text#cellType ## 关键配置 -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'text' | 'link' | 'image' | 'video' +| 'audio' | 'sparkline' | 'progressbar' | 'chart'; @@ -28,12 +29,19 @@ cellType: ## 代码演示 ```javascript livedemo template=vtable +const audioFiles = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; + const records = [ { name: 'pigeon', introduction: 'The pigeon is a common urban bird with gray plumage and a short, stout beak', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/pigeon.mp4', + audio: audioFiles[0], YoY: 50, QoQ: 10, trend: [ @@ -50,6 +58,7 @@ const records = [ introduction: 'Swallow is a kind of bird that is good at flying, usually perches near houses and buildings.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/swallow.mp4', + audio: audioFiles[1], YoY: 10, QoQ: -10, trend: [ @@ -66,6 +75,7 @@ const records = [ 'The magpie is a common small bird mainly found in Asia. They are small in size with a black head and throat, gray back and white belly. Magpies are social animals and often live in woods Breeding nests in China or in urban parks, feeding on insects, fruit and seeds. They are also highly intelligent and social, and are considered an intelligent, playful bird.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Magpie.mp4', + audio: audioFiles[2], YoY: -10, QoQ: -10, trend: [ @@ -82,6 +92,7 @@ const records = [ 'The peacock is a large, beautiful bird with brilliant blue-green plumage and a long tail. Native to South Asia, it feeds on insects, fruit, and seeds.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/peacock.mp4', + audio: audioFiles[0], YoY: -10, QoQ: -10, trend: [ @@ -98,6 +109,7 @@ const records = [ 'The flamingo is a beautiful pink bird with long legs and neck, good at swimming, and is a common bird in tropical areas.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/flamingo.mp4', + audio: audioFiles[1], YoY: -1, QoQ: -6, trend: [ @@ -114,6 +126,7 @@ const records = [ 'The ostrich is a large bird that cannot fly and runs fast. It is one of the largest birds in the world', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/ostrich.mp4', + audio: audioFiles[2], YoY: -3, QoQ: 10, trend: [ @@ -130,6 +143,7 @@ const records = [ 'Mandarin duck is a kind of two-winged bird. The head of the male bird is blue and the head of the female bird is brown. It usually perches and mates in pairs. It is one of the symbols in Chinese culture.', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.jpeg', video: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/media/Mandarin.mp4', + audio: audioFiles[0], YoY: 10, QoQ: 16, trend: [ @@ -171,6 +185,12 @@ const columns = [ width: 150 // imageSizing : 'keep-aspect-ratio', }, + { + field: 'audio', + title: 'bird audio', + cellType: 'audio', + width: 150 + }, { field: 'YoY', title: 'count Year-over-Year', diff --git a/docs/assets/demo/zh/cell-type/progressbar.md b/docs/assets/demo/zh/cell-type/progressbar.md index d3157cb433..a6ab0bce32 100644 --- a/docs/assets/demo/zh/cell-type/progressbar.md +++ b/docs/assets/demo/zh/cell-type/progressbar.md @@ -14,7 +14,7 @@ option: ListTable-columns-progressbar#cellType ## 关键配置 -headerType: 'text' | 'link' | 'image' | 'video'; +headerType: 'text' | 'link' | 'image' | 'video' | 'audio'; cellType: 'text' diff --git a/docs/assets/guide/en/basic_function/row_series_number.md b/docs/assets/guide/en/basic_function/row_series_number.md index 43ae8f0cad..6ca7770260 100644 --- a/docs/assets/guide/en/basic_function/row_series_number.md +++ b/docs/assets/guide/en/basic_function/row_series_number.md @@ -16,7 +16,8 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; diff --git a/docs/assets/guide/en/cell_type/cellType.md b/docs/assets/guide/en/cell_type/cellType.md index dfd0699d71..7c0e5ae7f5 100644 --- a/docs/assets/guide/en/cell_type/cellType.md +++ b/docs/assets/guide/en/cell_type/cellType.md @@ -1,20 +1,21 @@ # data type -In the field of data analytics and visualization, one of the typical applications of VTable is to display and present various types of data. The so-called types here include: text, links, pictures, videos, progress bars, and charts. By supporting multiple column types, VTable can provide users with a richer and more diverse data experience, allowing users to observe and understand data from different perspectives and Dimensions. +In the field of data analytics and visualization, one of the typical applications of VTable is to display and present various types of data. The so-called types here include: text, links, pictures, videos, audio, progress bars, and charts. By supporting multiple column types, VTable can provide users with a richer and more diverse data experience, allowing users to observe and understand data from different perspectives and Dimensions. Next, this article will focus on the various column types and their usage and characteristics to help you gain insight into data lake visualization using VTable. ## Support type -There are 7 data types supported by VTable, namely: +There are 8 data types supported by VTable, namely: 1. Text 2. Link 3. Image 4. Video -5. progressbar -6. Sparkline -7. Chart +5. Audio +6. progressbar +7. Sparkline +8. Chart Next, we'll cover each column type one by one. @@ -119,6 +120,10 @@ Through the above settings, we can customize the columns of the "image" type to When the column type is "video", it is used to display the data of the video type. All configuration items of "video" are the same as image, you can refer to the image type configuration item. +## Audio + +When the column type is "audio", it is used to display audio data. The cell displays an audio icon, and clicking it opens the preview window with the browser audio controls. All configuration items of "audio" are the same as image and video, you can refer to the image type configuration item. + ## progressbar When the column type is "progressbar", it is used to display the data of the progress bar type. The progress bar data can be processed by setting the type of the progress bar, the data range of the progress bar, etc. @@ -258,7 +263,7 @@ The headerType in the rows and columns of the pivot table, and the cellType in t ## Extension: Custom Column Types (TypeScript) -In real-world applications, in addition to VTable’s built-in `cellType` values (such as `text`, `link`, `image`, `video`, `progressbar`, `sparkline`, and `chart`), it is often necessary to attach **business-specific configurations** to column definitions, for example: +In real-world applications, in addition to VTable’s built-in `cellType` values (such as `text`, `link`, `image`, `video`, `audio`, `progressbar`, `sparkline`, and `chart`), it is often necessary to attach **business-specific configurations** to column definitions, for example: - Permission or visibility control: `permissionKey` - Tracking and analytics metadata: `trackId` / `trackParams` diff --git a/docs/assets/guide/en/cell_type/image_video.md b/docs/assets/guide/en/cell_type/image_video.md index 1190274802..d3c7e645c6 100644 --- a/docs/assets/guide/en/cell_type/image_video.md +++ b/docs/assets/guide/en/cell_type/image_video.md @@ -1,8 +1,8 @@ # Multimedia Type -Tables can display various types of data, such as text, numbers, charts, etc. Among them, the display of multimedia types (such as images and videos) in some scenes is particularly important, they can visually display information and help users quickly obtain key data. For example, e-commerce websites can display product pictures in tables, which is convenient for store owners to quickly view product information; video websites can display video preview diagrams in tables, which is convenient for users to quickly understand video content. By displaying multimedia types in tables, the effect of data display and user experience can be effectively improved. +Tables can display various types of data, such as text, numbers, charts, etc. Among them, the display of multimedia types (such as images, videos, and audio) in some scenes is particularly important, they can visually display information and help users quickly obtain key data. For example, e-commerce websites can display product pictures in tables, which is convenient for store owners to quickly view product information; video websites can display video preview diagrams in tables; audio libraries can display playable audio entries in tables. By displaying multimedia types in tables, the effect of data display and user experience can be effectively improved. -Note: The following descriptions are based on image types, and video types are also applicable. +Note: The following descriptions are based on image types, and video and audio types are also applicable. ## Introduction to unique configuration items @@ -45,5 +45,5 @@ Image type specific configuration items in terms of style style: ## Click preview of image -In some scenes, we want to click on the image to preview the larger image. VTable defaults to clicking on the multimedia (including videos and pictures) cell to pop up the preview window. +In some scenes, we want to click on the image to preview the larger image. VTable defaults to clicking on the multimedia (including images, videos, and audio) cell to pop up the preview window. The audio type displays an audio player in the preview window. ![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) diff --git a/docs/assets/guide/en/custom_define/custom_icon.md b/docs/assets/guide/en/custom_define/custom_icon.md index 8abf93d114..b328477090 100644 --- a/docs/assets/guide/en/custom_define/custom_icon.md +++ b/docs/assets/guide/en/custom_define/custom_icon.md @@ -210,7 +210,7 @@ The list of resettable internal icons is as follows: | Fixed Column | VTable. TYPES. IconFuncTypeEnum.frozen | "freeze" | Fixed Column Function Icon, Fixed State | | Fixed Columns | VTable. TYPES. IconFuncTypeEnum.frozen | "frozen" | Fixed Columns Function Icon, Fixed Status | | Fixed Columns | VTable. TYPES. IconFuncTypeEnum.frozen | "frozenCurrent" | Fixed Columns Function Icon, Frozen When Columns | -| Image or video address invalid | VTable. TYPES. IconFuncTypeEnum.damagePic | "damage_pic" | Multimedia resource parsing failed | +| Image, video, or audio address invalid | VTable. TYPES. IconFuncTypeEnum.damagePic | "damage_pic" | Multimedia resource parsing failed | | Loading | VTable. TYPES. IconFuncTypeEnum.loading_pic | "loading_pic" | Loading status | | Image image type parsing failed | VTable. TYPES. IconFuncTypeEnum.imageDamagePic | "image_damage_pic" | Image resource parsing failed | | Video video type parsing failed | VTable. TYPES. IconFuncTypeEnum.videoDamagePic | "video_damage_pic" | Video resource parsing failed | diff --git a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md index a2df037e00..282363ace7 100644 --- a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ The following are the Key Configurations items of the basic table and their desc * `container`: The container DOM element of the table, which needs to have width and height. * `records`: The data of the table, represented as an array. -* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'sparkline', 'progressbar', 'chart'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). +* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'audio', 'sparkline', 'progressbar', 'chart'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). * `frozenColCount`: Specifies the number of frozen columns. * `transpose`: Whether to transpose the table, the default is false. * `showHeader`: Whether to display the header, the default is true. diff --git a/docs/assets/guide/zh/basic_function/row_series_number.md b/docs/assets/guide/zh/basic_function/row_series_number.md index b99a87e3ad..3f09af0607 100644 --- a/docs/assets/guide/zh/basic_function/row_series_number.md +++ b/docs/assets/guide/zh/basic_function/row_series_number.md @@ -16,7 +16,8 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; diff --git a/docs/assets/guide/zh/cell_type/cellType.md b/docs/assets/guide/zh/cell_type/cellType.md index 5c404d4827..9d4208b6a4 100644 --- a/docs/assets/guide/zh/cell_type/cellType.md +++ b/docs/assets/guide/zh/cell_type/cellType.md @@ -1,20 +1,21 @@ # 数据类型 -在数据分析和可视化领域,VTable 的典型应用之一便是显示和呈现各种类型的数据,这里所谓的类型包括:文本、链接、图片、视频、进度条以及图表等。通过支持多种列类型,VTable 可以为用户现更丰富、更多样化的数据体验,便于用户从不同视角和维度来观察和理解数据。 +在数据分析和可视化领域,VTable 的典型应用之一便是显示和呈现各种类型的数据,这里所谓的类型包括:文本、链接、图片、视频、音频、进度条以及图表等。通过支持多种列类型,VTable 可以为用户现更丰富、更多样化的数据体验,便于用户从不同视角和维度来观察和理解数据。 接下来,本文将重点讨论各类列类型及其用法和特点,以帮助您深入了解使用 VTable 进行数据可视化的方法。 ## 支持类型 -VTable 所支持的数据类型共有 7 种,分别为: +VTable 所支持的数据类型共有 8 种,分别为: 1. text 2. link 3. image 4. video -5. progressbar -6. sparkline -7. chart +5. audio +6. progressbar +7. sparkline +8. chart 接下来,我们将逐一介绍每一种列类型的相关内容。 @@ -117,6 +118,10 @@ VTable 所支持的数据类型共有 7 种,分别为: 当列类型为 "video" 时,用于显示视频类型的数据。"video"所有配置项和image一样,可参考image类型配置项。 +## audio + +当列类型为 "audio" 时,用于显示音频类型的数据。单元格会展示音频图标,点击后会弹出预览窗口并使用浏览器音频控件播放。audio 的配置项和 image、video 一样,可参考 image 类型配置项。 + ## progressbar @@ -260,7 +265,7 @@ VTable 所支持的数据类型共有 7 种,分别为: ## 扩展:自定义列类型(TypeScript) -在实际业务中,除了 VTable 内置的 `cellType`(text/link/image/video/progressbar/sparkline/chart 等),我们常常还需要在列定义里附加一些 **业务侧专用配置**,例如: +在实际业务中,除了 VTable 内置的 `cellType`(text、link、image、video、audio、progressbar、sparkline、chart 等),我们常常还需要在列定义里附加一些 **业务侧专用配置**,例如: - 权限/可见性控制:`permissionKey` - 埋点信息:`trackId` / `trackParams` diff --git a/docs/assets/guide/zh/cell_type/image_video.md b/docs/assets/guide/zh/cell_type/image_video.md index 98e33b05ed..807c91fdfb 100644 --- a/docs/assets/guide/zh/cell_type/image_video.md +++ b/docs/assets/guide/zh/cell_type/image_video.md @@ -1,8 +1,8 @@ # 多媒体类型 -表格可以展示各种类型的数据,例如文本、数字、图表等。其中,多媒体类型(如 image 和 video)在某些场景中的展示尤其重要,它们可以直观地展示信息,帮助用户快速获取关键数据。例如,电商网站可以在表格中展示商品图片,便于店主快速查看商品信息;视频网站可以在表格中展示视频预览图,方便用户快速了解视频内容。通过在表格中展示多媒体类型,可以有效提高数据展示的效果和用户体验。 +表格可以展示各种类型的数据,例如文本、数字、图表等。其中,多媒体类型(如 image、video 和 audio)在某些场景中的展示尤其重要,它们可以直观地展示信息,帮助用户快速获取关键数据。例如,电商网站可以在表格中展示商品图片,便于店主快速查看商品信息;视频网站可以在表格中展示视频预览图,音频库可以在表格中展示可播放的音频入口。通过在表格中展示多媒体类型,可以有效提高数据展示的效果和用户体验。 -注:以下介绍均以image类型为例,video类型也同样适用。 +注:以下介绍均以 image 类型为例,video 和 audio 类型也同样适用。 ## 特有配置项介绍 @@ -42,5 +42,5 @@ image 类型在样式 style 方面的特有配置项: ``` ## image 的点击预览 -当某些场景下,我们希望点击图片可以预览大图。VTable默认点击多媒体(包括视频和图片)单元格即可以弹出预览窗口。 -![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) \ No newline at end of file +当某些场景下,我们希望点击图片可以预览大图。VTable 默认点击多媒体(包括图片、视频和音频)单元格即可以弹出预览窗口。其中 audio 类型会在预览窗口中展示音频播放控件。 +![image](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/03421afda76ced0240204bf01.gif) diff --git a/docs/assets/guide/zh/custom_define/custom_icon.md b/docs/assets/guide/zh/custom_define/custom_icon.md index e6d882150c..1f64971d17 100644 --- a/docs/assets/guide/zh/custom_define/custom_icon.md +++ b/docs/assets/guide/zh/custom_define/custom_icon.md @@ -210,7 +210,7 @@ VTable.register.icon('frozenCurrent', { | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "freeze" | 固定列功能图标 可固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozen" | 固定列功能图标 已固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozenCurrent" | 固定列功能图标 被冻结当列 | -| 图片 or 视频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | +| 图片、视频或音频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | | 加载中 | VTable.TYPES.IconFuncTypeEnum.loading_pic | "loading_pic" | 加载中状态 | |图片image类型解析失败 |VTable.TYPES.IconFuncTypeEnum.imageDamagePic | "image_damage_pic" | 图片资源解析失败 | |视频video类型解析失败 |VTable.TYPES.IconFuncTypeEnum.videoDamagePic | "video_damage_pic" | 视频资源解析失败 | diff --git a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md index ce6483df31..3fe6387329 100644 --- a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ * `container`:表格的容器 DOM 元素,dom容器需要提前设置好宽高。这里非必填,可以放到初始化函数的第一个参数中如`new VTable.ListTable(container, option);` * `records`:表格的数据,以数组形式表示。 -* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'sparkline'、'progressbar'、'chart'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 +* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'audio'、'sparkline'、'progressbar'、'chart'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 * `frozenColCount`:指定冻结列数。 * `transpose`:是否转置表格,默认为 false。 * `showHeader`:是否显示表头,默认为 true。 diff --git a/docs/assets/option/en/column/audio-column-type.md b/docs/assets/option/en/column/audio-column-type.md new file mode 100644 index 0000000000..31a1e8e48c --- /dev/null +++ b/docs/assets/option/en/column/audio-column-type.md @@ -0,0 +1,13 @@ +{{ target: audio-cell-type }} + +#${prefix} columns.audio(string) + +Specify the column type as `'audio'`, cellType can be omitted with the default as 'text' + +##${prefix} cellType(string) = 'audio' + +Specify the column type as `'audio'`, cellType can be omitted with the default as `'text'`. All configuration options are the same as the image, and you can refer to the image type configuration options. + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/column/base-column-type.md b/docs/assets/option/en/column/base-column-type.md index 79f360aa01..1132322d88 100644 --- a/docs/assets/option/en/column/base-column-type.md +++ b/docs/assets/option/en/column/base-column-type.md @@ -2,7 +2,7 @@ ${prefix} headerType(string) = 'text' -Specify header type, optional: `'text'|'link'|'image'|'video'|'checkbox'`, default `'text'`. +Specify header type, optional: `'text'|'link'|'image'|'video'|'audio'|'checkbox'`, default `'text'`. ${prefix} field(string) diff --git a/docs/assets/option/en/column/column-define.md b/docs/assets/option/en/column/column-define.md index 89d01c867d..85da2af9f7 100644 --- a/docs/assets/option/en/column/column-define.md +++ b/docs/assets/option/en/column/column-define.md @@ -2,7 +2,7 @@ #${prefix} columns -Table column configuration, each column needs to set configuration items separately, and the configuration items differ slightly depending on the cellType. cellType can support: `'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart`, The specific configuration items for each cellType are as follows: +Table column configuration, each column needs to set configuration items separately, and the configuration items differ slightly depending on the cellType. cellType can support: `'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'`, The specific configuration items for each cellType are as follows: {{ use: text-cell-type( prefix = ${prefix} @@ -20,6 +20,10 @@ Table column configuration, each column needs to set configuration items separat prefix = ${prefix} ) }} +{{ use: audio-cell-type( + prefix = ${prefix} +) }} + {{ use: progressbar-cell-type( prefix = ${prefix} ) }} diff --git a/docs/assets/option/en/column/image-column-type.md b/docs/assets/option/en/column/image-column-type.md index 6c2892bf14..912773710e 100644 --- a/docs/assets/option/en/column/image-column-type.md +++ b/docs/assets/option/en/column/image-column-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, the default cellType is 'text'. ##${prefix} cellType(string) = 'image' -Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows (also applicable to types with 'video'): +Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows (also applicable to 'video' and 'audio' types): {{ use: base-cell-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, the default cellType is `'text'`. Other co ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +**Configuration specific to image type** Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/audio-dimension-type.md b/docs/assets/option/en/dimension/audio-dimension-type.md new file mode 100644 index 0000000000..48db09f869 --- /dev/null +++ b/docs/assets/option/en/dimension/audio-dimension-type.md @@ -0,0 +1,13 @@ +{{ target: audio-dimension-type }} + +#${prefix} ${dimensionHeaderType}.audio(string) + +Specifies the column type as `'audio'`, headerType can be omitted and defaults to 'text' + +##${prefix} headerType(string) = 'audio' + +Specifies the column type as `'audio'`, headerType can be omitted and defaults to `'text'`. All configuration items are the same as image type, please refer to image type configuration items. + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/columns-dimenison-define.md b/docs/assets/option/en/dimension/columns-dimenison-define.md index bb09213fd4..7f936e2b61 100644 --- a/docs/assets/option/en/dimension/columns-dimenison-define.md +++ b/docs/assets/option/en/dimension/columns-dimenison-define.md @@ -2,7 +2,7 @@ #${prefix} columns -The style and format configuration corresponding to the header of each level of dimensions should be set separately for each dimension. The configuration items will vary slightly depending on the headerType. Supported headerType values are: `'text' | 'link' | 'image' | 'video'`. The specific configuration items for each headerType are as follows: +The style and format configuration corresponding to the header of each level of dimensions should be set separately for each dimension. The configuration items will vary slightly depending on the headerType. Supported headerType values are: `'text' | 'link' | 'image' | 'video' | 'audio'`. The specific configuration items for each headerType are as follows: {{ use: text-dimension-type( @@ -23,4 +23,9 @@ The style and format configuration corresponding to the header of each level of {{ use: video-dimension-type( prefix = ${prefix}, dimensionHeaderType = 'columns' -) }} \ No newline at end of file +) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'columns' +) }} diff --git a/docs/assets/option/en/dimension/image-dimension-type.md b/docs/assets/option/en/dimension/image-dimension-type.md index 365dbd1a81..4a5abeb30d 100644 --- a/docs/assets/option/en/dimension/image-dimension-type.md +++ b/docs/assets/option/en/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} headerType(string) = 'image' -Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to type 'video'): +Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' and 'audio' types): {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +**Configuration specific to image type** Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/rows-dimension-define.md b/docs/assets/option/en/dimension/rows-dimension-define.md index dc69d016a5..98d3c03c13 100644 --- a/docs/assets/option/en/dimension/rows-dimension-define.md +++ b/docs/assets/option/en/dimension/rows-dimension-define.md @@ -4,7 +4,7 @@ **The structure is the same as the list header tree structure columns configuration** -The row header corresponds to the style and format configuration of each level of dimension. Each type of dimension needs to be set separately. The configuration items have slight differences according to the headerType. The supported headerTypes are: `'text' | 'link' | 'image' | 'video'`. The specific configuration items for each headerType are as follows: +The row header corresponds to the style and format configuration of each level of dimension. Each type of dimension needs to be set separately. The configuration items have slight differences according to the headerType. The supported headerTypes are: `'text' | 'link' | 'image' | 'video' | 'audio'`. The specific configuration items for each headerType are as follows: {{ use: text-dimension-type( @@ -25,4 +25,9 @@ The row header corresponds to the style and format configuration of each level o {{ use: video-dimension-type( prefix = ${prefix}, dimensionHeaderType = 'rows' -) }} \ No newline at end of file +) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'rows' +) }} diff --git a/docs/assets/option/en/indicator/audio-indicator-type.md b/docs/assets/option/en/indicator/audio-indicator-type.md new file mode 100644 index 0000000000..fd50512a0e --- /dev/null +++ b/docs/assets/option/en/indicator/audio-indicator-type.md @@ -0,0 +1,13 @@ +{{ target: audio-indicator-type }} + +#${prefix} indicators.audio(string) + +Specifies the column type as `'audio'`, cellType can be omitted with default as 'text' + +##${prefix} cellType(string) = 'audio' + +Specifies the column type as `'audio'`, cellType can be omitted with default as `'text'`. All configuration options are the same as the image type, refer to the image type configuration options for details. + +##${prefix} clickToPreview(boolean) = true + +Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/base-indicator-type.md b/docs/assets/option/en/indicator/base-indicator-type.md index 09da7b6a75..2457d73dbd 100644 --- a/docs/assets/option/en/indicator/base-indicator-type.md +++ b/docs/assets/option/en/indicator/base-indicator-type.md @@ -41,7 +41,7 @@ Minimum width of the indicator column ${prefix} headerType(string) = 'text' -Specify the header type, options: `'text'|'link'|'image'|'video'`, default `'text'`. +Specify the header type, options: `'text'|'link'|'image'|'video'|'audio'`, default `'text'`. ${prefix} headerStyle(TODO) diff --git a/docs/assets/option/en/indicator/image-indicator-type.md b/docs/assets/option/en/indicator/image-indicator-type.md index 5e3c8bc24d..9708e83f5d 100644 --- a/docs/assets/option/en/indicator/image-indicator-type.md +++ b/docs/assets/option/en/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} cellType(string) = 'image' -Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' type): +Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' and 'audio' types): {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. \ No newline at end of file +**Configuration specific to image type** Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/indicator-define.md b/docs/assets/option/en/indicator/indicator-define.md index f605ff10d3..6421fa3bbb 100644 --- a/docs/assets/option/en/indicator/indicator-define.md +++ b/docs/assets/option/en/indicator/indicator-define.md @@ -2,7 +2,7 @@ #${prefix} indicators -The specific configuration of each indicator in the pivot table, such as style, format, title, etc., needs to be separately set for each type of indicator. The configuration items vary slightly according to the different `cellType`. `cellType` can support: `'text' | 'link' | 'image' | 'video' | 'progressbar' | 'sparkline'`. The specific configuration items for each `cellType` are as follows: +The specific configuration of each indicator in the pivot table, such as style, format, title, etc., needs to be separately set for each type of indicator. The configuration items vary slightly according to the different `cellType`. `cellType` can support: `'text' | 'link' | 'image' | 'video' | 'audio' | 'progressbar' | 'sparkline'`. The specific configuration items for each `cellType` are as follows: {{ use: text-indicator-type( prefix = ${prefix}) }} @@ -16,6 +16,9 @@ The specific configuration of each indicator in the pivot table, such as style, {{ use: video-indicator-type( prefix = ${prefix}) }} +{{ use: audio-indicator-type( + prefix = ${prefix}) }} + {{ use: progressbar-indicator-type( prefix = ${prefix}) }} @@ -23,4 +26,4 @@ The specific configuration of each indicator in the pivot table, such as style, prefix = ${prefix}) }} {{ use: chart-indicator-type( - prefix = ${prefix}) }} \ No newline at end of file + prefix = ${prefix}) }} diff --git a/docs/assets/option/zh/column/audio-column-type.md b/docs/assets/option/zh/column/audio-column-type.md new file mode 100644 index 0000000000..040e4c006c --- /dev/null +++ b/docs/assets/option/zh/column/audio-column-type.md @@ -0,0 +1,13 @@ +{{ target: audio-cell-type }} + +#${prefix} columns.audio(string) + +指定该列或该行单元格类型为`'audio'`,cellType 缺省的话会被默认为'text' + +##${prefix} cellType(string) = 'audio' + +指定该列或该行单元格类型为`'audio'`,cellType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/column/base-column-type.md b/docs/assets/option/zh/column/base-column-type.md index b15f7c09c8..c7349573d3 100644 --- a/docs/assets/option/zh/column/base-column-type.md +++ b/docs/assets/option/zh/column/base-column-type.md @@ -2,7 +2,7 @@ ${prefix} headerType(string) = 'text' -指定表头类型,可选:`'text'|'link'|'image'|'video'|'checkbox'`,默认 `'text'`。 +指定表头类型,可选:`'text'|'link'|'image'|'video'|'audio'|'checkbox'`,默认 `'text'`。 ${prefix} field(string) diff --git a/docs/assets/option/zh/column/column-define.md b/docs/assets/option/zh/column/column-define.md index 68a3241e01..5086eab08c 100644 --- a/docs/assets/option/zh/column/column-define.md +++ b/docs/assets/option/zh/column/column-define.md @@ -2,7 +2,7 @@ #${prefix} columns -表格列配置,每一列需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart`, 每种 cellType 的配置项具体如下: +表格列配置,每一列需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'`, 每种 cellType 的配置项具体如下: {{ use: text-cell-type( prefix = ${prefix} @@ -20,6 +20,10 @@ prefix = ${prefix} ) }} +{{ use: audio-cell-type( + prefix = ${prefix} +) }} + {{ use: progressbar-cell-type( prefix = ${prefix} ) }} diff --git a/docs/assets/option/zh/column/image-column-type.md b/docs/assets/option/zh/column/image-column-type.md index 3f40b01fb8..0ff641a18f 100644 --- a/docs/assets/option/zh/column/image-column-type.md +++ b/docs/assets/option/zh/column/image-column-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): {{ use: base-cell-type( prefix = '##'+${prefix}, diff --git a/docs/assets/option/zh/dimension/audio-dimension-type.md b/docs/assets/option/zh/dimension/audio-dimension-type.md new file mode 100644 index 0000000000..a1b2966af0 --- /dev/null +++ b/docs/assets/option/zh/dimension/audio-dimension-type.md @@ -0,0 +1,13 @@ +{{ target: audio-dimension-type }} + +#${prefix} ${dimensionHeaderType}.audio(string) + +指定列类型为`'audio'`, headerType 可缺省默认为'text' + +##${prefix} headerType(string) = 'audio' + +指定列类型为`'audio'`,headerType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/columns-dimenison-define.md b/docs/assets/option/zh/dimension/columns-dimenison-define.md index b4801b7293..d51f573d70 100644 --- a/docs/assets/option/zh/dimension/columns-dimenison-define.md +++ b/docs/assets/option/zh/dimension/columns-dimenison-define.md @@ -2,7 +2,7 @@ #${prefix} columns -列表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video'`, 每种headerType的配置项具体如下: +列表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video' | 'audio'`, 每种headerType的配置项具体如下: {{ use: text-dimension-type( @@ -24,3 +24,8 @@ prefix = ${prefix}, dimensionHeaderType = 'columns' ) }} + +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'columns' +) }} diff --git a/docs/assets/option/zh/dimension/image-dimension-type.md b/docs/assets/option/zh/dimension/image-dimension-type.md index 2ae091941d..a6ce8707e8 100644 --- a/docs/assets/option/zh/dimension/image-dimension-type.md +++ b/docs/assets/option/zh/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ ##${prefix} headerType(string) = 'image' -指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 \ No newline at end of file +**image 类型专属配置项** 是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/rows-dimension-define.md b/docs/assets/option/zh/dimension/rows-dimension-define.md index bb911fa6f5..9cdcdfe7dc 100644 --- a/docs/assets/option/zh/dimension/rows-dimension-define.md +++ b/docs/assets/option/zh/dimension/rows-dimension-define.md @@ -4,7 +4,7 @@ **结构同列表头树形结构columns配置** -行表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video'`, 每种headerType的配置项具体如下: +行表头对应各级维度的样式及format配置,每中维度需要分别设置配置项,配置项根据headerType不同有略微差别,headerType可支持:`'text' | 'link' | 'image' | 'video' | 'audio'`, 每种headerType的配置项具体如下: {{ use: text-dimension-type( @@ -27,3 +27,7 @@ dimensionHeaderType = 'rows' ) }} +{{ use: audio-dimension-type( + prefix = ${prefix}, + dimensionHeaderType = 'rows' +) }} diff --git a/docs/assets/option/zh/indicator/audio-indicator-type.md b/docs/assets/option/zh/indicator/audio-indicator-type.md new file mode 100644 index 0000000000..8b78a00cd2 --- /dev/null +++ b/docs/assets/option/zh/indicator/audio-indicator-type.md @@ -0,0 +1,13 @@ +{{ target: audio-indicator-type }} + +#${prefix} indicators.audio(string) + +指定列类型为`'audio'`,cellType 缺省的话会被默认为'text' + +##${prefix} cellType(string) = 'audio' + +指定列类型为`'audio'`,cellType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 + +##${prefix} clickToPreview(boolean) = true + +是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/base-indicator-type.md b/docs/assets/option/zh/indicator/base-indicator-type.md index 1a54f13768..d3ff59a828 100644 --- a/docs/assets/option/zh/indicator/base-indicator-type.md +++ b/docs/assets/option/zh/indicator/base-indicator-type.md @@ -41,7 +41,7 @@ ${prefix} minWidth(number|string) ${prefix} headerType(string) = 'text' -指定表头类型,可选:`'text'|'link'|'image'|'video'`,默认 `'text'`。 +指定表头类型,可选:`'text'|'link'|'image'|'video'|'audio'`,默认 `'text'`。 ${prefix} headerStyle(TODO) diff --git a/docs/assets/option/zh/indicator/image-indicator-type.md b/docs/assets/option/zh/indicator/image-indicator-type.md index ccac13e515..79c6ea5c50 100644 --- a/docs/assets/option/zh/indicator/image-indicator-type.md +++ b/docs/assets/option/zh/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为'video'): +指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 \ No newline at end of file +**image 类型专属配置项** 是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/indicator-define.md b/docs/assets/option/zh/indicator/indicator-define.md index 8aec5dc667..3c659cb64d 100644 --- a/docs/assets/option/zh/indicator/indicator-define.md +++ b/docs/assets/option/zh/indicator/indicator-define.md @@ -2,7 +2,7 @@ #${prefix} indicators(Array) -透视表中各个指标的具体配置,如样式,format,title 等,每种指标需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'progressbar' | 'sparkline'`, 每种 cellType 的配置项具体如下: +透视表中各个指标的具体配置,如样式,format,title 等,每种指标需要分别设置配置项,配置项根据 cellType 不同有略微差别,cellType 可支持:`'text' | 'link' | 'image' | 'video' | 'audio' | 'progressbar' | 'sparkline'`, 每种 cellType 的配置项具体如下: {{ use: text-indicator-type( prefix = ${prefix}) }} @@ -16,6 +16,9 @@ {{ use: video-indicator-type( prefix = ${prefix}) }} +{{ use: audio-indicator-type( + prefix = ${prefix}) }} + {{ use: progressbar-indicator-type( prefix = ${prefix}) }} diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 2080a366fb..9eddb375d7 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -1,7 +1,8 @@ // @ts-nocheck import { createVideoCellGroup } from '../../src/scenegraph/group-creater/cell-type/video-cell'; +import { audioIconSvg } from '../../src/scenegraph/group-creater/cell-type/audio-cell'; import { updateImageCellContentWhileResize } from '../../src/scenegraph/group-creater/cell-type/image-cell'; -import { registerForVrender } from '../../src/vrender'; +import { application, registerForVrender } from '../../src/vrender'; import * as icons from '../../src/icons'; global.__VERSION__ = 'none'; @@ -10,16 +11,16 @@ registerForVrender(); describe('video cell first frame snapshot', () => { const originalCreateElement = document.createElement.bind(document); - let createdVideo: HTMLVideoElement; + let createdVideo: HTMLVideoElement | undefined; let drawImage: jest.Mock; - function createTable(customConfig?: Record) { + function createTable(customConfig?: Record, value = 'https://example.com/video.mp4') { return { options: { customConfig }, _getCellStyle: jest.fn(() => ({})), - getCellValue: jest.fn(() => 'https://example.com/video.mp4'), + getCellValue: jest.fn(() => value), colCount: 1, rowCount: 1, theme: { @@ -35,8 +36,12 @@ describe('video cell first frame snapshot', () => { }; } - function createCell(customConfig?: Record, size = { width: 200, height: 120 }) { - const table = createTable(customConfig); + function createCell( + customConfig?: Record, + size = { width: 200, height: 120 }, + value = 'https://example.com/video.mp4' + ) { + const table = createTable(customConfig, value); const cellGroup = createVideoCellGroup( undefined, 0, @@ -70,6 +75,16 @@ describe('video cell first frame snapshot', () => { } beforeEach(() => { + createdVideo = undefined; + (application.global as any).loadSvg = jest.fn(() => + Promise.resolve({ + data: { + width: 24, + height: 24 + } + }) + ); + (application.global as any).getRequestAnimationFrame = () => (cb: FrameRequestCallback) => cb(0); drawImage = jest.fn(); jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage @@ -116,6 +131,15 @@ describe('video cell first frame snapshot', () => { expect(video.load).not.toHaveBeenCalled(); }); + it('renders an audio placeholder when a video cell receives an audio url', () => { + const { image, video } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/audio.mp3'); + + expect(video).toBeUndefined(); + expect(image.attribute.image).toBe(audioIconSvg); + expect(image.attribute.width).toBe(32); + expect(image.attribute.height).toBe(32); + }); + it('uses a canvas snapshot and releases the video when enabled', () => { const { image, table, video } = createCell({ videoFirstFrameSnapshot: true, @@ -150,6 +174,32 @@ describe('video cell first frame snapshot', () => { expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); }); + it('shows the damage image instead of drawing a zero-size video frame', () => { + const { image, table, video } = createCell({ + videoFirstFrameSnapshot: true + }); + Object.defineProperties(video, { + videoWidth: { + value: 0, + configurable: true + }, + videoHeight: { + value: 0, + configurable: true + } + }); + const regedIcons = icons.get(); + const damageImage = regedIcons.video_damage_pic + ? (regedIcons.video_damage_pic as any).svg + : (regedIcons.damage_pic as any).svg; + + video.dispatchEvent(new Event('loadeddata')); + + expect(drawImage).not.toHaveBeenCalled(); + expect(image.attribute.image).toBe(damageImage); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); + }); + it('keeps the video damage image aspect ratio immediately after load error', () => { const { image, video } = createCell( { diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5ea0fb6253..01e838dc32 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -636,6 +636,10 @@ export const menus = [ path: 'type', name: 'video' }, + { + path: 'type', + name: 'audio' + }, { path: 'type', name: 'progress-bar' diff --git a/packages/vtable/examples/type/audio.ts b/packages/vtable/examples/type/audio.ts new file mode 100644 index 0000000000..c2cd61a390 --- /dev/null +++ b/packages/vtable/examples/type/audio.ts @@ -0,0 +1,210 @@ +import * as VTable from '../../src'; + +const ListTable = VTable.ListTable; +const CONTAINER_ID = 'vTable'; + +const audioUrls = [ + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-bright-melody.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-soft-loop.mp3', + 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/demo/vtable-demo-calm-theme.mp3' +]; +const TOOLBAR_ID = 'audio-demo-theme-toolbar'; + +type DemoTheme = 'light' | 'dark'; + +const themeMap = { + light: VTable.themes.DEFAULT.extends({ + underlayBackgroundColor: '#ffffff', + defaultStyle: { + bgColor: '#ffffff', + color: '#111827', + borderColor: '#E5E7EB' + }, + headerStyle: { + bgColor: '#F3F6F9', + color: '#111827', + borderColor: '#E5E7EB' + }, + bodyStyle: { + bgColor: '#ffffff', + color: '#111827', + borderColor: '#E5E7EB' + }, + frameStyle: { + borderColor: '#E5E7EB', + borderLineWidth: 1 + }, + selectionStyle: { + cellBorderColor: '#2563EB', + cellBorderLineWidth: 3, + cellBgColor: 'rgba(37, 99, 235, 0.08)' + } + }), + dark: VTable.themes.DEFAULT.extends({ + underlayBackgroundColor: '#111827', + defaultStyle: { + bgColor: '#111827', + color: '#E5E7EB', + borderColor: '#374151' + }, + headerStyle: { + bgColor: '#1F2937', + color: '#F9FAFB', + borderColor: '#374151' + }, + bodyStyle: { + bgColor: '#111827', + color: '#E5E7EB', + borderColor: '#374151' + }, + frameStyle: { + borderColor: '#374151', + borderLineWidth: 1 + }, + selectionStyle: { + cellBorderColor: '#60A5FA', + cellBorderLineWidth: 3, + cellBgColor: 'rgba(96, 165, 250, 0.18)' + } + }) +}; + +function applyPageTheme(theme: DemoTheme) { + const isDark = theme === 'dark'; + document.body.style.background = isDark ? '#0F172A' : '#ffffff'; + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.background = isDark ? '#111827' : '#ffffff'; + } +} + +function createToolbar(instance: VTable.ListTable, currentTheme: DemoTheme) { + document.getElementById(TOOLBAR_ID)?.remove(); + + const toolbar = document.createElement('div'); + toolbar.id = TOOLBAR_ID; + toolbar.style.display = 'flex'; + toolbar.style.gap = '8px'; + toolbar.style.alignItems = 'center'; + toolbar.style.margin = '8px 0 12px 30px'; + toolbar.style.fontFamily = 'sans-serif'; + + const setActiveButton = (theme: DemoTheme) => { + toolbar.querySelectorAll('button').forEach(button => { + const active = button.dataset.theme === theme; + button.style.background = active ? '#2563EB' : '#F3F4F6'; + button.style.color = active ? '#FFFFFF' : '#111827'; + button.style.borderColor = active ? '#2563EB' : '#D1D5DB'; + }); + }; + + const switchTheme = (theme: DemoTheme) => { + currentTheme = theme; + applyPageTheme(theme); + instance.updateTheme(themeMap[theme]); + setActiveButton(currentTheme); + }; + + [ + { theme: 'light' as DemoTheme, label: '亮色主题' }, + { theme: 'dark' as DemoTheme, label: '暗黑主题' } + ].forEach(item => { + const button = document.createElement('button'); + button.dataset.theme = item.theme; + button.textContent = item.label; + button.style.height = '32px'; + button.style.padding = '0 14px'; + button.style.border = '1px solid #D1D5DB'; + button.style.borderRadius = '8px'; + button.style.cursor = 'pointer'; + button.style.fontSize = '14px'; + button.onclick = () => switchTheme(item.theme); + toolbar.appendChild(button); + }); + + const root = document.getElementById(CONTAINER_ID); + root?.parentNode?.insertBefore(toolbar, root); + setActiveButton(currentTheme); +} + +export function createTable() { + const currentTheme: DemoTheme = 'light'; + applyPageTheme(currentTheme); + + const records = [ + { + id: 1, + name: 'audio cell', + type: 'cellType: audio', + audio: audioUrls[0] + }, + { + id: 2, + name: 'video fallback', + type: 'cellType: video + audio url', + audio: audioUrls[1] + }, + { + id: 3, + name: 'audio with query', + type: 'audio url with query', + audio: `${audioUrls[2]}?from=vtable-demo` + } + ]; + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + columns: [ + { + field: 'id', + title: 'ID', + width: 80 + }, + { + field: 'name', + title: 'Name', + width: 180 + }, + { + field: 'type', + title: 'Case', + width: 220 + }, + { + field: 'audio', + title: 'Audio', + cellType: 'audio', + width: 180, + keepAspectRatio: true, + style: { + padding: 8 + } + }, + { + field: 'audio', + title: 'Video Cell Fallback', + cellType: 'video', + width: 220, + keepAspectRatio: true, + style: { + padding: 8 + } + } + ], + records, + widthMode: 'standard', + theme: themeMap[currentTheme], + hover: { + highlightMode: 'cross' + } + }; + + const instance = new ListTable(option); + createToolbar(instance, currentTheme); + + VTable.bindDebugTool(instance.scenegraph.stage as any, { + customGrapicKeys: ['role', '_updateTag'] + }); + + window.tableInstance = instance; +} diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 8a2db09c06..be98052aac 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -84,6 +84,7 @@ "vite": "3.2.6", "typescript": "4.9.5", "@babel/core": "7.20.12", + "@babel/plugin-transform-react-jsx": "7.28.6", "@babel/preset-env": "7.20.2", "@types/chai": "4.2.22", "@types/jest": "^26.0.0", diff --git a/packages/vtable/src/ListTable-all.ts b/packages/vtable/src/ListTable-all.ts index 3ead7052d3..7e6a56e09a 100644 --- a/packages/vtable/src/ListTable-all.ts +++ b/packages/vtable/src/ListTable-all.ts @@ -11,6 +11,7 @@ import { import { registerCustomCellStylePlugin } from './plugins/custom-cell-style'; import { registerListTreeStickCellPlugin } from './plugins/list-tree-stick-cell'; import { + registerAudioCell, registerButtonCell, registerChartCell, registerCheckboxCell, @@ -32,6 +33,7 @@ registerTooltip(); registerListTreeStickCellPlugin(); registerCustomCellStylePlugin(); registerAnimation(); +registerAudioCell(); registerChartCell(); registerCheckboxCell(); registerImageCell(); diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index 0fdb4dc221..dce0beee77 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -64,6 +64,7 @@ import { registerAnimation } from './components'; import { + registerAudioCell, registerChartCell, registerCheckboxCell, registerImageCell, @@ -87,6 +88,7 @@ registerMenu(); registerTitle(); registerTooltip(); registerAnimation(); +registerAudioCell(); registerChartCell(); registerCheckboxCell(); registerImageCell(); diff --git a/packages/vtable/src/PivotTable-all.ts b/packages/vtable/src/PivotTable-all.ts index 1caf13a88b..ccd2a5ea59 100644 --- a/packages/vtable/src/PivotTable-all.ts +++ b/packages/vtable/src/PivotTable-all.ts @@ -10,6 +10,7 @@ import { } from './components'; import { registerCustomCellStylePlugin } from './plugins/custom-cell-style'; import { + registerAudioCell, registerChartCell, registerCheckboxCell, registerImageCell, @@ -28,6 +29,7 @@ registerTitle(); registerTooltip(); registerCustomCellStylePlugin(); registerAnimation(); +registerAudioCell(); registerCheckboxCell(); registerImageCell(); registerProgressBarCell(); diff --git a/packages/vtable/src/body-helper/body-helper.ts b/packages/vtable/src/body-helper/body-helper.ts index 9a5ab4e6b9..cae03bda40 100644 --- a/packages/vtable/src/body-helper/body-helper.ts +++ b/packages/vtable/src/body-helper/body-helper.ts @@ -97,6 +97,7 @@ export class BodyHelper { | 'link' | 'image' | 'video' + | 'audio' | 'chart' | 'sparkline' | 'progressbar' @@ -110,8 +111,8 @@ export class BodyHelper { case 'text': return TextStyle; case 'image': - return ImageStyle; case 'video': + case 'audio': return ImageStyle; case 'link': return TextStyle; diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 6639c16c9e..05b94dbbd2 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -6,6 +6,183 @@ import type { LinkColumnDefine, MousePointerCellEvent } from '../ts-types'; import type { BaseTableAPI, HeaderData } from '../ts-types/base-table'; import type { IImageColumnBodyDefine } from '../ts-types/list-table/define/image-define'; import { getOrApply } from '../tools/helper'; +import { isAudioUrl } from '../tools/media'; + +function formatAudioTime(time: number) { + if (!Number.isFinite(time) || time < 0) { + return '0:00'; + } + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function updateAudioProgressStyle(progress: HTMLInputElement, value: number) { + progress.style.background = `linear-gradient( + to right, + #2563EB 0%, + #2563EB ${value}%, + #D1D5DB ${value}%, + #D1D5DB 100% + )`; +} + +function ensureAudioPreviewStyle() { + const styleId = 'vtable-audio-preview-style'; + if (document.getElementById(styleId)) { + return; + } + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .vtable-audio-preview-progress { + -webkit-appearance: none; + appearance: none; + } + .vtable-audio-preview-progress::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: transparent; + } + .vtable-audio-preview-progress::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-top: -4px; + border-radius: 50%; + border: 0; + background: #2563EB; + } + .vtable-audio-preview-progress::-moz-range-track { + height: 4px; + border-radius: 999px; + background: transparent; + } + .vtable-audio-preview-progress::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 0; + background: #2563EB; + } + `; + document.head.appendChild(style); +} + +function createAudioPreview(cellValue: string) { + ensureAudioPreviewStyle(); + + const audio = document.createElement('audio'); + audio.src = cellValue; + audio.preload = 'auto'; + + const wrapper = document.createElement('div'); + wrapper.style.width = '80%'; + wrapper.style.maxWidth = '640px'; + wrapper.style.height = '54px'; + wrapper.style.boxSizing = 'border-box'; + wrapper.style.padding = '0 16px'; + wrapper.style.borderRadius = '27px'; + wrapper.style.background = '#F8FAFC'; + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.gap = '10px'; + wrapper.style.boxShadow = '0 8px 24px rgba(15, 23, 42, 0.16)'; + + const playButton = document.createElement('button'); + playButton.type = 'button'; + playButton.textContent = '▶'; + playButton.style.width = '34px'; + playButton.style.height = '34px'; + playButton.style.border = '1px solid #CBD5E1'; + playButton.style.borderRadius = '8px'; + playButton.style.background = '#EFF6FF'; + playButton.style.color = '#0F172A'; + playButton.style.cursor = 'pointer'; + playButton.style.flex = '0 0 auto'; + playButton.style.fontSize = '14px'; + + const timeLabel = document.createElement('span'); + timeLabel.textContent = '0:00 / 0:00'; + timeLabel.style.color = '#0F172A'; + timeLabel.style.font = '14px sans-serif'; + timeLabel.style.whiteSpace = 'nowrap'; + + const progress = document.createElement('input'); + progress.className = 'vtable-audio-preview-progress'; + progress.type = 'range'; + progress.min = '0'; + progress.max = '100'; + progress.value = '0'; + progress.step = '0.1'; + progress.style.flex = '1 1 auto'; + progress.style.height = '4px'; + progress.style.borderRadius = '999px'; + progress.style.outline = 'none'; + progress.style.cursor = 'pointer'; + progress.style.accentColor = '#2563EB'; + updateAudioProgressStyle(progress, 0); + + const updateProgress = () => { + const duration = audio.duration || 0; + const currentTime = audio.currentTime || 0; + const value = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; + progress.value = `${value}`; + timeLabel.textContent = `${formatAudioTime(currentTime)} / ${formatAudioTime(duration)}`; + updateAudioProgressStyle(progress, value); + }; + + playButton.addEventListener('click', e => { + e.stopPropagation(); + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + }); + + progress.addEventListener('click', e => { + e.stopPropagation(); + }); + + progress.addEventListener('input', () => { + const duration = audio.duration || 0; + const value = Number(progress.value); + if (duration > 0) { + audio.currentTime = (duration * value) / 100; + } + updateAudioProgressStyle(progress, value); + }); + + audio.addEventListener('loadedmetadata', updateProgress); + audio.addEventListener('timeupdate', updateProgress); + audio.addEventListener('play', () => { + playButton.textContent = '❚❚'; + }); + audio.addEventListener('pause', () => { + playButton.textContent = '▶'; + }); + audio.addEventListener('ended', () => { + audio.currentTime = 0; + playButton.textContent = '▶'; + updateProgress(); + }); + + wrapper.appendChild(playButton); + wrapper.appendChild(timeLabel); + wrapper.appendChild(progress); + wrapper.appendChild(audio); + + return { + element: wrapper, + destroy: () => { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + }; +} export function bindMediaClick(table: BaseTableAPI): void { if (Env.mode === 'browser') { @@ -135,6 +312,43 @@ export function bindMediaClick(table: BaseTableAPI): void { image.style.backgroundColor = '#FFF'; overlay.appendChild(image); + document.body.appendChild(overlay); + } else if (cellType === 'audio' || (cellType === 'video' && isAudioUrl(cellValue))) { + // 点击音频,弹出播放窗口 + const { clickToPreview } = columnDefine as IImageColumnBodyDefine; + if (clickToPreview === false) { + return; + } + + // 开启蒙版 + const overlay = document.createElement('div'); + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.backgroundColor = 'rgba(30, 30, 30, 0.4)'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.overflow = 'hidden'; + overlay.style.zIndex = '9999'; + + const audioPreview = createAudioPreview(cellValue); + const closeOverlay = () => { + audioPreview.destroy(); + document.body.removeChild(overlay); + }; + + overlay.addEventListener('click', e => { + if (e.target === overlay) { + closeOverlay(); + } + }); + + // 创建音频 + overlay.appendChild(audioPreview.element); + document.body.appendChild(overlay); } else if (cellType === 'video') { // 点击视频,弹出播放窗口 @@ -163,7 +377,6 @@ export function bindMediaClick(table: BaseTableAPI): void { } }); - // 创建视频 const video = document.createElement('video'); video.src = cellValue; video.style.maxWidth = '80%'; diff --git a/packages/vtable/src/header-helper/header-helper.ts b/packages/vtable/src/header-helper/header-helper.ts index 4c883780f4..94f2cd06eb 100644 --- a/packages/vtable/src/header-helper/header-helper.ts +++ b/packages/vtable/src/header-helper/header-helper.ts @@ -461,8 +461,8 @@ export class HeaderHelper { case 'text': return TextHeaderStyle; case 'image': - return ImageStyle; case 'video': + case 'audio': return ImageStyle; case 'link': return TextHeaderStyle; diff --git a/packages/vtable/src/layout/layout-helper.ts b/packages/vtable/src/layout/layout-helper.ts index 40b79b2ed8..d189cc6773 100644 --- a/packages/vtable/src/layout/layout-helper.ts +++ b/packages/vtable/src/layout/layout-helper.ts @@ -98,7 +98,10 @@ export function hasAutoImageColumn(table: BaseTableAPI) { continue; } if ( - (column.headerType === 'image' || column.headerType === 'video' || typeof column.headerType === 'function') && + (column.headerType === 'image' || + column.headerType === 'video' || + column.headerType === 'audio' || + typeof column.headerType === 'function') && (column as IImageDimension).imageAutoSizing ) { return true; @@ -112,7 +115,10 @@ export function hasAutoImageColumn(table: BaseTableAPI) { continue; } if ( - (row.headerType === 'image' || row.headerType === 'video' || typeof row.headerType === 'function') && + (row.headerType === 'image' || + row.headerType === 'video' || + row.headerType === 'audio' || + typeof row.headerType === 'function') && (row as IImageDimension).imageAutoSizing ) { return true; @@ -128,10 +134,12 @@ export function hasAutoImageColumn(table: BaseTableAPI) { if ( ((indicator.cellType === 'image' || indicator.cellType === 'video' || + indicator.cellType === 'audio' || typeof indicator.cellType === 'function') && (indicator as IImageColumnIndicator).imageAutoSizing) || ((indicator.headerType === 'image' || indicator.headerType === 'video' || + indicator.headerType === 'audio' || typeof indicator.headerType === 'function') && (indicator as IImageHeaderIndicator).imageAutoSizing) ) { @@ -145,10 +153,14 @@ export function hasAutoImageColumn(table: BaseTableAPI) { for (let i = 0; i < columns.length; i++) { const column = columns[i] as unknown as ListTableConstructorOptions['columns'][0]; if ( - ((column.cellType === 'image' || column.cellType === 'video' || typeof column.cellType === 'function') && + ((column.cellType === 'image' || + column.cellType === 'video' || + column.cellType === 'audio' || + typeof column.cellType === 'function') && (column as IImageColumnBodyDefine).imageAutoSizing) || ((column.headerType === 'image' || column.headerType === 'video' || + column.headerType === 'audio' || typeof column.headerType === 'function') && (column as IImageHeaderDefine).imageAutoSizing) ) { diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index b443c0b3c3..71dba983df 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -24,6 +24,7 @@ import type { CreateProgressBarCell } from './cell-type/progress-bar-cell'; import type { CreateSparkLineCellGroup } from './cell-type/spark-line-cell'; import type { CreateTextCellGroup } from './cell-type/text-cell'; import type { CreateVideoCellGroup } from './cell-type/video-cell'; +import type { CreateAudioCellGroup } from './cell-type/audio-cell'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; import { getCellCornerRadius, getStyleTheme } from '../../core/tableHelper'; import { getOrApply, isPromise } from '../../tools/helper'; @@ -41,6 +42,7 @@ 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 { isAudioUrl } from '../../tools/media'; export function createCell( type: ColumnTypeOption, @@ -251,6 +253,24 @@ export function createCell( range, isAsync ); + } else if (type === 'audio' || (type === 'video' && isAudioUrl(value))) { + const createAudioCellGroup = Factory.getFunction('createAudioCellGroup') as CreateAudioCellGroup; + cellGroup = createAudioCellGroup( + columnGroup, + 0, + y, + col, + row, + cellWidth, + cellHeight, + padding, + textAlign, + textBaseline, + table, + cellTheme, + range, + isAsync + ); } else if (type === 'video') { // 创建视频单元格 const createVideoCellGroup = Factory.getFunction('createVideoCellGroup') as CreateVideoCellGroup; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts new file mode 100644 index 0000000000..07b85c08a3 --- /dev/null +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -0,0 +1,113 @@ +/* eslint-disable no-undef */ +import type { Cursor, IImage, IThemeSpec } from '@src/vrender'; +import { createImage } from '@src/vrender'; +import { Group } from '../../graphic/group'; +import { calcStartPosition } from '../../utils/cell-pos'; +import { getFunctionalProp } from '../../utils/get-prop'; +import { isValid } from '@visactor/vutils'; +import type { BaseTableAPI } from '../../../ts-types/base-table'; +import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; +import { getQuadProps } from '../../utils/padding'; +import type { CellRange } from '../../../ts-types'; + +export const audioIconSvg = + ''; + +export function createAudioCellGroup( + columnGroup: Group, + xOrigin: number, + yOrigin: number, + col: number, + row: number, + width: number, + height: number, + padding: [number, number, number, number], + textAlign: CanvasTextAlign, + textBaseline: CanvasTextBaseline, + table: BaseTableAPI, + cellTheme: IThemeSpec, + _range: CellRange | undefined, + isAsync: boolean +) { + const headerStyle = table._getCellStyle(col, row); + const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); + if (table.options.customConfig?.imageMargin) { + padding = getQuadProps(table.options.customConfig?.imageMargin); + } else if (isValid(functionalPadding)) { + padding = functionalPadding; + } + if (cellTheme?.text?.textAlign) { + textAlign = cellTheme.text.textAlign; + } + if (cellTheme?.text?.textBaseline) { + textBaseline = cellTheme.text.textBaseline; + } + + const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); + let cellGroup: Group; + if (isAsync) { + cellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + if (cellGroup && cellGroup.role === 'cell') { + cellGroup.setAttributes({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + } + } + if (!cellGroup || cellGroup.role !== 'cell') { + cellGroup = new Group({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + cellGroup.role = 'cell'; + cellGroup.col = col; + cellGroup.row = row; + cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + } + + const availableWidth = Math.max(1, width - padding[1] - padding[3]); + const availableHeight = Math.max(1, height - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition(0, 0, width, height, iconSize, iconSize, textAlign, textBaseline, padding); + const image: IImage = createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }); + image.name = 'image'; + image.keepAspectRatio = true; + image.textAlign = textAlign; + image.textBaseline = textBaseline; + cellGroup.appendChild(image); + + return cellGroup; +} + +export type CreateAudioCellGroup = typeof createAudioCellGroup; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts index 221d4f0bd0..a9790c3a87 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/index.ts @@ -6,6 +6,7 @@ import { createRadioCellGroup } from './radio-cell'; import { createSwitchCellGroup } from './switch-cell'; import { createSparkLineCellGroup } from './spark-line-cell'; import { createVideoCellGroup } from './video-cell'; +import { createAudioCellGroup } from './audio-cell'; import { createCellGroup as createTextCellGroup } from './text-cell'; import { createProgressBarCell } from './progress-bar-cell'; import { getAxisDomainRangeAndLabels } from '../../../layout/chart-helper/get-axis-domain'; @@ -51,3 +52,7 @@ export const registerTextCell = () => { export const registerVideoCell = () => { Factory.registerFunction('createVideoCellGroup', createVideoCellGroup); }; + +export const registerAudioCell = () => { + Factory.registerFunction('createAudioCellGroup', createAudioCellGroup); +}; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index ac058bd6d6..04706e819f 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -20,6 +20,8 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { getQuadProps } from '../../utils/padding'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { isAudioUrl } from '../../../tools/media'; +import { audioIconSvg } from './audio-cell'; const regedIcons = icons.get(); @@ -51,6 +53,8 @@ function snapshotVideoFirstFrame(video: HTMLVideoElement, image: IImage, table: const displayWidth = image.attribute.width; const displayHeight = image.attribute.height; if ( + video.videoWidth <= 0 || + video.videoHeight <= 0 || typeof displayWidth !== 'number' || typeof displayHeight !== 'number' || displayWidth <= 0 || @@ -244,6 +248,26 @@ export function createVideoCellGroup( // video const value = table.getCellValue(col, row); + if (isAudioUrl(value)) { + const availableWidth = Math.max(1, width - padding[1] - padding[3]); + const availableHeight = Math.max(1, height - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition(0, 0, width, height, iconSize, iconSize, textAlign, textBaseline, padding); + const image: IImage = createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }); + image.name = 'image'; + image.keepAspectRatio = true; + image.textAlign = textAlign; + image.textBaseline = textBaseline; + cellGroup.appendChild(image); + return cellGroup; + } const video = document.createElement('video'); video.muted = true; video.playsInline = true; @@ -343,6 +367,10 @@ export function createVideoCellGroup( if (!scenegraph) { return; } + if (video.videoWidth <= 0 || video.videoHeight <= 0) { + handleVideoLoadFail(); + return; + } if (imageAutoSizing) { _adjustWidthHeight(col, row, video.videoWidth, video.videoHeight, scenegraph, padding, cellGroup); } @@ -440,8 +468,8 @@ export function createVideoCellGroup( const image: IImage = createImage({ x: padding[3], y: padding[0], - width: width - padding[1] - padding[3], - height: height - padding[2] - padding[0], + width: Math.max(1, width - padding[1] - padding[3]), + height: Math.max(1, height - padding[2] - padding[0]), image: video as any, cursor: 'pointer' as Cursor }); diff --git a/packages/vtable/src/scenegraph/layout/update-height.ts b/packages/vtable/src/scenegraph/layout/update-height.ts index dc20649851..55a70bc19b 100644 --- a/packages/vtable/src/scenegraph/layout/update-height.ts +++ b/packages/vtable/src/scenegraph/layout/update-height.ts @@ -189,7 +189,7 @@ export function updateCellHeight( getStyleTheme(headerStyle, scene.table, col, row, getProp).theme, false ); - } else if (type === 'image' || type === 'video') { + } else if (type === 'image' || type === 'video' || type === 'audio') { updateImageCellContentWhileResize(cell, col, row, 0, detaY, scene.table); } else if (cell.firstChild?.name === 'axis') { (cell.firstChild as any)?.originAxis.resize(cell.attribute.width, cell.attribute.height); diff --git a/packages/vtable/src/scenegraph/layout/update-width.ts b/packages/vtable/src/scenegraph/layout/update-width.ts index 14dd4b9f32..ac06b5a68e 100644 --- a/packages/vtable/src/scenegraph/layout/update-width.ts +++ b/packages/vtable/src/scenegraph/layout/update-width.ts @@ -330,7 +330,7 @@ function updateCellWidth( getStyleTheme(headerStyle, scene.table, col, row, getProp).theme, false ); - } else if (type === 'image' || type === 'video') { + } else if (type === 'image' || type === 'video' || type === 'audio') { // // 只更新背景边框 // const rect = cell.firstChild as Rect; // rect.setAttribute('width', cell.attribute.width); diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index f3dc9a32a8..f2132a0976 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -2318,7 +2318,7 @@ export class Scenegraph { const type = isVtableMerge ? 'text' : this.table.getCellType(col, row); const cellGroup = this.getCell(col, row); - if (type === 'image' || type === 'video') { + if (type === 'image' || type === 'video' || type === 'audio') { updateImageCellContentWhileResize(cellGroup, col, row, 0, 0, this.table); } } diff --git a/packages/vtable/src/tools/media.ts b/packages/vtable/src/tools/media.ts new file mode 100644 index 0000000000..a9fbe042a4 --- /dev/null +++ b/packages/vtable/src/tools/media.ts @@ -0,0 +1,5 @@ +const AUDIO_EXT_REG = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)(?:[?#].*)?$/i; + +export function isAudioUrl(value: unknown): boolean { + return typeof value === 'string' && AUDIO_EXT_REG.test(value); +} diff --git a/packages/vtable/src/ts-types/column/type.ts b/packages/vtable/src/ts-types/column/type.ts index 4dac54cfe5..a99dd1a665 100644 --- a/packages/vtable/src/ts-types/column/type.ts +++ b/packages/vtable/src/ts-types/column/type.ts @@ -2,6 +2,7 @@ export type ColumnTypeOption = | 'text' | 'image' | 'video' + | 'audio' | 'link' | 'chart' | 'sparkline' @@ -10,4 +11,4 @@ export type ColumnTypeOption = | 'radio' | 'switch' | 'button'; -export type HeaderTypeOption = 'text' | 'image' | 'video' | 'link' | 'checkbox'; +export type HeaderTypeOption = 'text' | 'image' | 'video' | 'audio' | 'link' | 'checkbox'; diff --git a/packages/vtable/src/ts-types/list-table/define/image-define.ts b/packages/vtable/src/ts-types/list-table/define/image-define.ts index a9dd91c276..76ddbeec35 100644 --- a/packages/vtable/src/ts-types/list-table/define/image-define.ts +++ b/packages/vtable/src/ts-types/list-table/define/image-define.ts @@ -4,7 +4,7 @@ import type { IBasicColumnBodyDefine, IBasicHeaderDefine } from './basic-define' export interface IImageHeaderDefine extends IBasicHeaderDefine { headerStyle?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); - headerType: 'image' | 'video'; + headerType: 'image' | 'video' | 'audio'; /** 是否保持横纵比 默认false */ keepAspectRatio?: boolean; @@ -17,7 +17,7 @@ export interface IImageHeaderDefine extends IBasicHeaderDefine { export interface IImageColumnBodyDefine extends IBasicColumnBodyDefine { style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); - cellType: 'image' | 'video'; + cellType: 'image' | 'video' | 'audio'; /** 是否保持横纵比 默认false */ keepAspectRatio?: boolean; diff --git a/packages/vtable/src/ts-types/list-table/layout-map/api.ts b/packages/vtable/src/ts-types/list-table/layout-map/api.ts index d63102f5ca..86cacc4a34 100644 --- a/packages/vtable/src/ts-types/list-table/layout-map/api.ts +++ b/packages/vtable/src/ts-types/list-table/layout-map/api.ts @@ -153,7 +153,7 @@ export interface IndicatorData extends WidthData { indicatorKey: string; // fieldKey: FieldKeyDef; fieldFormat?: FieldFormat; - cellType: 'text' | 'link' | 'image' | 'video' | 'sparkline' | 'progressbar' | 'chart'; //BaseColumn; + cellType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'sparkline' | 'progressbar' | 'chart'; //BaseColumn; chartModule?: string; chartSpec?: any | ((arg0: CustomRenderFunctionArg) => any); chartInstance?: any; @@ -191,8 +191,8 @@ export interface SeriesNumberColumnData extends WidthData { | (string | ColumnIconOption)[] | ((args: CellInfo) => undefined | string | ColumnIconOption | (string | ColumnIconOption)[]); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; - cellType: 'text' | 'link' | 'image' | 'video' | 'checkbox' | 'radio'; - headerType: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + cellType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; + headerType: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; style: ColumnStyleOption | null | undefined; define: IRowSeriesNumber; isChildNode?: false; diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts index 4e9903a7c2..099584c661 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts @@ -3,7 +3,7 @@ import type { StylePropertyFunctionArg } from '../../style-define'; import type { IBasicDimension } from './basic-dimension'; export interface IImageDimension extends IBasicDimension { - headerType: 'image' | 'video'; + headerType: 'image' | 'video' | 'audio'; headerStyle?: | IImageStyleOption //表头可以配置吸附; | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); //该维度层级表头部分的样式 diff --git a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts index 510a7311a6..be8b5b6991 100644 --- a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts +++ b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts @@ -3,7 +3,7 @@ import type { StylePropertyFunctionArg } from '../../style-define'; import type { IBasicColumnIndicator, IBasicHeaderIndicator } from './basic-indicator'; export interface IImageHeaderIndicator extends IBasicHeaderIndicator { - headerType: 'image' | 'video'; // 指标表头类型 + headerType: 'image' | 'video' | 'audio'; // 指标表头类型 headerStyle?: | IImageStyleOption //表头可以配置吸附; | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // 指标名称在表头部分显示类型 @@ -18,7 +18,7 @@ export interface IImageHeaderIndicator extends IBasicHeaderIndicator { } export interface IImageColumnIndicator extends IBasicColumnIndicator { - cellType: 'image' | 'video'; // body指标值显示类型 + cellType: 'image' | 'video' | 'audio'; // body指标值显示类型 style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // body部分指标值显示样式 /** 是否保持横纵比 默认false */ diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 00f45593ec..d98bb1d5f2 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -153,8 +153,8 @@ export interface IRowSeriesNumber { title?: string; field?: string | number; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - headerType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox' | 'radio'; + headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; @@ -184,7 +184,7 @@ export interface ColumnSeriesNumber { title?: string; field?: FieldDef; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; - cellType?: 'text' | 'link' | 'image' | 'video' | 'checkbox'; + cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); icon?: From 2af2e558abfb248400583465af79aabf6b592ade Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 12 Aug 2026 16:21:50 +0800 Subject: [PATCH 16/44] fix(vtable): address audio cell review feedback Tighten audio URL detection and preserve media icon layout. Align audio docs with supported options. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/guide/en/cell_type/cellType.md | 8 ++- .../list_table_define_and_generate.md | 2 +- docs/assets/guide/zh/cell_type/cellType.md | 8 ++- .../list_table_define_and_generate.md | 2 +- .../option/en/column/audio-column-type.md | 2 +- .../option/en/column/image-column-type.md | 4 +- .../en/dimension/audio-dimension-type.md | 2 +- .../en/dimension/image-dimension-type.md | 4 +- .../en/indicator/audio-indicator-type.md | 2 +- .../en/indicator/image-indicator-type.md | 4 +- .../option/zh/column/audio-column-type.md | 2 +- .../option/zh/column/image-column-type.md | 4 +- .../zh/dimension/audio-dimension-type.md | 2 +- .../zh/dimension/image-dimension-type.md | 4 +- .../zh/indicator/audio-indicator-type.md | 2 +- .../zh/indicator/image-indicator-type.md | 4 +- packages/vtable/examples/type/audio.ts | 18 ++++- .../scenegraph/group-creater/cell-helper.ts | 4 +- .../group-creater/cell-type/audio-cell.ts | 67 ++++++++++++++++++- .../group-creater/cell-type/image-cell.ts | 34 +++++++++- .../group-creater/cell-type/video-cell.ts | 1 + packages/vtable/src/tools/media.ts | 25 ++++++- 22 files changed, 171 insertions(+), 34 deletions(-) diff --git a/docs/assets/guide/en/cell_type/cellType.md b/docs/assets/guide/en/cell_type/cellType.md index 7c0e5ae7f5..5a389873b6 100644 --- a/docs/assets/guide/en/cell_type/cellType.md +++ b/docs/assets/guide/en/cell_type/cellType.md @@ -6,7 +6,7 @@ Next, this article will focus on the various column types and their usage and ch ## Support type -There are 8 data types supported by VTable, namely: +There are 12 data types supported by VTable, namely: 1. Text 2. Link @@ -16,6 +16,10 @@ There are 8 data types supported by VTable, namely: 6. progressbar 7. Sparkline 8. Chart +9. Checkbox +10. Radio +11. Switch +12. Button Next, we'll cover each column type one by one. @@ -122,7 +126,7 @@ When the column type is "video", it is used to display the data of the video typ ## Audio -When the column type is "audio", it is used to display audio data. The cell displays an audio icon, and clicking it opens the preview window with the browser audio controls. All configuration items of "audio" are the same as image and video, you can refer to the image type configuration item. +When the column type is "audio", it is used to display audio data. The cell displays an audio icon, and clicking it opens the preview window with audio playback controls. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. ## progressbar diff --git a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md index 282363ace7..507110531b 100644 --- a/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/en/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ The following are the Key Configurations items of the basic table and their desc * `container`: The container DOM element of the table, which needs to have width and height. * `records`: The data of the table, represented as an array. -* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'audio', 'sparkline', 'progressbar', 'chart'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). +* `columns`: Configuration of table columns, each column needs to set configuration items separately, including column type, width, title, etc. Supported column types are: 'text', 'link', 'image', 'video', 'audio', 'sparkline', 'progressbar', 'chart', 'checkbox', 'radio', 'switch', 'button'. The configuration items for each column type are slightly different, please flexibly add specific configurations according to the required type, you can refer to[Document](../../guide/cell_type/cellType). * `frozenColCount`: Specifies the number of frozen columns. * `transpose`: Whether to transpose the table, the default is false. * `showHeader`: Whether to display the header, the default is true. diff --git a/docs/assets/guide/zh/cell_type/cellType.md b/docs/assets/guide/zh/cell_type/cellType.md index 9d4208b6a4..4c37cd7fe1 100644 --- a/docs/assets/guide/zh/cell_type/cellType.md +++ b/docs/assets/guide/zh/cell_type/cellType.md @@ -6,7 +6,7 @@ ## 支持类型 -VTable 所支持的数据类型共有 8 种,分别为: +VTable 所支持的数据类型共有 12 种,分别为: 1. text 2. link @@ -16,6 +16,10 @@ VTable 所支持的数据类型共有 8 种,分别为: 6. progressbar 7. sparkline 8. chart +9. checkbox +10. radio +11. switch +12. button 接下来,我们将逐一介绍每一种列类型的相关内容。 @@ -120,7 +124,7 @@ VTable 所支持的数据类型共有 8 种,分别为: ## audio -当列类型为 "audio" 时,用于显示音频类型的数据。单元格会展示音频图标,点击后会弹出预览窗口并使用浏览器音频控件播放。audio 的配置项和 image、video 一样,可参考 image 类型配置项。 +当列类型为 "audio" 时,用于显示音频类型的数据。单元格会展示音频图标,点击后会弹出预览窗口并使用音频播放控件播放。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 ## progressbar diff --git a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md index 3fe6387329..e31c8a6f63 100644 --- a/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md +++ b/docs/assets/guide/zh/table_type/List_table/list_table_define_and_generate.md @@ -6,7 +6,7 @@ * `container`:表格的容器 DOM 元素,dom容器需要提前设置好宽高。这里非必填,可以放到初始化函数的第一个参数中如`new VTable.ListTable(container, option);` * `records`:表格的数据,以数组形式表示。 -* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'audio'、'sparkline'、'progressbar'、'chart'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 +* `columns`:表格列的配置,每一列需要分别设置配置项,包括列类型、宽度、标题等。支持的列类型有:'text'、'link'、'image'、'video'、'audio'、'sparkline'、'progressbar'、'chart'、'checkbox'、'radio'、'switch'、'button'。每种列类型的配置项略有差别,请根据所需类型灵活添加特有配置,可参考[文档](../../guide/cell_type/cellType)。 * `frozenColCount`:指定冻结列数。 * `transpose`:是否转置表格,默认为 false。 * `showHeader`:是否显示表头,默认为 true。 diff --git a/docs/assets/option/en/column/audio-column-type.md b/docs/assets/option/en/column/audio-column-type.md index 31a1e8e48c..98e1566b7a 100644 --- a/docs/assets/option/en/column/audio-column-type.md +++ b/docs/assets/option/en/column/audio-column-type.md @@ -6,7 +6,7 @@ Specify the column type as `'audio'`, cellType can be omitted with the default a ##${prefix} cellType(string) = 'audio' -Specify the column type as `'audio'`, cellType can be omitted with the default as `'text'`. All configuration options are the same as the image, and you can refer to the image type configuration options. +Specify the column type as `'audio'`, cellType can be omitted with the default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/en/column/image-column-type.md b/docs/assets/option/en/column/image-column-type.md index 912773710e..33ded61c59 100644 --- a/docs/assets/option/en/column/image-column-type.md +++ b/docs/assets/option/en/column/image-column-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, the default cellType is 'text'. ##${prefix} cellType(string) = 'image' -Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows (also applicable to 'video' and 'audio' types): +Specify the column type as `'image'`, the default cellType is `'text'`. Other configuration options are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-cell-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, the default cellType is `'text'`. Other co ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. +Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/audio-dimension-type.md b/docs/assets/option/en/dimension/audio-dimension-type.md index 48db09f869..3e060b508e 100644 --- a/docs/assets/option/en/dimension/audio-dimension-type.md +++ b/docs/assets/option/en/dimension/audio-dimension-type.md @@ -6,7 +6,7 @@ Specifies the column type as `'audio'`, headerType can be omitted and defaults t ##${prefix} headerType(string) = 'audio' -Specifies the column type as `'audio'`, headerType can be omitted and defaults to `'text'`. All configuration items are the same as image type, please refer to image type configuration items. +Specifies the column type as `'audio'`, headerType can be omitted and defaults to `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/en/dimension/image-dimension-type.md b/docs/assets/option/en/dimension/image-dimension-type.md index 4a5abeb30d..63c7493c0b 100644 --- a/docs/assets/option/en/dimension/image-dimension-type.md +++ b/docs/assets/option/en/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} headerType(string) = 'image' -Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' and 'audio' types): +Specify the column type as `'image'`, headerType can be omitted and defaults to `'text'`. Other configuration items are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specify the column type as `'image'`, headerType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. +Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/audio-indicator-type.md b/docs/assets/option/en/indicator/audio-indicator-type.md index fd50512a0e..4b0843db93 100644 --- a/docs/assets/option/en/indicator/audio-indicator-type.md +++ b/docs/assets/option/en/indicator/audio-indicator-type.md @@ -6,7 +6,7 @@ Specifies the column type as `'audio'`, cellType can be omitted with default as ##${prefix} cellType(string) = 'audio' -Specifies the column type as `'audio'`, cellType can be omitted with default as `'text'`. All configuration options are the same as the image type, refer to the image type configuration options for details. +Specifies the column type as `'audio'`, cellType can be omitted with default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/en/indicator/image-indicator-type.md b/docs/assets/option/en/indicator/image-indicator-type.md index 9708e83f5d..bf3f4f6079 100644 --- a/docs/assets/option/en/indicator/image-indicator-type.md +++ b/docs/assets/option/en/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} cellType(string) = 'image' -Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows (also applicable to 'video' and 'audio' types): +Specifies the column type as `'image'`, cellType can be omitted and defaults to `'text'`. Other configuration items are as follows; media preview options such as `clickToPreview` are also applicable to 'video' and 'audio' types: {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ Specifies the column type as `'image'`, cellType can be omitted and defaults to ##${prefix} clickToPreview(boolean) = true -**Configuration specific to image type** Whether to enable click preview. +Whether to enable click preview. diff --git a/docs/assets/option/zh/column/audio-column-type.md b/docs/assets/option/zh/column/audio-column-type.md index 040e4c006c..d3ef991f2d 100644 --- a/docs/assets/option/zh/column/audio-column-type.md +++ b/docs/assets/option/zh/column/audio-column-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'audio' -指定该列或该行单元格类型为`'audio'`,cellType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 +指定该列或该行单元格类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/zh/column/image-column-type.md b/docs/assets/option/zh/column/image-column-type.md index 0ff641a18f..b1b4e1bf2c 100644 --- a/docs/assets/option/zh/column/image-column-type.md +++ b/docs/assets/option/zh/column/image-column-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): +指定该列或该行单元格类型为`'image'`,cellType 可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-cell-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 +是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/audio-dimension-type.md b/docs/assets/option/zh/dimension/audio-dimension-type.md index a1b2966af0..52b780de4f 100644 --- a/docs/assets/option/zh/dimension/audio-dimension-type.md +++ b/docs/assets/option/zh/dimension/audio-dimension-type.md @@ -6,7 +6,7 @@ ##${prefix} headerType(string) = 'audio' -指定列类型为`'audio'`,headerType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 +指定列类型为`'audio'`,headerType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/zh/dimension/image-dimension-type.md b/docs/assets/option/zh/dimension/image-dimension-type.md index a6ce8707e8..1ae664494f 100644 --- a/docs/assets/option/zh/dimension/image-dimension-type.md +++ b/docs/assets/option/zh/dimension/image-dimension-type.md @@ -6,7 +6,7 @@ ##${prefix} headerType(string) = 'image' -指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): +指定列类型为`'image'`, headerType可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-dimension-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 +是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/audio-indicator-type.md b/docs/assets/option/zh/indicator/audio-indicator-type.md index 8b78a00cd2..c2b108c4ed 100644 --- a/docs/assets/option/zh/indicator/audio-indicator-type.md +++ b/docs/assets/option/zh/indicator/audio-indicator-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'audio' -指定列类型为`'audio'`,cellType 可缺省默认为`'text'`。所有配置项和 image 一样,可参考 image 类型配置项。 +指定列类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 ##${prefix} clickToPreview(boolean) = true diff --git a/docs/assets/option/zh/indicator/image-indicator-type.md b/docs/assets/option/zh/indicator/image-indicator-type.md index 79c6ea5c50..1ac47d9c5c 100644 --- a/docs/assets/option/zh/indicator/image-indicator-type.md +++ b/docs/assets/option/zh/indicator/image-indicator-type.md @@ -6,7 +6,7 @@ ##${prefix} cellType(string) = 'image' -指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下(同样适用于类型为 'video' 和 'audio'): +指定列类型为`'image'`,cellType可缺省默认为`'text'`。其他配置项如下;`clickToPreview` 等媒体预览配置同样适用于类型为 'video' 和 'audio': {{ use: base-indicator-type( prefix = '##'+${prefix}, @@ -23,4 +23,4 @@ ##${prefix} clickToPreview(boolean) = true -**image 类型专属配置项** 是否开启点击预览 +是否开启点击预览 diff --git a/packages/vtable/examples/type/audio.ts b/packages/vtable/examples/type/audio.ts index c2cd61a390..df1cd58950 100644 --- a/packages/vtable/examples/type/audio.ts +++ b/packages/vtable/examples/type/audio.ts @@ -71,10 +71,18 @@ const themeMap = { function applyPageTheme(theme: DemoTheme) { const isDark = theme === 'dark'; - document.body.style.background = isDark ? '#0F172A' : '#ffffff'; const container = document.getElementById(CONTAINER_ID); if (container) { container.style.background = isDark ? '#111827' : '#ffffff'; + container.parentElement?.style.setProperty('background', isDark ? '#0F172A' : '#ffffff'); + } +} + +function clearPageTheme() { + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.background = ''; + container.parentElement?.style.removeProperty('background'); } } @@ -175,7 +183,6 @@ export function createTable() { title: 'Audio', cellType: 'audio', width: 180, - keepAspectRatio: true, style: { padding: 8 } @@ -202,6 +209,13 @@ export function createTable() { const instance = new ListTable(option); createToolbar(instance, currentTheme); + const release = instance.release.bind(instance); + instance.release = () => { + document.getElementById(TOOLBAR_ID)?.remove(); + clearPageTheme(); + release(); + }; + VTable.bindDebugTool(instance.scenegraph.stage as any, { customGrapicKeys: ['role', '_updateTag'] }); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 71dba983df..41365ce96c 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -42,7 +42,6 @@ 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 { isAudioUrl } from '../../tools/media'; export function createCell( type: ColumnTypeOption, @@ -253,7 +252,7 @@ export function createCell( range, isAsync ); - } else if (type === 'audio' || (type === 'video' && isAudioUrl(value))) { + } else if (type === 'audio') { const createAudioCellGroup = Factory.getFunction('createAudioCellGroup') as CreateAudioCellGroup; cellGroup = createAudioCellGroup( columnGroup, @@ -266,6 +265,7 @@ export function createCell( padding, textAlign, textBaseline, + mayHaveIcon, table, cellTheme, range, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts index 07b85c08a3..b265687ef4 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -9,6 +9,7 @@ import type { BaseTableAPI } from '../../../ts-types/base-table'; import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { getQuadProps } from '../../utils/padding'; import type { CellRange } from '../../../ts-types'; +import { dealWithIconLayout } from '../../utils/text-icon-layout'; export const audioIconSvg = ''; @@ -24,9 +25,10 @@ export function createAudioCellGroup( padding: [number, number, number, number], textAlign: CanvasTextAlign, textBaseline: CanvasTextBaseline, + mayHaveIcon: boolean, table: BaseTableAPI, cellTheme: IThemeSpec, - _range: CellRange | undefined, + range: CellRange | undefined, isAsync: boolean ) { const headerStyle = table._getCellStyle(col, row); @@ -89,10 +91,68 @@ export function createAudioCellGroup( cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; } - const availableWidth = Math.max(1, width - padding[1] - padding[3]); + let cellIcons; + if (mayHaveIcon) { + let iconCol = col; + let iconRow = row; + if (range) { + iconCol = range.start.col; + iconRow = range.start.row; + } + cellIcons = table.getCellIcons(iconCol, iconRow); + } + + let cellLeftIconWidth = 0; + let cellRightIconWidth = 0; + if (Array.isArray(cellIcons) && cellIcons.length !== 0) { + const { leftIconWidth, rightIconWidth, absoluteRightIconWidth } = dealWithIconLayout( + cellIcons, + cellGroup, + range, + table + ); + + cellLeftIconWidth = leftIconWidth; + cellRightIconWidth = rightIconWidth; + + cellGroup.forEachChildren((child: any) => { + if (child.role === 'icon-left') { + child.setAttribute('x', child.attribute.x + padding[3]); + } else if (child.role === 'icon-right') { + child.setAttribute('x', child.attribute.x + width - rightIconWidth - padding[1]); + } else if (child.role === 'icon-absolute-right') { + child.setAttribute('x', child.attribute.x + width - absoluteRightIconWidth - padding[1]); + } + }); + + cellGroup.forEachChildren((child: any) => { + if (textBaseline === 'middle') { + child.setAttribute('y', (height - child.AABBBounds.height()) / 2); + } else if (textBaseline === 'bottom') { + child.setAttribute('y', height - child.AABBBounds.height() - padding[2]); + } else { + child.setAttribute('y', padding[0]); + } + }); + + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; + } + + const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); const availableHeight = Math.max(1, height - padding[0] - padding[2]); const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); - const pos = calcStartPosition(0, 0, width, height, iconSize, iconSize, textAlign, textBaseline, padding); + const pos = calcStartPosition( + cellLeftIconWidth, + 0, + width - cellLeftIconWidth - cellRightIconWidth, + height, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); const image: IImage = createImage({ x: pos.x, y: pos.y, @@ -103,6 +163,7 @@ export function createAudioCellGroup( }); image.name = 'image'; image.keepAspectRatio = true; + (image as any).isAudioIcon = true; image.textAlign = textAlign; image.textBaseline = textBaseline; cellGroup.appendChild(image); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 3e9a570e0c..303d742881 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -321,7 +321,7 @@ export function updateImageCellContentWhileResize( (typeof image.attribute.image !== 'string' && image.attribute.image) || image.resources?.get(image.attribute.image as string).data; - if (!originImage) { + if (!originImage && !(image as any).isAudioIcon) { return; } @@ -344,6 +344,38 @@ export function updateImageCellContentWhileResize( const leftIconWidth = (cellGroup as any)._cellLeftIconWidth ?? 0; const rightIconWidth = (cellGroup as any)._cellRightIconWidth ?? 0; + if ((image as any).isAudioIcon) { + const availableWidth = Math.max(1, cellWidth - padding[1] - padding[3] - leftIconWidth - rightIconWidth); + const availableHeight = Math.max(1, cellHeight - padding[0] - padding[2]); + const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); + const pos = calcStartPosition( + leftIconWidth, + 0, + cellWidth - leftIconWidth - rightIconWidth, + cellHeight, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); + + for (let col = colStart; col <= colEnd; col++) { + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(col, row); + const image = cellGroup.getChildByName('image') as Image; + image?.setAttributes({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize + }); + } + } + + return; + } + if ((image as any).keepAspectRatio || isDamagePic(image)) { const { width: imageWidth, height: imageHeight } = calcKeepAspectRatioSize( originImage.width || (originImage as any).videoWidth, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 04706e819f..29e44df5a7 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -263,6 +263,7 @@ export function createVideoCellGroup( }); image.name = 'image'; image.keepAspectRatio = true; + (image as any).isAudioIcon = true; image.textAlign = textAlign; image.textBaseline = textBaseline; cellGroup.appendChild(image); diff --git a/packages/vtable/src/tools/media.ts b/packages/vtable/src/tools/media.ts index a9fbe042a4..c8588cf739 100644 --- a/packages/vtable/src/tools/media.ts +++ b/packages/vtable/src/tools/media.ts @@ -1,5 +1,26 @@ -const AUDIO_EXT_REG = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)(?:[?#].*)?$/i; +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']); export function isAudioUrl(value: unknown): boolean { - return typeof value === 'string' && AUDIO_EXT_REG.test(value); + if (typeof value !== 'string') { + return false; + } + + const trimmedValue = value.trim(); + const queryIndex = trimmedValue.indexOf('?'); + const hashIndex = trimmedValue.indexOf('#'); + let endIndex = trimmedValue.length; + if (queryIndex >= 0) { + endIndex = queryIndex; + } + if (hashIndex >= 0 && hashIndex < endIndex) { + endIndex = hashIndex; + } + + const path = trimmedValue.slice(0, endIndex); + const dotIndex = path.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex === path.length - 1) { + return false; + } + + return AUDIO_EXTENSIONS.has(path.slice(dotIndex + 1).toLowerCase()); } From 6295ea281dfe3e5101817cbdb947f1254d652847 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 14:34:33 +0800 Subject: [PATCH 17/44] fix(vtable): address audio cell review gaps Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/guide/en/cell_type/image_video.md | 2 +- docs/assets/guide/zh/cell_type/image_video.md | 2 +- .../guide/zh/custom_define/custom_icon.md | 2 +- .../option/en/column/audio-column-type.md | 4 +++ .../en/dimension/audio-dimension-type.md | 4 +++ .../en/indicator/audio-indicator-type.md | 4 +++ .../en/indicator/base-indicator-type.md | 1 + .../option/zh/column/audio-column-type.md | 4 +++ .../zh/dimension/audio-dimension-type.md | 4 +++ .../zh/indicator/audio-indicator-type.md | 4 +++ .../zh/indicator/base-indicator-type.md | 1 + packages/vtable/examples/type/audio.ts | 4 --- packages/vtable/src/event/media-click.ts | 36 +++++++++++++++---- packages/vtable/src/layout/layout-helper.ts | 13 ++----- .../group-creater/cell-type/audio-cell.ts | 5 ++- .../group-creater/cell-type/image-cell.ts | 8 ++--- .../group-creater/cell-type/video-cell.ts | 14 ++++++-- .../list-table/define/image-define.ts | 20 +++++++++-- .../src/ts-types/list-table/define/index.ts | 17 +++++++-- .../pivot-table/dimension/image-dimension.ts | 12 ++++++- .../ts-types/pivot-table/dimension/index.ts | 6 ++-- .../pivot-table/indicator/image-indicator.ts | 22 ++++++++++-- .../ts-types/pivot-table/indicator/index.ts | 9 ++++- packages/vtable/src/ts-types/table-engine.ts | 4 +++ 24 files changed, 156 insertions(+), 46 deletions(-) diff --git a/docs/assets/guide/en/cell_type/image_video.md b/docs/assets/guide/en/cell_type/image_video.md index d3c7e645c6..0060b7d3b8 100644 --- a/docs/assets/guide/en/cell_type/image_video.md +++ b/docs/assets/guide/en/cell_type/image_video.md @@ -2,7 +2,7 @@ Tables can display various types of data, such as text, numbers, charts, etc. Among them, the display of multimedia types (such as images, videos, and audio) in some scenes is particularly important, they can visually display information and help users quickly obtain key data. For example, e-commerce websites can display product pictures in tables, which is convenient for store owners to quickly view product information; video websites can display video preview diagrams in tables; audio libraries can display playable audio entries in tables. By displaying multimedia types in tables, the effect of data display and user experience can be effectively improved. -Note: The following descriptions are based on image types, and video and audio types are also applicable. +Note: The following descriptions are based on image types. Video and audio share media capabilities such as click preview; image-specific options such as `keepAspectRatio`, `imageAutoSizing`, and image styles do not apply to audio. ## Introduction to unique configuration items diff --git a/docs/assets/guide/zh/cell_type/image_video.md b/docs/assets/guide/zh/cell_type/image_video.md index 807c91fdfb..ce130a5a00 100644 --- a/docs/assets/guide/zh/cell_type/image_video.md +++ b/docs/assets/guide/zh/cell_type/image_video.md @@ -2,7 +2,7 @@ 表格可以展示各种类型的数据,例如文本、数字、图表等。其中,多媒体类型(如 image、video 和 audio)在某些场景中的展示尤其重要,它们可以直观地展示信息,帮助用户快速获取关键数据。例如,电商网站可以在表格中展示商品图片,便于店主快速查看商品信息;视频网站可以在表格中展示视频预览图,音频库可以在表格中展示可播放的音频入口。通过在表格中展示多媒体类型,可以有效提高数据展示的效果和用户体验。 -注:以下介绍均以 image 类型为例,video 和 audio 类型也同样适用。 +注:以下介绍均以 image 类型为例,video 和 audio 共享点击预览等媒体能力;`keepAspectRatio`、`imageAutoSizing` 和图片样式等 image 专属配置不适用于 audio。 ## 特有配置项介绍 diff --git a/docs/assets/guide/zh/custom_define/custom_icon.md b/docs/assets/guide/zh/custom_define/custom_icon.md index 1f64971d17..c29106ef25 100644 --- a/docs/assets/guide/zh/custom_define/custom_icon.md +++ b/docs/assets/guide/zh/custom_define/custom_icon.md @@ -210,7 +210,7 @@ VTable.register.icon('frozenCurrent', { | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "freeze" | 固定列功能图标 可固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozen" | 固定列功能图标 已固定状态 | | 固定列 | VTable.TYPES.IconFuncTypeEnum.frozen | "frozenCurrent" | 固定列功能图标 被冻结当列 | -| 图片、视频或音频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | +| 图片或视频地址失效 | VTable.TYPES.IconFuncTypeEnum.damagePic | "damage_pic" | 多媒体资源解析失败 | | 加载中 | VTable.TYPES.IconFuncTypeEnum.loading_pic | "loading_pic" | 加载中状态 | |图片image类型解析失败 |VTable.TYPES.IconFuncTypeEnum.imageDamagePic | "image_damage_pic" | 图片资源解析失败 | |视频video类型解析失败 |VTable.TYPES.IconFuncTypeEnum.videoDamagePic | "video_damage_pic" | 视频资源解析失败 | diff --git a/docs/assets/option/en/column/audio-column-type.md b/docs/assets/option/en/column/audio-column-type.md index 98e1566b7a..7f57051374 100644 --- a/docs/assets/option/en/column/audio-column-type.md +++ b/docs/assets/option/en/column/audio-column-type.md @@ -8,6 +8,10 @@ Specify the column type as `'audio'`, cellType can be omitted with the default a Specify the column type as `'audio'`, cellType can be omitted with the default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. +{{ use: base-cell-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true Whether to enable click preview. diff --git a/docs/assets/option/en/dimension/audio-dimension-type.md b/docs/assets/option/en/dimension/audio-dimension-type.md index 3e060b508e..2d62d44b84 100644 --- a/docs/assets/option/en/dimension/audio-dimension-type.md +++ b/docs/assets/option/en/dimension/audio-dimension-type.md @@ -8,6 +8,10 @@ Specifies the column type as `'audio'`, headerType can be omitted and defaults t Specifies the column type as `'audio'`, headerType can be omitted and defaults to `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. +{{ use: base-dimension-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/audio-indicator-type.md b/docs/assets/option/en/indicator/audio-indicator-type.md index 4b0843db93..3a699d49b1 100644 --- a/docs/assets/option/en/indicator/audio-indicator-type.md +++ b/docs/assets/option/en/indicator/audio-indicator-type.md @@ -8,6 +8,10 @@ Specifies the column type as `'audio'`, cellType can be omitted with default as Specifies the column type as `'audio'`, cellType can be omitted with default as `'text'`. Audio shares media preview options such as `clickToPreview`; image-specific options such as `keepAspectRatio` and `imageAutoSizing` do not apply. +{{ use: base-indicator-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true Whether to enable click preview. diff --git a/docs/assets/option/en/indicator/base-indicator-type.md b/docs/assets/option/en/indicator/base-indicator-type.md index 2457d73dbd..34445486e2 100644 --- a/docs/assets/option/en/indicator/base-indicator-type.md +++ b/docs/assets/option/en/indicator/base-indicator-type.md @@ -51,6 +51,7 @@ Header cell style. Configuration items vary slightly depending on the headerType - For headerType 'link', see [headerStyle](../option/PivotTable-columns-link#headerStyle.bgColor) - For headerType 'image', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) - For headerType 'video', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) +- For headerType 'audio', see [headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) ${prefix} style diff --git a/docs/assets/option/zh/column/audio-column-type.md b/docs/assets/option/zh/column/audio-column-type.md index d3ef991f2d..600ae7be62 100644 --- a/docs/assets/option/zh/column/audio-column-type.md +++ b/docs/assets/option/zh/column/audio-column-type.md @@ -8,6 +8,10 @@ 指定该列或该行单元格类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 +{{ use: base-cell-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true 是否开启点击预览 diff --git a/docs/assets/option/zh/dimension/audio-dimension-type.md b/docs/assets/option/zh/dimension/audio-dimension-type.md index 52b780de4f..5c81969cfb 100644 --- a/docs/assets/option/zh/dimension/audio-dimension-type.md +++ b/docs/assets/option/zh/dimension/audio-dimension-type.md @@ -8,6 +8,10 @@ 指定列类型为`'audio'`,headerType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 +{{ use: base-dimension-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true 是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/audio-indicator-type.md b/docs/assets/option/zh/indicator/audio-indicator-type.md index c2b108c4ed..76d45434fb 100644 --- a/docs/assets/option/zh/indicator/audio-indicator-type.md +++ b/docs/assets/option/zh/indicator/audio-indicator-type.md @@ -8,6 +8,10 @@ 指定列类型为`'audio'`,cellType 可缺省默认为`'text'`。audio 共享 `clickToPreview` 等媒体预览配置;`keepAspectRatio`、`imageAutoSizing` 等 image 专属配置不适用于 audio。 +{{ use: base-indicator-type( + prefix = '##'+${prefix} +) }} + ##${prefix} clickToPreview(boolean) = true 是否开启点击预览 diff --git a/docs/assets/option/zh/indicator/base-indicator-type.md b/docs/assets/option/zh/indicator/base-indicator-type.md index d3ff59a828..ddd8d695aa 100644 --- a/docs/assets/option/zh/indicator/base-indicator-type.md +++ b/docs/assets/option/zh/indicator/base-indicator-type.md @@ -51,6 +51,7 @@ ${prefix} headerStyle(TODO) - headerType 为'link',对应[headerStyle](../option/PivotTable-columns-link#headerStyle.bgColor) - headerType 为'image',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) - headerType 为'video',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) +- headerType 为'audio',对应[headerStyle](../option/PivotTable-columns-image#headerStyle.bgColor) ${prefix} style diff --git a/packages/vtable/examples/type/audio.ts b/packages/vtable/examples/type/audio.ts index df1cd58950..da11e4b2db 100644 --- a/packages/vtable/examples/type/audio.ts +++ b/packages/vtable/examples/type/audio.ts @@ -216,9 +216,5 @@ export function createTable() { release(); }; - VTable.bindDebugTool(instance.scenegraph.stage as any, { - customGrapicKeys: ['role', '_updateTag'] - }); - window.tableInstance = instance; } diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 05b94dbbd2..42bb31ab51 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -4,7 +4,7 @@ import { Env } from '../tools/env'; import { regUrl } from '../tools/global'; import type { LinkColumnDefine, MousePointerCellEvent } from '../ts-types'; import type { BaseTableAPI, HeaderData } from '../ts-types/base-table'; -import type { IImageColumnBodyDefine } from '../ts-types/list-table/define/image-define'; +import type { IAudioColumnBodyDefine, IImageColumnBodyDefine } from '../ts-types/list-table/define/image-define'; import { getOrApply } from '../tools/helper'; import { isAudioUrl } from '../tools/media'; @@ -76,6 +76,7 @@ function createAudioPreview(cellValue: string) { const audio = document.createElement('audio'); audio.src = cellValue; audio.preload = 'auto'; + let released = false; const wrapper = document.createElement('div'); wrapper.style.width = '80%'; @@ -125,9 +126,11 @@ function createAudioPreview(cellValue: string) { updateAudioProgressStyle(progress, 0); const updateProgress = () => { - const duration = audio.duration || 0; + const duration = Number.isFinite(audio.duration) ? audio.duration : 0; const currentTime = audio.currentTime || 0; const value = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; + progress.disabled = duration <= 0; + progress.style.cursor = duration > 0 ? 'pointer' : 'default'; progress.value = `${value}`; timeLabel.textContent = `${formatAudioTime(currentTime)} / ${formatAudioTime(duration)}`; updateAudioProgressStyle(progress, value); @@ -136,7 +139,15 @@ function createAudioPreview(cellValue: string) { playButton.addEventListener('click', e => { e.stopPropagation(); if (audio.paused) { - audio.play(); + const playPromise = audio.play(); + if (playPromise) { + playPromise.catch(error => { + if (released || error?.name === 'AbortError') { + return; + } + playButton.textContent = '▶'; + }); + } } else { audio.pause(); } @@ -147,7 +158,7 @@ function createAudioPreview(cellValue: string) { }); progress.addEventListener('input', () => { - const duration = audio.duration || 0; + const duration = Number.isFinite(audio.duration) ? audio.duration : 0; const value = Number(progress.value); if (duration > 0) { audio.currentTime = (duration * value) / 100; @@ -177,6 +188,7 @@ function createAudioPreview(cellValue: string) { return { element: wrapper, destroy: () => { + released = true; audio.pause(); audio.removeAttribute('src'); audio.load(); @@ -187,6 +199,13 @@ function createAudioPreview(cellValue: string) { export function bindMediaClick(table: BaseTableAPI): void { if (Env.mode === 'browser') { // table.hasMedia = false; + let closeAudioPreview: (() => void) | null = null; + table.addReleaseObj({ + release: () => { + closeAudioPreview?.(); + closeAudioPreview = null; + } + }); table.on(TABLE_EVENT_TYPE.CLICK_CELL, (e: MousePointerCellEvent) => { //如果目前是在某个icon上,如收起展开按钮 则不进行其他点击逻辑 @@ -315,7 +334,7 @@ export function bindMediaClick(table: BaseTableAPI): void { document.body.appendChild(overlay); } else if (cellType === 'audio' || (cellType === 'video' && isAudioUrl(cellValue))) { // 点击音频,弹出播放窗口 - const { clickToPreview } = columnDefine as IImageColumnBodyDefine; + const { clickToPreview } = columnDefine as IAudioColumnBodyDefine; if (clickToPreview === false) { return; } @@ -335,10 +354,15 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.zIndex = '9999'; const audioPreview = createAudioPreview(cellValue); + closeAudioPreview?.(); const closeOverlay = () => { + closeAudioPreview = null; audioPreview.destroy(); - document.body.removeChild(overlay); + if (overlay.parentNode) { + document.body.removeChild(overlay); + } }; + closeAudioPreview = closeOverlay; overlay.addEventListener('click', e => { if (e.target === overlay) { diff --git a/packages/vtable/src/layout/layout-helper.ts b/packages/vtable/src/layout/layout-helper.ts index d189cc6773..a77fb945ae 100644 --- a/packages/vtable/src/layout/layout-helper.ts +++ b/packages/vtable/src/layout/layout-helper.ts @@ -115,10 +115,7 @@ export function hasAutoImageColumn(table: BaseTableAPI) { continue; } if ( - (row.headerType === 'image' || - row.headerType === 'video' || - row.headerType === 'audio' || - typeof row.headerType === 'function') && + (row.headerType === 'image' || row.headerType === 'video' || typeof row.headerType === 'function') && (row as IImageDimension).imageAutoSizing ) { return true; @@ -134,12 +131,10 @@ export function hasAutoImageColumn(table: BaseTableAPI) { if ( ((indicator.cellType === 'image' || indicator.cellType === 'video' || - indicator.cellType === 'audio' || typeof indicator.cellType === 'function') && (indicator as IImageColumnIndicator).imageAutoSizing) || ((indicator.headerType === 'image' || indicator.headerType === 'video' || - indicator.headerType === 'audio' || typeof indicator.headerType === 'function') && (indicator as IImageHeaderIndicator).imageAutoSizing) ) { @@ -153,14 +148,10 @@ export function hasAutoImageColumn(table: BaseTableAPI) { for (let i = 0; i < columns.length; i++) { const column = columns[i] as unknown as ListTableConstructorOptions['columns'][0]; if ( - ((column.cellType === 'image' || - column.cellType === 'video' || - column.cellType === 'audio' || - typeof column.cellType === 'function') && + ((column.cellType === 'image' || column.cellType === 'video' || typeof column.cellType === 'function') && (column as IImageColumnBodyDefine).imageAutoSizing) || ((column.headerType === 'image' || column.headerType === 'video' || - column.headerType === 'audio' || typeof column.headerType === 'function') && (column as IImageHeaderDefine).imageAutoSizing) ) { diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts index b265687ef4..8fdf6f10b2 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -134,10 +134,9 @@ export function createAudioCellGroup( child.setAttribute('y', padding[0]); } }); - - (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; - (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); const availableHeight = Math.max(1, height - padding[0] - padding[2]); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 303d742881..7b03d4fb47 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -339,7 +339,7 @@ export function updateImageCellContentWhileResize( const colStart = cellGroup.mergeStartCol ?? cellGroup.col; const rowStart = cellGroup.mergeStartRow ?? cellGroup.row; const colEnd = cellGroup.mergeEndCol ?? cellGroup.col; - const rowEnd = cellGroup.mergeEndCol ?? cellGroup.row; + const rowEnd = cellGroup.mergeEndRow ?? cellGroup.row; const leftIconWidth = (cellGroup as any)._cellLeftIconWidth ?? 0; const rightIconWidth = (cellGroup as any)._cellRightIconWidth ?? 0; @@ -372,11 +372,7 @@ export function updateImageCellContentWhileResize( }); } } - - return; - } - - if ((image as any).keepAspectRatio || isDamagePic(image)) { + } else if ((image as any).keepAspectRatio || isDamagePic(image)) { const { width: imageWidth, height: imageHeight } = calcKeepAspectRatioSize( originImage.width || (originImage as any).videoWidth, originImage.height || (originImage as any).videoHeight, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 29e44df5a7..3ea02f3da7 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -249,10 +249,20 @@ export function createVideoCellGroup( // video const value = table.getCellValue(col, row); if (isAudioUrl(value)) { - const availableWidth = Math.max(1, width - padding[1] - padding[3]); + const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); const availableHeight = Math.max(1, height - padding[0] - padding[2]); const iconSize = Math.max(1, Math.min(availableWidth, availableHeight, 32)); - const pos = calcStartPosition(0, 0, width, height, iconSize, iconSize, textAlign, textBaseline, padding); + const pos = calcStartPosition( + cellLeftIconWidth, + 0, + width - cellLeftIconWidth - cellRightIconWidth, + height, + iconSize, + iconSize, + textAlign, + textBaseline, + padding + ); const image: IImage = createImage({ x: pos.x, y: pos.y, diff --git a/packages/vtable/src/ts-types/list-table/define/image-define.ts b/packages/vtable/src/ts-types/list-table/define/image-define.ts index 76ddbeec35..efcfcc06ca 100644 --- a/packages/vtable/src/ts-types/list-table/define/image-define.ts +++ b/packages/vtable/src/ts-types/list-table/define/image-define.ts @@ -4,7 +4,7 @@ import type { IBasicColumnBodyDefine, IBasicHeaderDefine } from './basic-define' export interface IImageHeaderDefine extends IBasicHeaderDefine { headerStyle?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); - headerType: 'image' | 'video' | 'audio'; + headerType: 'image' | 'video'; /** 是否保持横纵比 默认false */ keepAspectRatio?: boolean; @@ -17,7 +17,7 @@ export interface IImageHeaderDefine extends IBasicHeaderDefine { export interface IImageColumnBodyDefine extends IBasicColumnBodyDefine { style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); - cellType: 'image' | 'video' | 'audio'; + cellType: 'image' | 'video'; /** 是否保持横纵比 默认false */ keepAspectRatio?: boolean; @@ -27,3 +27,19 @@ export interface IImageColumnBodyDefine extends IBasicColumnBodyDefine { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioHeaderDefine extends IBasicHeaderDefine { + headerStyle?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); + headerType: 'audio'; + + /** 点击开启预览 */ + clickToPreview?: boolean; +} + +export interface IAudioColumnBodyDefine extends IBasicColumnBodyDefine { + style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); + cellType: 'audio'; + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/list-table/define/index.ts b/packages/vtable/src/ts-types/list-table/define/index.ts index 42897cf308..b802e5c2b8 100644 --- a/packages/vtable/src/ts-types/list-table/define/index.ts +++ b/packages/vtable/src/ts-types/list-table/define/index.ts @@ -2,7 +2,12 @@ import type { Either } from '../../../tools/helper'; import type { ICompositeColumnBodyDefine } from './composite-define'; import type { IChartColumnBodyDefine } from './chart-define'; import type { ICheckboxColumnBodyDefine, ICheckboxHeaderDefine } from './checkbox-define'; -import type { IImageColumnBodyDefine, IImageHeaderDefine } from './image-define'; +import type { + IAudioColumnBodyDefine, + IAudioHeaderDefine, + IImageColumnBodyDefine, + IImageHeaderDefine +} from './image-define'; import type { ILinkColumnBodyDefine, ILinkHeaderDefine } from './link-define'; import type { ITextColumnBodyDefine, ITextHeaderDefine } from './multilinetext-define'; import type { IProgressbarColumnBodyDefine } from './progressbar-define'; @@ -11,7 +16,12 @@ import type { IRadioColumnBodyDefine } from './radio-define'; import type { ISwitchColumnBodyDefine } from './switch-define'; import type { IButtonColumnBodyDefine } from './button-define'; -export type HeaderDefine = IImageHeaderDefine | ILinkHeaderDefine | ICheckboxHeaderDefine | ITextHeaderDefine; +export type HeaderDefine = + | IImageHeaderDefine + | IAudioHeaderDefine + | ILinkHeaderDefine + | ICheckboxHeaderDefine + | ITextHeaderDefine; /** * 内置列 body 类型联合 @@ -20,6 +30,7 @@ export type HeaderDefine = IImageHeaderDefine | ILinkHeaderDefine | ICheckboxHea export type BuiltinColumnBodyDefine = | ILinkColumnBodyDefine | IImageColumnBodyDefine + | IAudioColumnBodyDefine | ISparklineColumnBodyDefine | IProgressbarColumnBodyDefine | ICheckboxColumnBodyDefine @@ -54,6 +65,7 @@ export type ColumnBodyDefine = BuiltinColumnBodyDefine | CustomColumnBodyDefineM export type TextColumnDefine = ITextColumnBodyDefine & HeaderDefine; export type LinkColumnDefine = ILinkColumnBodyDefine & HeaderDefine; export type ImageColumnDefine = IImageColumnBodyDefine & HeaderDefine; +export type AudioColumnDefine = IAudioColumnBodyDefine & HeaderDefine; export type SparklineColumnDefine = ISparklineColumnBodyDefine & HeaderDefine; export type ProgressbarColumnDefine = IProgressbarColumnBodyDefine & HeaderDefine; export type CheckboxColumnDefine = ICheckboxColumnBodyDefine & HeaderDefine; @@ -69,6 +81,7 @@ export type ButtonColumnDefine = IButtonColumnBodyDefine & HeaderDefine; export type BuiltinSimpleColumnDefine = | LinkColumnDefine | ImageColumnDefine + | AudioColumnDefine | SparklineColumnDefine | ProgressbarColumnDefine | CheckboxColumnDefine diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts index 099584c661..89283cf253 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/image-dimension.ts @@ -3,7 +3,7 @@ import type { StylePropertyFunctionArg } from '../../style-define'; import type { IBasicDimension } from './basic-dimension'; export interface IImageDimension extends IBasicDimension { - headerType: 'image' | 'video' | 'audio'; + headerType: 'image' | 'video'; headerStyle?: | IImageStyleOption //表头可以配置吸附; | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); //该维度层级表头部分的样式 @@ -20,3 +20,13 @@ export interface IImageDimension extends IBasicDimension { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioDimension extends IBasicDimension { + headerType: 'audio'; + headerStyle?: + | IImageStyleOption //表头可以配置吸附; + | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); //该维度层级表头部分的样式 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts index df638478ec..4b8647020d 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts @@ -1,11 +1,11 @@ import type { ICompositeDimension } from './composite-dimension'; -import type { IImageDimension } from './image-dimension'; +import type { IAudioDimension, IImageDimension } from './image-dimension'; import type { ILinkDimension } from './link-dimension'; import type { ITextDimension } from './multilinetext-dimension'; export type IDimension = IRowDimension | IColumnDimension; -export type IRowDimension = ILinkDimension | IImageDimension | ITextDimension | ICompositeDimension; +export type IRowDimension = ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension; export type IColumnDimension = Omit< - ILinkDimension | IImageDimension | ITextDimension | ICompositeDimension, + ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension, 'width' | 'minWidth' | 'maxWidth' >; diff --git a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts index be8b5b6991..5353b5a570 100644 --- a/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts +++ b/packages/vtable/src/ts-types/pivot-table/indicator/image-indicator.ts @@ -3,7 +3,7 @@ import type { StylePropertyFunctionArg } from '../../style-define'; import type { IBasicColumnIndicator, IBasicHeaderIndicator } from './basic-indicator'; export interface IImageHeaderIndicator extends IBasicHeaderIndicator { - headerType: 'image' | 'video' | 'audio'; // 指标表头类型 + headerType: 'image' | 'video'; // 指标表头类型 headerStyle?: | IImageStyleOption //表头可以配置吸附; | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // 指标名称在表头部分显示类型 @@ -18,7 +18,7 @@ export interface IImageHeaderIndicator extends IBasicHeaderIndicator { } export interface IImageColumnIndicator extends IBasicColumnIndicator { - cellType: 'image' | 'video' | 'audio'; // body指标值显示类型 + cellType: 'image' | 'video'; // body指标值显示类型 style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // body部分指标值显示样式 /** 是否保持横纵比 默认false */ @@ -29,3 +29,21 @@ export interface IImageColumnIndicator extends IBasicColumnIndicator { /** 点击开启预览 */ clickToPreview?: boolean; } + +export interface IAudioHeaderIndicator extends IBasicHeaderIndicator { + headerType: 'audio'; // 指标表头类型 + headerStyle?: + | IImageStyleOption //表头可以配置吸附; + | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // 指标名称在表头部分显示类型 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} + +export interface IAudioColumnIndicator extends IBasicColumnIndicator { + cellType: 'audio'; // body指标值显示类型 + style?: IImageStyleOption | ((styleArg: StylePropertyFunctionArg) => IImageStyleOption); // body部分指标值显示样式 + + /** 点击开启预览 */ + clickToPreview?: boolean; +} diff --git a/packages/vtable/src/ts-types/pivot-table/indicator/index.ts b/packages/vtable/src/ts-types/pivot-table/indicator/index.ts index afb3744133..59cc820e22 100644 --- a/packages/vtable/src/ts-types/pivot-table/indicator/index.ts +++ b/packages/vtable/src/ts-types/pivot-table/indicator/index.ts @@ -1,7 +1,12 @@ import type { IChartColumnIndicator } from './chart-indicator'; import type { ICheckboxColumnIndicator } from './checkbox-indicator'; import type { ICompositeColumnIndicator, ICompositeHeaderIndicator } from './composite-indicator'; -import type { IImageColumnIndicator, IImageHeaderIndicator } from './image-indicator'; +import type { + IAudioColumnIndicator, + IAudioHeaderIndicator, + IImageColumnIndicator, + IImageHeaderIndicator +} from './image-indicator'; import type { ILinkColumnIndicator, ILinkHeaderIndicator } from './link-indicator'; import type { ITextColumnIndicator, ITextHeaderIndicator } from './multilinetext-indicator'; import type { IProgressbarColumnIndicator } from './progress-indicator'; @@ -10,12 +15,14 @@ import type { ISparklineColumnIndicator } from './sparkline-indicator'; export type HeaderIndicator = | ILinkHeaderIndicator | IImageHeaderIndicator + | IAudioHeaderIndicator | ITextHeaderIndicator | ICompositeHeaderIndicator; export type ColumnIndicator = | ILinkColumnIndicator | IImageColumnIndicator + | IAudioColumnIndicator | ISparklineColumnIndicator | IProgressbarColumnIndicator | ICheckboxColumnIndicator diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index d98bb1d5f2..4c540bd72f 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -155,6 +155,8 @@ export interface IRowSeriesNumber { format?: (col?: number, row?: number, table?: BaseTableAPI) => any; headerType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox' | 'radio'; + /** 点击开启预览,仅在 image/video/audio 类型时生效 */ + clickToPreview?: boolean; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerIcon?: string | ColumnIconOption | (string | ColumnIconOption)[]; @@ -185,6 +187,8 @@ export interface ColumnSeriesNumber { field?: FieldDef; format?: (col?: number, row?: number, table?: BaseTableAPI) => any; cellType?: 'text' | 'link' | 'image' | 'video' | 'audio' | 'checkbox'; + /** 点击开启预览,仅在 image/video/audio 类型时生效 */ + clickToPreview?: boolean; style?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); headerStyle?: ITextStyleOption | ((styleArg: StylePropertyFunctionArg) => ITextStyleOption); icon?: From 5928d151f40b5f74336b51e36eb2b732d554c756 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 14:37:46 +0800 Subject: [PATCH 18/44] fix(vtable-plugins): align context menu click payload Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/guide/en/plugin/context-menu.md | 6 ++-- docs/assets/guide/zh/plugin/context-menu.md | 6 ++-- .../context-menu/handle-menu-helper.test.ts | 3 +- .../issue-4655-context-menu-click.ts | 29 +++++++++++++++---- packages/vtable-plugins/src/context-menu.ts | 6 +++- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/assets/guide/en/plugin/context-menu.md b/docs/assets/guide/en/plugin/context-menu.md index 28b549192e..7211754946 100644 --- a/docs/assets/guide/en/plugin/context-menu.md +++ b/docs/assets/guide/en/plugin/context-menu.md @@ -135,7 +135,7 @@ const contextMenuPlugin = new ContextMenuPlugin({ ## Listen to Menu Item Click Events -If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled, and it is fired after the corresponding menu item handling logic finishes. Note that when `menuClickCallback` is configured as a function, the plugin runs that callback and skips the built-in menu handling. Built-in operations such as freeze, insert, and delete are only executed when `menuClickCallback` is not configured, or when it is configured as an object and the current `menuKey` does not match a custom callback. +If you need to observe user actions after a right-click menu item is clicked, listen to the `context_menu_click` event fired by `ContextMenuPlugin`. This event is only fired after the right-click menu plugin is installed and enabled, and it is fired after the corresponding menu item handling logic is called. Note that when `menuClickCallback` is configured as a function, the plugin runs that callback and skips the built-in menu handling. Built-in operations such as freeze, insert, and delete are only executed when `menuClickCallback` is not configured, or when it is configured as an object and the current `menuKey` does not match a custom callback. ```typescript import * as VTable from '@visactor/vtable'; @@ -172,8 +172,8 @@ Event argument description: | `contextMenu.menuText` | string | Display text of the clicked menu item | | `contextMenu.rowIndex` | number \| undefined | Row index of the menu trigger position; may be `undefined` when no row context exists | | `contextMenu.colIndex` | number \| undefined | Column index of the menu trigger position; may be `undefined` when no column context exists | -| `contextMenu.cellValue` | any | Cell value at the menu trigger position | -| `contextMenu.inputValue` | number \| string | Input value of an input menu item | +| `contextMenu.cellValue` | any \| undefined | Cell value at the menu trigger position; `undefined` when there is no corresponding cell | +| `contextMenu.inputValue` | number \| string \| undefined | Input value of an input menu item; only exists when an input menu item is submitted | ## Complete Example diff --git a/docs/assets/guide/zh/plugin/context-menu.md b/docs/assets/guide/zh/plugin/context-menu.md index 34804adc1b..e999ba4268 100644 --- a/docs/assets/guide/zh/plugin/context-menu.md +++ b/docs/assets/guide/zh/plugin/context-menu.md @@ -135,7 +135,7 @@ const contextMenuPlugin = new ContextMenuPlugin({ ## 监听菜单项点击事件 -如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在菜单项对应的处理逻辑完成后触发。需要注意的是,如果 `menuClickCallback` 配置为函数,插件会执行该回调并跳过内置菜单处理;如果未配置 `menuClickCallback`,或配置为对象但当前 `menuKey` 未命中自定义回调,才会执行内置的冻结、插入、删除等菜单操作。 +如果需要在右键菜单项点击后统一感知用户操作,可以监听 `ContextMenuPlugin` 触发的 `context_menu_click` 事件。该事件只有安装并启用右键菜单插件后才会触发,并且会在菜单项对应的处理逻辑调用后触发。需要注意的是,如果 `menuClickCallback` 配置为函数,插件会执行该回调并跳过内置菜单处理;如果未配置 `menuClickCallback`,或配置为对象但当前 `menuKey` 未命中自定义回调,才会执行内置的冻结、插入、删除等菜单操作。 ```typescript import * as VTable from '@visactor/vtable'; @@ -172,8 +172,8 @@ tableInstance.on(VTable.TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, args => { | `contextMenu.menuText` | string | 被点击菜单项的展示文本 | | `contextMenu.rowIndex` | number \| undefined | 菜单触发位置对应的行号;没有对应行时可能为 `undefined` | | `contextMenu.colIndex` | number \| undefined | 菜单触发位置对应的列号;没有对应列时可能为 `undefined` | -| `contextMenu.cellValue` | any | 菜单触发位置对应的单元格值 | -| `contextMenu.inputValue` | number \| string | 输入型菜单项中的输入值 | +| `contextMenu.cellValue` | any \| undefined | 菜单触发位置对应的单元格值;没有对应单元格时为 `undefined` | +| `contextMenu.inputValue` | number \| string \| undefined | 输入型菜单项中的输入值;仅输入型菜单项提交时存在 | ## 完整示例 diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index 1e4f639506..dfe17e37bf 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -152,8 +152,7 @@ describe('Context menu canvas option', () => { menuKey: 'freeze_to_this_row_and_column', menuText: '冻结到本行本列', rowIndex: 1, - colIndex: 0, - cellValue: 1 + colIndex: 0 }, table ); diff --git a/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts index cf5f5934ce..f26580ae23 100644 --- a/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts +++ b/packages/vtable-plugins/demo/context-menu/issue-4655-context-menu-click.ts @@ -2,6 +2,8 @@ import * as VTable from '@visactor/vtable'; import { ContextMenuPlugin } from '../../src/context-menu'; const CONTAINER_ID = 'vTable'; +const INFO_ID = 'context-menu-click-info'; +const STATUS_ID = 'context-menu-click-status'; function createRecords() { return Array.from({ length: 12 }, (_, index) => ({ @@ -54,7 +56,19 @@ export function createTableInstance(status: HTMLElement) { } export function createTable() { + document.getElementById(INFO_ID)?.remove(); + document.getElementById(STATUS_ID)?.remove(); + + const container = document.getElementById(CONTAINER_ID) ?? document.createElement('div'); + container.id = CONTAINER_ID; + container.style.width = '100%'; + container.style.height = '460px'; + if (!container.parentNode) { + document.body.appendChild(container); + } + const info = document.createElement('div'); + info.id = INFO_ID; info.style.margin = '10px'; info.style.padding = '10px'; info.style.border = '1px solid #ddd'; @@ -68,6 +82,7 @@ export function createTable() { document.body.appendChild(info); const status = document.createElement('pre'); + status.id = STATUS_ID; status.style.margin = '10px'; status.style.padding = '10px'; status.style.border = '1px solid #ddd'; @@ -75,11 +90,13 @@ export function createTable() { status.style.backgroundColor = '#fff'; document.body.appendChild(status); - const container = document.createElement('div'); - container.id = CONTAINER_ID; - container.style.width = '100%'; - container.style.height = '460px'; - document.body.appendChild(container); + const tableInstance = createTableInstance(status); + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + document.getElementById(INFO_ID)?.remove(); + document.getElementById(STATUS_ID)?.remove(); + release(); + }; - return createTableInstance(status); + return tableInstance; } diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 1e8aa5d7c3..0c50e0ac0a 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -254,13 +254,17 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { private fireContextMenuClick(args: MenuClickEventArgs, table: ListTable): void { const { colIndex, rowIndex, ...contextMenu } = args; + const hasCellPosition = + typeof colIndex === 'number' && typeof rowIndex === 'number' && colIndex >= 0 && rowIndex >= 0; + const cellValue = contextMenu.cellValue ?? (hasCellPosition ? table.getCellValue(colIndex, rowIndex) : undefined); table.fireListeners(TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, { col: colIndex ?? -1, row: rowIndex ?? -1, contextMenu: { ...contextMenu, colIndex, - rowIndex + rowIndex, + cellValue } }); } From b84bb2c3220fa7adb19afea38eebc2e0d08b9260 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 14:59:24 +0800 Subject: [PATCH 19/44] fix(vtable-plugins): stabilize context menu click test Use two columns so freeze-to-column remains valid. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/context-menu/handle-menu-helper.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index dfe17e37bf..021f2139e7 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -140,8 +140,11 @@ describe('Context menu canvas option', () => { table = new ListTable({ container, - columns: [{ field: 'id', title: 'ID' }], - records: [{ id: 1 }], + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records: [{ id: 1, name: 'Alice' }], plugins: [contextMenuPlugin] }); From e20afef0726ba3df5c7feee141f0becb59283d49 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 16:18:38 +0800 Subject: [PATCH 20/44] fix(vtable): address audio review follow-ups Export the audio column type. Keep audio out of image sizing paths. Clear stale video graphics on reused cells. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/index.ts | 2 ++ packages/vtable/src/layout/layout-helper.ts | 5 +---- .../group-creater/cell-type/video-cell.ts | 22 +++++++++++++++++++ .../ts-types/pivot-table/dimension/index.ts | 6 ++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/vtable/src/index.ts b/packages/vtable/src/index.ts index f834a4543a..3d02a5bade 100644 --- a/packages/vtable/src/index.ts +++ b/packages/vtable/src/index.ts @@ -16,6 +16,7 @@ import type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, @@ -99,6 +100,7 @@ export { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, diff --git a/packages/vtable/src/layout/layout-helper.ts b/packages/vtable/src/layout/layout-helper.ts index a77fb945ae..40b79b2ed8 100644 --- a/packages/vtable/src/layout/layout-helper.ts +++ b/packages/vtable/src/layout/layout-helper.ts @@ -98,10 +98,7 @@ export function hasAutoImageColumn(table: BaseTableAPI) { continue; } if ( - (column.headerType === 'image' || - column.headerType === 'video' || - column.headerType === 'audio' || - typeof column.headerType === 'function') && + (column.headerType === 'image' || column.headerType === 'video' || typeof column.headerType === 'function') && (column as IImageDimension).imageAutoSizing ) { return true; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 3ea02f3da7..f325b20d09 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -39,6 +39,27 @@ function releaseVideoResource(video: HTMLVideoElement): void { } } +function releaseGraphicVideoResource(graphic: any): void { + const source = graphic?.attribute?.image; + if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) { + releaseVideoResource(source); + } +} + +function removeMediaChildren(cellGroup: Group): void { + const mediaChildren: any[] = []; + cellGroup.forEachChildren((child: any) => { + if (child.name === 'image' || child.name === 'play-icon') { + mediaChildren.push(child); + } + }); + mediaChildren.forEach(child => { + releaseGraphicVideoResource(child); + cellGroup.removeChild(child); + child.release?.(); + }); +} + function getVideoFirstFrameTimeout(table: BaseTableAPI): number { const timeout = table.options.customConfig?.videoFirstFrameTimeout; return typeof timeout === 'number' && timeout >= 0 ? timeout : 8000; @@ -248,6 +269,7 @@ export function createVideoCellGroup( // video const value = table.getCellValue(col, row); + removeMediaChildren(cellGroup); if (isAudioUrl(value)) { const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); const availableHeight = Math.max(1, height - padding[0] - padding[2]); diff --git a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts index 4b8647020d..e5fca4629e 100644 --- a/packages/vtable/src/ts-types/pivot-table/dimension/index.ts +++ b/packages/vtable/src/ts-types/pivot-table/dimension/index.ts @@ -5,7 +5,7 @@ import type { ITextDimension } from './multilinetext-dimension'; export type IDimension = IRowDimension | IColumnDimension; export type IRowDimension = ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension; -export type IColumnDimension = Omit< - ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension, - 'width' | 'minWidth' | 'maxWidth' +type OmitColumnWidth = T extends unknown ? Omit : never; +export type IColumnDimension = OmitColumnWidth< + ILinkDimension | IImageDimension | IAudioDimension | ITextDimension | ICompositeDimension >; From 31d41629ea1f7f2bc1a0a3e78ed116e28319dbfe Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 17:31:12 +0800 Subject: [PATCH 21/44] fix(vtable): avoid removing custom video cell icons Track video cell media nodes with an internal marker. Keep user icons named image or play-icon during reuse. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../cell-type/video-cell-first-frame.test.ts | 46 ++++++++- .../group-creater/cell-type/video-cell.ts | 96 +++++++++++++------ 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 9eddb375d7..499795816b 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -2,7 +2,7 @@ import { createVideoCellGroup } from '../../src/scenegraph/group-creater/cell-type/video-cell'; import { audioIconSvg } from '../../src/scenegraph/group-creater/cell-type/audio-cell'; import { updateImageCellContentWhileResize } from '../../src/scenegraph/group-creater/cell-type/image-cell'; -import { application, registerForVrender } from '../../src/vrender'; +import { application, createImage, registerForVrender } from '../../src/vrender'; import * as icons from '../../src/icons'; global.__VERSION__ = 'none'; @@ -140,6 +140,50 @@ describe('video cell first frame snapshot', () => { expect(image.attribute.height).toBe(32); }); + it('keeps custom icons named image or play-icon when reusing a video cell for audio', () => { + const { cellGroup, table } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.mp4'); + const customImageIcon = createImage({ x: 1, y: 1, width: 10, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 2, y: 2, width: 10, height: 10, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + + expect(children).toContain(customImageIcon); + expect(children).toContain(customPlayIcon); + expect(children.filter(child => child.name === 'image')).toHaveLength(2); + expect(children.filter(child => child.name === 'play-icon')).toHaveLength(1); + }); + it('uses a canvas snapshot and releases the video when enabled', () => { const { image, table, video } = createCell({ videoFirstFrameSnapshot: true, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index f325b20d09..26ad301d35 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -6,13 +6,7 @@ import { Group } from '../../graphic/group'; import { calcKeepAspectRatioSize } from '../../utils/keep-aspect-ratio'; import { Icon } from '../../graphic/icon'; import { calcStartPosition } from '../../utils/cell-pos'; -import { - _adjustWidthHeight, - getCellRange, - isDamagePic, - updateAutoSizingAndKeepAspectRatio, - updateImageDxDy -} from './image-cell'; +import { _adjustWidthHeight, getCellRange, isDamagePic, updateAutoSizingAndKeepAspectRatio } from './image-cell'; import { getFunctionalProp, getProp } from '../../utils/get-prop'; import { isValid } from '@visactor/vutils'; import type { BaseTableAPI } from '../../../ts-types/base-table'; @@ -24,6 +18,7 @@ import { isAudioUrl } from '../../../tools/media'; import { audioIconSvg } from './audio-cell'; const regedIcons = icons.get(); +const VIDEO_CELL_MEDIA_KEY = '__vtable_video_cell_media__'; function releaseVideoResource(video: HTMLVideoElement): void { try { @@ -46,10 +41,49 @@ function releaseGraphicVideoResource(graphic: any): void { } } +function markVideoCellMedia(graphic: T): T { + (graphic as any)[VIDEO_CELL_MEDIA_KEY] = true; + return graphic; +} + +function isVideoCellMedia(graphic: any): boolean { + return graphic?.[VIDEO_CELL_MEDIA_KEY] === true; +} + +function getVideoCellMediaImage(cellGroup: Group): IImage | undefined { + let image: IImage | undefined; + cellGroup.forEachChildren((child: any) => { + if (isVideoCellMedia(child) && child.name === 'image') { + image = child; + return true; + } + return false; + }); + return image; +} + +function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number, endRow: number, table: BaseTableAPI) { + for (let col = startCol; col <= endCol; col++) { + for (let row = startRow; row <= endRow; row++) { + const cellGroup = table.scenegraph.getCell(col, row); + if (cellGroup) { + cellGroup.forEachChildren((child: any) => { + if (isVideoCellMedia(child)) { + child.setAttributes({ + dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), + dy: -table.getRowsHeight(cellGroup.mergeStartRow, row - 1) + }); + } + }); + } + } + } +} + function removeMediaChildren(cellGroup: Group): void { const mediaChildren: any[] = []; cellGroup.forEachChildren((child: any) => { - if (child.name === 'image' || child.name === 'play-icon') { + if (isVideoCellMedia(child)) { mediaChildren.push(child); } }); @@ -285,14 +319,16 @@ export function createVideoCellGroup( textBaseline, padding ); - const image: IImage = createImage({ - x: pos.x, - y: pos.y, - width: iconSize, - height: iconSize, - image: audioIconSvg, - cursor: 'pointer' as Cursor - }); + const image: IImage = markVideoCellMedia( + createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }) + ); image.name = 'image'; image.keepAspectRatio = true; (image as any).isAudioIcon = true; @@ -322,7 +358,7 @@ export function createVideoCellGroup( clearVideoLoadTimer(); releaseVideoResource(video); }; - const isCurrentImage = (): boolean => cellGroup.getChildByName('image', true) === image; + const isCurrentImage = (): boolean => getVideoCellMediaImage(cellGroup) === image; const setVideoDamageImage = (): void => { const regedIcons = icons.get(); const damageIcon = regedIcons.video_damage_pic || regedIcons.damage_pic; @@ -365,7 +401,7 @@ export function createVideoCellGroup( }); if (isMerge) { - updateImageDxDy( + updateVideoMediaDxDy( cellGroup.mergeStartCol, cellGroup.mergeEndCol, cellGroup.mergeStartRow, @@ -449,7 +485,7 @@ export function createVideoCellGroup( } if (isMerge) { - updateImageDxDy( + updateVideoMediaDxDy( cellGroup.mergeStartCol, cellGroup.mergeEndCol, cellGroup.mergeStartRow, @@ -474,8 +510,7 @@ export function createVideoCellGroup( top + (height > image.attribute.height ? image.attribute.y - top + image.attribute.height / 2 : height / 2); // get dx dy of image graphic for merge cell - const imageGraphic = cellGroup.getChildByName('image', true); - const { dx, dy } = imageGraphic.attribute; + const { dx, dy } = image.attribute; const playIcon: Icon = new Icon({ x: anchorX - iconSize / 2, @@ -487,6 +522,7 @@ export function createVideoCellGroup( dx, dy }); + markVideoCellMedia(playIcon); playIcon.name = 'play-icon'; cellGroup.appendChild(playIcon); if (shouldSnapshot && snapshotVideoFirstFrame(video, image, table)) { @@ -498,14 +534,16 @@ export function createVideoCellGroup( video.addEventListener('error', handleVideoLoadFail); video.addEventListener('abort', handleVideoLoadFail); - const image: IImage = createImage({ - x: padding[3], - y: padding[0], - width: Math.max(1, width - padding[1] - padding[3]), - height: Math.max(1, height - padding[2] - padding[0]), - image: video as any, - cursor: 'pointer' as Cursor - }); + const image: IImage = markVideoCellMedia( + createImage({ + x: padding[3], + y: padding[0], + width: Math.max(1, width - padding[1] - padding[3]), + height: Math.max(1, height - padding[2] - padding[0]), + image: video as any, + cursor: 'pointer' as Cursor + }) + ); image.name = 'image'; image.keepAspectRatio = keepAspectRatio; image.textAlign = textAlign; From d0f7eeb2e63e3f0b565572a2664c96a218829c22 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 19:23:56 +0800 Subject: [PATCH 22/44] fix(vtable): keep headers after setRecords at scroll edge Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../listTable-set-records-scroll.test.ts | 66 +++++++++ .../issue-5278-set-records-header-blank.ts | 137 ++++++++++++++++++ packages/vtable/examples/menu.ts | 4 + packages/vtable/src/ListTable.ts | 10 ++ 4 files changed, 217 insertions(+) create mode 100644 packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts create mode 100644 packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts diff --git a/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts b/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts new file mode 100644 index 0000000000..92ff44412a --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-set-records-scroll.test.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv } from '../dom'; + +global.__VERSION__ = 'none'; + +describe('listTable setRecords with horizontal scroll', () => { + test('keeps visible headers after setRecords clears data at right scroll edge', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '900px'; + containerDom.style.height = '420px'; + + const columns = Array.from({ length: 60 }, (_, index) => ({ + field: `field${index}`, + title: `Field ${index}`, + width: 120 + })); + const records = Array.from({ length: 6 }, (_, rowIndex) => { + const record: Record = {}; + for (let colIndex = 0; colIndex < columns.length; colIndex++) { + record[`field${colIndex}`] = rowIndex * columns.length + colIndex; + } + return record; + }); + + const table = new ListTable(containerDom, { + records, + frozenRowCount: 3, + bottomFrozenRowCount: 1, + frozenColCount: 2, + rightFrozenColCount: 2, + columns, + widthMode: 'standard', + maintainedColumnCount: 20, + rowSeriesNumber: { + width: 50, + format: () => '', + cellType: 'checkbox', + headerType: 'checkbox' + } + }); + + table.setScrollLeft(100000); + expect(table.getScrollLeft()).toBeGreaterThan(0); + + table.setRecords([]); + + const visibleHeader = table.getCellAtRelativePosition(500, 20); + const headerCell = table.scenegraph.getCell(visibleHeader.col, visibleHeader.row); + const frozenHeader = table.getCellAtRelativePosition(80, 20); + const frozenHeaderCell = table.scenegraph.getCell(frozenHeader.col, frozenHeader.row); + + expect(table.records.length).toBe(0); + expect(visibleHeader.row).toBe(0); + expect(table.getCellValue(visibleHeader.col, visibleHeader.row)).toBeTruthy(); + expect(headerCell.children[0].attribute.text).toBe(table.getCellValue(visibleHeader.col, visibleHeader.row)); + expect(frozenHeader.col).toBe(1); + expect(frozenHeader.row).toBe(0); + expect(frozenHeaderCell.children[0].attribute.text).toBe('Field 0'); + expect(table.scenegraph.cornerHeaderGroup.attribute.width).toBe(table.getFrozenColsWidth()); + expect(table.scenegraph.colHeaderGroup.attribute.x + table.getScrollLeft()).toBe(table.getFrozenColsWidth()); + + table.release(); + }); +}); diff --git a/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts b/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts new file mode 100644 index 0000000000..3e7f827f36 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5278-set-records-header-blank.ts @@ -0,0 +1,137 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const TOOLBAR_ID = 'issue5278Toolbar'; +const STATUS_ID = 'issue5278Status'; + +const baseColumns = [ + 'Order ID', + 'Customer ID', + 'Product Name', + 'Category', + 'Sub-Category', + 'Region', + 'City', + 'Order Date', + 'Quantity', + 'Sales', + 'Profit' +]; + +const records = Array.from({ length: 6 }, (_, index) => { + const record: Record = { + 'Order ID': `CA-2026-${1000 + index}`, + 'Customer ID': `C-${100 + index}`, + 'Product Name': ['Bookcase', 'Chair', 'Table', 'Phone', 'Binder', 'Storage'][index], + Category: ['Furniture', 'Technology', 'Office Supplies'][index % 3], + 'Sub-Category': ['Bookcases', 'Chairs', 'Tables', 'Phones', 'Binders', 'Storage'][index], + Region: ['East', 'West', 'Central'][index % 3], + City: ['New York', 'Seattle', 'Chicago', 'Boston', 'Austin', 'Denver'][index], + 'Order Date': `2026-01-${String(index + 1).padStart(2, '0')}`, + Quantity: index + 1, + Sales: 100 + index * 20, + Profit: 10 + index * 5 + }; + + for (let col = 0; col < 300; col++) { + record[`Sales${col}`] = 1000 + index * 300 + col; + } + + return record; +}); + +const columns: VTable.ColumnsDefine = [ + ...baseColumns.map(field => ({ + field, + title: field, + width: 'auto' + })), + ...Array.from({ length: 300 }, (_, index) => ({ + field: `Sales${index}`, + title: `Sales${index}`, + width: 120 + })) +]; + +const setStatus = (message: string) => { + const status = document.getElementById(STATUS_ID); + if (status) { + status.textContent = message; + } +}; + +export function createTable() { + document.getElementById(TOOLBAR_ID)?.remove(); + + const container = document.getElementById(CONTAINER_ID)!; + container.style.width = '900px'; + container.style.height = '420px'; + + const toolbar = document.createElement('div'); + toolbar.id = TOOLBAR_ID; + toolbar.style.cssText = ['display: flex', 'gap: 8px', 'align-items: center', 'height: 40px', 'font-size: 12px'].join( + ';' + ); + + const runButton = document.createElement('button'); + runButton.id = 'issue5278Run'; + runButton.textContent = 'scroll right + setRecords([])'; + const resetButton = document.createElement('button'); + resetButton.id = 'issue5278Reset'; + resetButton.textContent = 'reset records'; + const status = document.createElement('strong'); + status.id = STATUS_ID; + + toolbar.append(runButton, resetButton, status); + container.before(toolbar); + + const tableInstance = new VTable.ListTable(container, { + records: records.slice(), + frozenRowCount: 3, + bottomFrozenRowCount: 1, + frozenColCount: 2, + rightFrozenColCount: 2, + columns, + widthMode: 'standard', + rowSeriesNumber: { + width: 50, + format: () => '', + cellType: 'checkbox', + headerType: 'checkbox' + } + }); + + const scrollToRight = () => { + tableInstance.setScrollLeft(100000); + }; + + const run = () => { + scrollToRight(); + tableInstance.setRecords([]); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }; + + const reset = () => { + tableInstance.setRecords(records.slice()); + scrollToRight(); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }; + + runButton.addEventListener('click', run); + resetButton.addEventListener('click', reset); + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + document.getElementById(TOOLBAR_ID)?.remove(); + release(); + }; + + (window as any).tableInstance = tableInstance; + (window as any).issue5278Run = run; + (window as any).issue5278Reset = reset; + + requestAnimationFrame(() => { + scrollToRight(); + setStatus(`records=${tableInstance.records.length}, scrollLeft=${tableInstance.getScrollLeft()}`); + }); +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5ea0fb6253..61849c3e30 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -62,6 +62,10 @@ export const menus = [ path: 'debug', name: 'issue-5213-row-series-number-aggregation' }, + { + path: 'debug', + name: 'issue-5278-set-records-header-blank' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 4c56dd313c..11ce44f6c0 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1626,6 +1626,8 @@ export class ListTable extends BaseTable implements ListTableAPI { } const time = typeof window !== 'undefined' ? window.performance.now() : 0; const oldHoverState = { col: this.stateManager.hover.cellPos.col, row: this.stateManager.hover.cellPos.row }; + const oldScrollLeft = this.stateManager.scroll.horizontalBarPos; + const oldScrollTop = this.stateManager.scroll.verticalBarPos; // 清空单元格内容 this.scenegraph.clearCells(); @@ -1671,6 +1673,8 @@ export class ListTable extends BaseTable implements ListTableAPI { // this.internalProps.frozenColCount = this.options.frozenColCount || this.rowHeaderLevelCount; // 生成单元格场景树 this.clearCellStyleCache(); + this.stateManager.scroll.horizontalBarPos = 0; + this.stateManager.scroll.verticalBarPos = 0; this.scenegraph.createSceneGraph(); this.stateManager.updateHoverPos(oldHoverState.col, oldHoverState.row); @@ -1691,6 +1695,12 @@ export class ListTable extends BaseTable implements ListTableAPI { }); this.scenegraph.resize(); + if (oldScrollLeft) { + this.stateManager.setScrollLeft(oldScrollLeft, undefined, false); + } + if (oldScrollTop) { + this.stateManager.setScrollTop(oldScrollTop, undefined, false); + } if (this.options.emptyTip) { if (this.internalProps.emptyTip) { From d267c6b27a025f4417eeda648a88617fc84865e0 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 19:25:31 +0800 Subject: [PATCH 23/44] fix(vtable): avoid resizing custom media icons Co-Authored-By: Claude Sonnet 4.6 --- .../group-creater/cell-type/image-cell.ts | 35 ++++++++++---- .../cell-type/media-cell-helper.ts | 46 +++++++++++++++++++ .../group-creater/cell-type/video-cell.ts | 31 +++---------- 3 files changed, 78 insertions(+), 34 deletions(-) create mode 100644 packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 7b03d4fb47..cddf5ff192 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -13,6 +13,23 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { getVideoCellMediaChildren, getVideoCellMediaImage, getVideoCellMediaPlayIcon } from './media-cell-helper'; + +function hasVideoCellMedia(cellGroup: Group): boolean { + return getVideoCellMediaChildren(cellGroup).length > 0; +} + +function getResizeImage(cellGroup: Group): Image | undefined { + return hasVideoCellMedia(cellGroup) + ? (getVideoCellMediaImage(cellGroup) as Image) + : (cellGroup.getChildByName('image') as Image); +} + +function getResizePlayIcon(cellGroup: Group): Image | undefined { + return hasVideoCellMedia(cellGroup) + ? (getVideoCellMediaPlayIcon(cellGroup) as Image) + : (cellGroup.getChildByName('play-icon') as Image); +} export function createImageCellGroup( columnGroup: Group, @@ -313,7 +330,7 @@ export function updateImageCellContentWhileResize( deltaY: number, table: BaseTableAPI ) { - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); if (!image) { return; } @@ -363,7 +380,7 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); image?.setAttributes({ x: pos.x, y: pos.y, @@ -399,7 +416,7 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); image?.setAttributes({ x: pos.x, y: pos.y, @@ -412,7 +429,7 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const image = cellGroup.getChildByName('image') as Image; + const image = getResizeImage(cellGroup); image?.setAttributes({ x: leftIconWidth + padding[3], y: padding[0], @@ -426,7 +443,7 @@ export function updateImageCellContentWhileResize( } // update video play icon - const playIcon = cellGroup.getChildByName('play-icon'); + const playIcon = getResizePlayIcon(cellGroup); if (playIcon) { const left = 0; const top = 0; @@ -442,8 +459,8 @@ export function updateImageCellContentWhileResize( for (let col = colStart; col <= colEnd; col++) { for (let row = rowStart; row <= rowEnd; row++) { const cellGroup = table.scenegraph.getCell(col, row); - const playIcon = cellGroup.getChildByName('play-icon') as Image; - playIcon.setAttributes({ + const playIcon = getResizePlayIcon(cellGroup); + playIcon?.setAttributes({ x: anchorX - iconSize / 2, y: anchorY - iconSize / 2, width: iconSize, @@ -520,14 +537,14 @@ export function updateImageDxDy( for (let row = startRow; row <= endRow; row++) { const cellGroup = table.scenegraph.getCell(col, row); if (cellGroup) { - const image = cellGroup.getChildByName('image'); + const image = getResizeImage(cellGroup); if (image) { image.setAttributes({ dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), dy: -table.getRowsHeight(cellGroup.mergeStartRow, row - 1) }); } - const playIcon = cellGroup.getChildByName('play-icon'); + const playIcon = getResizePlayIcon(cellGroup); if (playIcon) { playIcon.setAttributes({ dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts new file mode 100644 index 0000000000..2b4391688a --- /dev/null +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts @@ -0,0 +1,46 @@ +import type { IImage } from '@src/vrender'; +import type { Group } from '../../graphic/group'; + +export const VIDEO_CELL_MEDIA_KEY = '__vtable_video_cell_media__'; +export const VIDEO_CELL_MEDIA_KIND_KEY = '__vtable_video_cell_media_kind__'; +export type VideoCellMediaKind = 'image' | 'play-icon'; + +export function markVideoCellMedia(graphic: T, kind: VideoCellMediaKind): T { + (graphic as any)[VIDEO_CELL_MEDIA_KEY] = true; + (graphic as any)[VIDEO_CELL_MEDIA_KIND_KEY] = kind; + return graphic; +} + +export function isVideoCellMedia(graphic: any): boolean { + return graphic?.[VIDEO_CELL_MEDIA_KEY] === true; +} + +export function getVideoCellMediaChildren(cellGroup: Group): any[] { + const mediaChildren: any[] = []; + cellGroup.forEachChildren((child: any) => { + if (isVideoCellMedia(child)) { + mediaChildren.push(child); + } + }); + return mediaChildren; +} + +function getVideoCellMediaByKind(cellGroup: Group, kind: VideoCellMediaKind): any { + let graphic: any; + cellGroup.forEachChildren((child: any) => { + if (isVideoCellMedia(child) && child[VIDEO_CELL_MEDIA_KIND_KEY] === kind) { + graphic = child; + return true; + } + return false; + }); + return graphic; +} + +export function getVideoCellMediaImage(cellGroup: Group): IImage | undefined { + return getVideoCellMediaByKind(cellGroup, 'image'); +} + +export function getVideoCellMediaPlayIcon(cellGroup: Group): any { + return getVideoCellMediaByKind(cellGroup, 'play-icon'); +} diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 26ad301d35..a8765c4c3c 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -16,9 +16,9 @@ import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; import { isAudioUrl } from '../../../tools/media'; import { audioIconSvg } from './audio-cell'; +import { getVideoCellMediaImage, isVideoCellMedia, markVideoCellMedia } from './media-cell-helper'; const regedIcons = icons.get(); -const VIDEO_CELL_MEDIA_KEY = '__vtable_video_cell_media__'; function releaseVideoResource(video: HTMLVideoElement): void { try { @@ -41,27 +41,6 @@ function releaseGraphicVideoResource(graphic: any): void { } } -function markVideoCellMedia(graphic: T): T { - (graphic as any)[VIDEO_CELL_MEDIA_KEY] = true; - return graphic; -} - -function isVideoCellMedia(graphic: any): boolean { - return graphic?.[VIDEO_CELL_MEDIA_KEY] === true; -} - -function getVideoCellMediaImage(cellGroup: Group): IImage | undefined { - let image: IImage | undefined; - cellGroup.forEachChildren((child: any) => { - if (isVideoCellMedia(child) && child.name === 'image') { - image = child; - return true; - } - return false; - }); - return image; -} - function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number, endRow: number, table: BaseTableAPI) { for (let col = startCol; col <= endCol; col++) { for (let row = startRow; row <= endRow; row++) { @@ -327,7 +306,8 @@ export function createVideoCellGroup( height: iconSize, image: audioIconSvg, cursor: 'pointer' as Cursor - }) + }), + 'image' ); image.name = 'image'; image.keepAspectRatio = true; @@ -522,7 +502,7 @@ export function createVideoCellGroup( dx, dy }); - markVideoCellMedia(playIcon); + markVideoCellMedia(playIcon, 'play-icon'); playIcon.name = 'play-icon'; cellGroup.appendChild(playIcon); if (shouldSnapshot && snapshotVideoFirstFrame(video, image, table)) { @@ -542,7 +522,8 @@ export function createVideoCellGroup( height: Math.max(1, height - padding[2] - padding[0]), image: video as any, cursor: 'pointer' as Cursor - }) + }), + 'image' ); image.name = 'image'; image.keepAspectRatio = keepAspectRatio; From 2805ee70ffa7c30252a67049657a9ab903f710ce Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 17 Aug 2026 19:53:09 +0800 Subject: [PATCH 24/44] fix(vtable): mark all media cell nodes Co-Authored-By: Claude Sonnet 4.6 --- .../cell-type/video-cell-first-frame.test.ts | 123 +++++++++++++++++- .../group-creater/cell-type/audio-cell.ts | 20 +-- .../group-creater/cell-type/image-cell.ts | 33 +++-- .../cell-type/media-cell-helper.ts | 32 ++--- .../group-creater/cell-type/video-cell.ts | 14 +- 5 files changed, 173 insertions(+), 49 deletions(-) diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 499795816b..1f28650ab7 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -1,7 +1,9 @@ // @ts-nocheck import { createVideoCellGroup } from '../../src/scenegraph/group-creater/cell-type/video-cell'; -import { audioIconSvg } from '../../src/scenegraph/group-creater/cell-type/audio-cell'; +import { audioIconSvg, createAudioCellGroup } from '../../src/scenegraph/group-creater/cell-type/audio-cell'; +import { createImageCellGroup } from '../../src/scenegraph/group-creater/cell-type/image-cell'; import { updateImageCellContentWhileResize } from '../../src/scenegraph/group-creater/cell-type/image-cell'; +import { Group } from '../../src/scenegraph/graphic/group'; import { application, createImage, registerForVrender } from '../../src/vrender'; import * as icons from '../../src/icons'; @@ -184,6 +186,125 @@ describe('video cell first frame snapshot', () => { expect(children.filter(child => child.name === 'play-icon')).toHaveLength(1); }); + it('resizes an image cell media node without touching custom icons with media names', () => { + const table = createTable(undefined, ''); + const cellGroup = new Group({ + x: 0, + y: 0, + width: 200, + height: 120 + }); + cellGroup.role = 'cell'; + cellGroup.col = 0; + cellGroup.row = 0; + const customImageIcon = createImage({ x: 7, y: 8, width: 9, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 11, y: 12, width: 13, height: 14, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + table.scenegraph.getCell.mockReturnValue(cellGroup); + + createImageCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + const mediaImage = children.find(child => child.name === 'image' && child !== customImageIcon); + mediaImage.resources.set(mediaImage.attribute.image, { + state: 'success', + data: { + width: 20, + height: 10 + } + }); + cellGroup.setAttribute('width', 260); + updateImageCellContentWhileResize(cellGroup, 0, 0, 60, 0, table); + + expect(mediaImage.attribute.width).toBe(260); + expect(mediaImage.attribute.height).toBe(120); + expect(customImageIcon.attribute).toMatchObject({ x: 7, y: 8, width: 9, height: 10 }); + expect(customPlayIcon.attribute).toMatchObject({ x: 11, y: 12, width: 13, height: 14 }); + }); + + it('resizes an audio cell media node without touching custom icons with media names', () => { + const table = createTable(undefined, 'https://example.com/audio.mp3'); + const cellGroup = new Group({ + x: 0, + y: 0, + width: 200, + height: 120 + }); + cellGroup.role = 'cell'; + cellGroup.col = 0; + cellGroup.row = 0; + const customImageIcon = createImage({ x: 7, y: 8, width: 9, height: 10, image: audioIconSvg }); + customImageIcon.name = 'image'; + const customPlayIcon = createImage({ x: 11, y: 12, width: 13, height: 14, image: audioIconSvg }); + customPlayIcon.name = 'play-icon'; + cellGroup.appendChild(customImageIcon); + cellGroup.appendChild(customPlayIcon); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + table.scenegraph.getCell.mockReturnValue(cellGroup); + + createAudioCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + const children = []; + cellGroup.forEachChildren(child => { + children.push(child); + }); + const mediaImage = children.find(child => child.name === 'image' && child !== customImageIcon); + cellGroup.setAttribute('width', 260); + updateImageCellContentWhileResize(cellGroup, 0, 0, 60, 0, table); + + expect(mediaImage.attribute.width).toBe(32); + expect(mediaImage.attribute.height).toBe(32); + expect(customImageIcon.attribute).toMatchObject({ x: 7, y: 8, width: 9, height: 10 }); + expect(customPlayIcon.attribute).toMatchObject({ x: 11, y: 12, width: 13, height: 14 }); + }); + it('uses a canvas snapshot and releases the video when enabled', () => { const { image, table, video } = createCell({ videoFirstFrameSnapshot: true, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts index 8fdf6f10b2..0203b7ac5b 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -10,6 +10,7 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { getQuadProps } from '../../utils/padding'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; +import { markCellMedia } from './media-cell-helper'; export const audioIconSvg = ''; @@ -152,14 +153,17 @@ export function createAudioCellGroup( textBaseline, padding ); - const image: IImage = createImage({ - x: pos.x, - y: pos.y, - width: iconSize, - height: iconSize, - image: audioIconSvg, - cursor: 'pointer' as Cursor - }); + const image: IImage = markCellMedia( + createImage({ + x: pos.x, + y: pos.y, + width: iconSize, + height: iconSize, + image: audioIconSvg, + cursor: 'pointer' as Cursor + }), + 'image' + ); image.name = 'image'; image.keepAspectRatio = true; (image as any).isAudioIcon = true; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index cddf5ff192..3b0d45d31e 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -13,22 +13,18 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; -import { getVideoCellMediaChildren, getVideoCellMediaImage, getVideoCellMediaPlayIcon } from './media-cell-helper'; +import { getCellMediaChildren, getCellMediaImage, getCellMediaPlayIcon, markCellMedia } from './media-cell-helper'; -function hasVideoCellMedia(cellGroup: Group): boolean { - return getVideoCellMediaChildren(cellGroup).length > 0; +function hasCellMedia(cellGroup: Group): boolean { + return getCellMediaChildren(cellGroup).length > 0; } function getResizeImage(cellGroup: Group): Image | undefined { - return hasVideoCellMedia(cellGroup) - ? (getVideoCellMediaImage(cellGroup) as Image) - : (cellGroup.getChildByName('image') as Image); + return hasCellMedia(cellGroup) ? (getCellMediaImage(cellGroup) as Image) : undefined; } function getResizePlayIcon(cellGroup: Group): Image | undefined { - return hasVideoCellMedia(cellGroup) - ? (getVideoCellMediaPlayIcon(cellGroup) as Image) - : (cellGroup.getChildByName('play-icon') as Image); + return hasCellMedia(cellGroup) ? (getCellMediaPlayIcon(cellGroup) as Image) : undefined; } export function createImageCellGroup( @@ -169,14 +165,17 @@ export function createImageCellGroup( // image const value = table.getCellValue(col, row); - const image: IImage = createImage({ - x: padding[3], - y: padding[0], - width: width - padding[1] - padding[3] - iconWidth, - height: height - padding[0] - padding[2], - image: value, - cursor: 'pointer' as Cursor - }); + const image: IImage = markCellMedia( + createImage({ + x: padding[3], + y: padding[0], + width: width - padding[1] - padding[3] - iconWidth, + height: height - padding[0] - padding[2], + image: value, + cursor: 'pointer' as Cursor + }), + 'image' + ); image.name = 'image'; image.keepAspectRatio = keepAspectRatio; image.textAlign = textAlign; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts index 2b4391688a..5422948b7c 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts @@ -1,34 +1,34 @@ import type { IImage } from '@src/vrender'; import type { Group } from '../../graphic/group'; -export const VIDEO_CELL_MEDIA_KEY = '__vtable_video_cell_media__'; -export const VIDEO_CELL_MEDIA_KIND_KEY = '__vtable_video_cell_media_kind__'; -export type VideoCellMediaKind = 'image' | 'play-icon'; +export const CELL_MEDIA_KEY = '__vtable_cell_media__'; +export const CELL_MEDIA_KIND_KEY = '__vtable_cell_media_kind__'; +export type CellMediaKind = 'image' | 'play-icon'; -export function markVideoCellMedia(graphic: T, kind: VideoCellMediaKind): T { - (graphic as any)[VIDEO_CELL_MEDIA_KEY] = true; - (graphic as any)[VIDEO_CELL_MEDIA_KIND_KEY] = kind; +export function markCellMedia(graphic: T, kind: CellMediaKind): T { + (graphic as any)[CELL_MEDIA_KEY] = true; + (graphic as any)[CELL_MEDIA_KIND_KEY] = kind; return graphic; } -export function isVideoCellMedia(graphic: any): boolean { - return graphic?.[VIDEO_CELL_MEDIA_KEY] === true; +export function isCellMedia(graphic: any): boolean { + return graphic?.[CELL_MEDIA_KEY] === true; } -export function getVideoCellMediaChildren(cellGroup: Group): any[] { +export function getCellMediaChildren(cellGroup: Group): any[] { const mediaChildren: any[] = []; cellGroup.forEachChildren((child: any) => { - if (isVideoCellMedia(child)) { + if (isCellMedia(child)) { mediaChildren.push(child); } }); return mediaChildren; } -function getVideoCellMediaByKind(cellGroup: Group, kind: VideoCellMediaKind): any { +function getCellMediaByKind(cellGroup: Group, kind: CellMediaKind): any { let graphic: any; cellGroup.forEachChildren((child: any) => { - if (isVideoCellMedia(child) && child[VIDEO_CELL_MEDIA_KIND_KEY] === kind) { + if (isCellMedia(child) && child[CELL_MEDIA_KIND_KEY] === kind) { graphic = child; return true; } @@ -37,10 +37,10 @@ function getVideoCellMediaByKind(cellGroup: Group, kind: VideoCellMediaKind): an return graphic; } -export function getVideoCellMediaImage(cellGroup: Group): IImage | undefined { - return getVideoCellMediaByKind(cellGroup, 'image'); +export function getCellMediaImage(cellGroup: Group): IImage | undefined { + return getCellMediaByKind(cellGroup, 'image'); } -export function getVideoCellMediaPlayIcon(cellGroup: Group): any { - return getVideoCellMediaByKind(cellGroup, 'play-icon'); +export function getCellMediaPlayIcon(cellGroup: Group): any { + return getCellMediaByKind(cellGroup, 'play-icon'); } diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index a8765c4c3c..2c481d6222 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -16,7 +16,7 @@ import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; import { isAudioUrl } from '../../../tools/media'; import { audioIconSvg } from './audio-cell'; -import { getVideoCellMediaImage, isVideoCellMedia, markVideoCellMedia } from './media-cell-helper'; +import { getCellMediaImage, isCellMedia, markCellMedia } from './media-cell-helper'; const regedIcons = icons.get(); @@ -47,7 +47,7 @@ function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number const cellGroup = table.scenegraph.getCell(col, row); if (cellGroup) { cellGroup.forEachChildren((child: any) => { - if (isVideoCellMedia(child)) { + if (isCellMedia(child)) { child.setAttributes({ dx: -table.getColsWidth(cellGroup.mergeStartCol, col - 1), dy: -table.getRowsHeight(cellGroup.mergeStartRow, row - 1) @@ -62,7 +62,7 @@ function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number function removeMediaChildren(cellGroup: Group): void { const mediaChildren: any[] = []; cellGroup.forEachChildren((child: any) => { - if (isVideoCellMedia(child)) { + if (isCellMedia(child)) { mediaChildren.push(child); } }); @@ -298,7 +298,7 @@ export function createVideoCellGroup( textBaseline, padding ); - const image: IImage = markVideoCellMedia( + const image: IImage = markCellMedia( createImage({ x: pos.x, y: pos.y, @@ -338,7 +338,7 @@ export function createVideoCellGroup( clearVideoLoadTimer(); releaseVideoResource(video); }; - const isCurrentImage = (): boolean => getVideoCellMediaImage(cellGroup) === image; + const isCurrentImage = (): boolean => getCellMediaImage(cellGroup) === image; const setVideoDamageImage = (): void => { const regedIcons = icons.get(); const damageIcon = regedIcons.video_damage_pic || regedIcons.damage_pic; @@ -502,7 +502,7 @@ export function createVideoCellGroup( dx, dy }); - markVideoCellMedia(playIcon, 'play-icon'); + markCellMedia(playIcon, 'play-icon'); playIcon.name = 'play-icon'; cellGroup.appendChild(playIcon); if (shouldSnapshot && snapshotVideoFirstFrame(video, image, table)) { @@ -514,7 +514,7 @@ export function createVideoCellGroup( video.addEventListener('error', handleVideoLoadFail); video.addEventListener('abort', handleVideoLoadFail); - const image: IImage = markVideoCellMedia( + const image: IImage = markCellMedia( createImage({ x: padding[3], y: padding[0], From b09691abef0834931fc3e4233779580cea984857 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 10:55:24 +0800 Subject: [PATCH 25/44] fix(vtable): align frame border with short frozen rows Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../options/listTable-api-with-frozen.test.ts | 35 ++++ .../debug/issue-5277-frozen-row-border.ts | 47 +++++ packages/vtable/examples/menu.ts | 4 + packages/vtable/src/scenegraph/scenegraph.ts | 168 ++++++++++-------- 4 files changed, 182 insertions(+), 72 deletions(-) create mode 100644 packages/vtable/examples/debug/issue-5277-frozen-row-border.ts diff --git a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts index c6081a0e07..29b93d49ca 100644 --- a/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts +++ b/packages/vtable/__tests__/options/listTable-api-with-frozen.test.ts @@ -305,6 +305,41 @@ describe('listTable init test', () => { frozenTable.release(); }); + test('listTable frame border should match short content with frozen rows', () => { + const optionWithShortFrozenRows = { + ...option, + columns: columns.slice(0, 5).map(column => ({ + ...column, + width: 180 + })), + frozenColCount: 0, + rightFrozenColCount: 0, + frozenRowCount: 3, + bottomFrozenRowCount: 1, + container: createDiv(), + records: records.slice(0, 6), + widthMode: 'standard', + theme: { + frameStyle: { + borderLineWidth: [1, 1, 1, 1], + borderColor: 'red' + } + } + }; + optionWithShortFrozenRows.container.style.position = 'relative'; + optionWithShortFrozenRows.container.style.width = '900px'; + optionWithShortFrozenRows.container.style.height = '500px'; + + const frozenTable = new ListTable(optionWithShortFrozenRows); + const { scenegraph } = frozenTable; + const contentBottom = scenegraph.bottomFrozenGroup.attribute.y + scenegraph.bottomFrozenGroup.attribute.height; + + expect(scenegraph.tableGroup.attribute.height).toBe(contentBottom); + expect(scenegraph.tableGroup.border.attribute.height).toBe(contentBottom + 1); + + frozenTable.release(); + }); + test('listTable should support decreasing rightFrozenColCount by setter with row series number', () => { const optionWithRightFrozen = { ...option, diff --git a/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts b/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts new file mode 100644 index 0000000000..96edd50209 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5277-frozen-row-border.ts @@ -0,0 +1,47 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const generateRecords = (count: number) => + Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Name ${index + 1}`, + city: ['Beijing', 'Shanghai', 'Shenzhen', 'Hangzhou', 'Chengdu', 'Wuhan'][index % 6], + sales: 1000 + index * 100, + profit: 120 + index * 10 + })); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + container.style.width = '900px'; + container.style.height = '500px'; + + const columns: VTable.ColumnsDefine = [ + { field: 'id', title: 'ID', width: 120 }, + { field: 'name', title: 'Name', width: 180 }, + { field: 'city', title: 'City', width: 180 }, + { field: 'sales', title: 'Sales', width: 180 }, + { field: 'profit', title: 'Profit', width: 180 } + ]; + + const option: VTable.ListTableConstructorOptions = { + container, + records: generateRecords(6), + frozenRowCount: 3, + bottomFrozenRowCount: 1, + columns, + widthMode: 'standard', + theme: { + frameStyle: { + borderLineWidth: [1, 1, 1, 1], + borderColor: 'red' + } + } + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5ea0fb6253..8adc5ed85c 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -62,6 +62,10 @@ export const menus = [ path: 'debug', name: 'issue-5213-row-series-number-aggregation' }, + { + path: 'debug', + name: 'issue-5277-frozen-row-border' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index f3dc9a32a8..8f4d009e0d 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -1363,78 +1363,6 @@ export class Scenegraph { height: tableHeight } as any); - if (this.tableGroup.border) { - const rectAttributes = this.tableGroup.border?.attribute; - let borderTop; - let borderRight; - let borderBottom; - let borderLeft; - if ((rectAttributes as any)?.strokeArrayWidth) { - borderTop = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[0] - : (rectAttributes.lineWidth as number) ?? 0; - borderRight = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[1] - : (rectAttributes.lineWidth as number) ?? 0; - borderBottom = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[2] - : (rectAttributes.lineWidth as number) ?? 0; - borderLeft = (rectAttributes as any).strokeArrayWidth - ? (rectAttributes as any).strokeArrayWidth[3] - : (rectAttributes.lineWidth as number) ?? 0; - } else { - borderTop = (rectAttributes?.lineWidth as number) ?? 0; - borderRight = (rectAttributes?.lineWidth as number) ?? 0; - borderBottom = (rectAttributes?.lineWidth as number) ?? 0; - borderLeft = (rectAttributes?.lineWidth as number) ?? 0; - } - if (this.tableGroup.border.type === 'rect') { - if (this.table.theme.frameStyle?.innerBorder) { - this.tableGroup.border.setAttributes({ - x: this.table.tableX + borderLeft / 2, - y: this.table.tableY + borderTop / 2, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - } else { - this.tableGroup.border.setAttributes({ - x: this.table.tableX - borderLeft / 2, - y: this.table.tableY - borderTop / 2, - width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, - height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 - }); - } - } else if (this.tableGroup.border.type === 'group') { - if (this.table.theme.frameStyle?.innerBorder) { - this.tableGroup.border.setAttributes({ - x: this.table.tableX + borderLeft / 2, - y: this.table.tableY + borderTop / 2, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - (this.tableGroup.border.firstChild as IRect)?.setAttributes({ - x: 0, - y: 0, - width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, - height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 - }); - } else { - this.tableGroup.border.setAttributes({ - x: this.table.tableX - borderLeft / 2, - y: this.table.tableY - borderTop / 2, - width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, - height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 - }); - (this.tableGroup.border.firstChild as IRect)?.setAttributes({ - x: borderLeft / 2, - y: borderTop / 2, - width: this.tableGroup.attribute.width, - height: this.tableGroup.attribute.height - }); - } - } - } - const hasFrozenCols = this.table.frozenColCount > 0; const hasRightFrozenCols = this.table.rightFrozenColCount > 0; const hasFrozenRows = this.table.frozenRowCount > 0; @@ -1510,10 +1438,106 @@ export class Scenegraph { }); } + if (hasBottomFrozenRows && !this.table.containerFit?.height) { + const actualContentBottom = Math.max( + this.colHeaderGroup.attribute.y + this.colHeaderGroup.attribute.height, + this.cornerHeaderGroup.attribute.y + this.cornerHeaderGroup.attribute.height, + this.rightTopCornerGroup.attribute.y + this.rightTopCornerGroup.attribute.height, + this.rowHeaderGroup.attribute.y + this.rowHeaderGroup.attribute.height, + this.bodyGroup.attribute.y + this.bodyGroup.attribute.height, + this.rightFrozenGroup.attribute.y + this.rightFrozenGroup.attribute.height, + this.leftBottomCornerGroup.attribute.y + this.leftBottomCornerGroup.attribute.height, + this.bottomFrozenGroup.attribute.y + this.bottomFrozenGroup.attribute.height, + this.rightBottomCornerGroup.attribute.y + this.rightBottomCornerGroup.attribute.height + ); + + if (actualContentBottom > 0 && actualContentBottom < this.tableGroup.attribute.height) { + this.tableGroup.setAttribute('height', actualContentBottom); + } + } + + this.updateTableGroupBorder(); + // update dom container size this.updateDomContainer(); } + updateTableGroupBorder() { + if (!this.tableGroup.border) { + return; + } + + const rectAttributes = this.tableGroup.border?.attribute; + let borderTop; + let borderRight; + let borderBottom; + let borderLeft; + if ((rectAttributes as any)?.strokeArrayWidth) { + borderTop = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[0] + : (rectAttributes.lineWidth as number) ?? 0; + borderRight = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[1] + : (rectAttributes.lineWidth as number) ?? 0; + borderBottom = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[2] + : (rectAttributes.lineWidth as number) ?? 0; + borderLeft = (rectAttributes as any).strokeArrayWidth + ? (rectAttributes as any).strokeArrayWidth[3] + : (rectAttributes.lineWidth as number) ?? 0; + } else { + borderTop = (rectAttributes?.lineWidth as number) ?? 0; + borderRight = (rectAttributes?.lineWidth as number) ?? 0; + borderBottom = (rectAttributes?.lineWidth as number) ?? 0; + borderLeft = (rectAttributes?.lineWidth as number) ?? 0; + } + if (this.tableGroup.border.type === 'rect') { + if (this.table.theme.frameStyle?.innerBorder) { + this.tableGroup.border.setAttributes({ + x: this.table.tableX + borderLeft / 2, + y: this.table.tableY + borderTop / 2, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + } else { + this.tableGroup.border.setAttributes({ + x: this.table.tableX - borderLeft / 2, + y: this.table.tableY - borderTop / 2, + width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, + height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 + }); + } + } else if (this.tableGroup.border.type === 'group') { + if (this.table.theme.frameStyle?.innerBorder) { + this.tableGroup.border.setAttributes({ + x: this.table.tableX + borderLeft / 2, + y: this.table.tableY + borderTop / 2, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + (this.tableGroup.border.firstChild as IRect)?.setAttributes({ + x: 0, + y: 0, + width: this.tableGroup.attribute.width - borderLeft / 2 - borderRight / 2, + height: this.tableGroup.attribute.height - borderTop / 2 - borderBottom / 2 + }); + } else { + this.tableGroup.border.setAttributes({ + x: this.table.tableX - borderLeft / 2, + y: this.table.tableY - borderTop / 2, + width: this.tableGroup.attribute.width + borderLeft / 2 + borderRight / 2, + height: this.tableGroup.attribute.height + borderTop / 2 + borderBottom / 2 + }); + (this.tableGroup.border.firstChild as IRect)?.setAttributes({ + x: borderLeft / 2, + y: borderTop / 2, + width: this.tableGroup.attribute.width, + height: this.tableGroup.attribute.height + }); + } + } + } + updateRowHeight(row: number, detaY: number, skipTableHeightMap?: boolean) { detaY = Math.round(detaY); updateRowHeight(this, row, detaY, skipTableHeightMap); From f4c9fd5f0d3767058e60c6e5ad4f69133045285b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 10:58:17 +0800 Subject: [PATCH 26/44] fix(vtable): harden audio media cleanup Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/src/event/media-click.ts | 107 +++++++++++++++--- .../group-creater/cell-type/audio-cell.ts | 3 +- .../group-creater/cell-type/image-cell.ts | 9 +- .../cell-type/media-cell-helper.ts | 30 +++++ .../group-creater/cell-type/video-cell.ts | 45 ++------ .../src/ts-types/list-table/layout-map/api.ts | 1 + .../ts-types/list-table/layout-map/index.ts | 1 + 7 files changed, 139 insertions(+), 57 deletions(-) diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index 42bb31ab51..ed39476819 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -8,6 +8,17 @@ import type { IAudioColumnBodyDefine, IImageColumnBodyDefine } from '../ts-types import { getOrApply } from '../tools/helper'; import { isAudioUrl } from '../tools/media'; +const MEDIA_PREVIEW_MANAGER_KEY = '__vtable_media_preview_manager__'; + +interface MediaPreviewManager { + closePreview: (() => void) | null; + registered: boolean; + close: () => void; + open: (closePreview: () => void) => void; + release: () => void; + ensureRegistered: () => void; +} + function formatAudioTime(time: number) { if (!Number.isFinite(time) || time < 0) { return '0:00'; @@ -167,6 +178,7 @@ function createAudioPreview(cellValue: string) { }); audio.addEventListener('loadedmetadata', updateProgress); + audio.addEventListener('durationchange', updateProgress); audio.addEventListener('timeupdate', updateProgress); audio.addEventListener('play', () => { playButton.textContent = '❚❚'; @@ -196,16 +208,48 @@ function createAudioPreview(cellValue: string) { }; } +function createMediaPreviewManager(table: BaseTableAPI): MediaPreviewManager { + const manager: MediaPreviewManager = { + closePreview: null, + registered: false, + close() { + const closePreview = manager.closePreview; + manager.closePreview = null; + closePreview?.(); + }, + open(closePreview: () => void) { + manager.close(); + manager.closePreview = closePreview; + }, + release() { + manager.close(); + manager.registered = false; + }, + ensureRegistered() { + if (manager.registered) { + return; + } + table.addReleaseObj(manager); + manager.registered = true; + } + }; + return manager; +} + +function getMediaPreviewManager(table: BaseTableAPI): MediaPreviewManager { + const tableWithManager = table as any; + if (!tableWithManager[MEDIA_PREVIEW_MANAGER_KEY]) { + tableWithManager[MEDIA_PREVIEW_MANAGER_KEY] = createMediaPreviewManager(table); + } + const manager = tableWithManager[MEDIA_PREVIEW_MANAGER_KEY] as MediaPreviewManager; + manager.ensureRegistered(); + return manager; +} + export function bindMediaClick(table: BaseTableAPI): void { if (Env.mode === 'browser') { // table.hasMedia = false; - let closeAudioPreview: (() => void) | null = null; - table.addReleaseObj({ - release: () => { - closeAudioPreview?.(); - closeAudioPreview = null; - } - }); + getMediaPreviewManager(table); table.on(TABLE_EVENT_TYPE.CLICK_CELL, (e: MousePointerCellEvent) => { //如果目前是在某个icon上,如收起展开按钮 则不进行其他点击逻辑 @@ -295,6 +339,7 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + const previewManager = getMediaPreviewManager(table); // 开启蒙版 const overlay = document.createElement('div'); @@ -310,9 +355,16 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.overflow = 'hidden'; overlay.style.zIndex = '9999'; + const closeOverlay = () => { + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + previewManager.open(closeOverlay); + overlay.addEventListener('click', e => { if (e.target === overlay) { - document.body.removeChild(overlay); + previewManager.close(); } }); // overlay.addEventListener('pointermove', (e) => { @@ -338,6 +390,7 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + const previewManager = getMediaPreviewManager(table); // 开启蒙版 const overlay = document.createElement('div'); @@ -354,19 +407,17 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.zIndex = '9999'; const audioPreview = createAudioPreview(cellValue); - closeAudioPreview?.(); const closeOverlay = () => { - closeAudioPreview = null; audioPreview.destroy(); if (overlay.parentNode) { document.body.removeChild(overlay); } }; - closeAudioPreview = closeOverlay; + previewManager.open(closeOverlay); overlay.addEventListener('click', e => { if (e.target === overlay) { - closeOverlay(); + previewManager.close(); } }); @@ -380,6 +431,7 @@ export function bindMediaClick(table: BaseTableAPI): void { if (clickToPreview === false) { return; } + const previewManager = getMediaPreviewManager(table); // 开启蒙版 const overlay = document.createElement('div'); @@ -395,20 +447,39 @@ export function bindMediaClick(table: BaseTableAPI): void { overlay.style.overflow = 'hidden'; overlay.style.zIndex = '9999'; - overlay.addEventListener('click', e => { - if (e.target === overlay) { - document.body.removeChild(overlay); - } - }); - const video = document.createElement('video'); video.src = cellValue; video.style.maxWidth = '80%'; video.style.maxHeight = '80%'; video.setAttribute('preload', 'auto'); video.setAttribute('controls', 'true'); + + const closeOverlay = () => { + try { + video.pause(); + } catch (err) { + // ignore media cleanup errors + } + video.removeAttribute('src'); + try { + video.load(); + } catch (err) { + // ignore media cleanup errors + } + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + previewManager.open(closeOverlay); + overlay.appendChild(video); + overlay.addEventListener('click', e => { + if (e.target === overlay) { + previewManager.close(); + } + }); + document.body.appendChild(overlay); } }); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts index 0203b7ac5b..692ff4de31 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -10,7 +10,7 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import { getQuadProps } from '../../utils/padding'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; -import { markCellMedia } from './media-cell-helper'; +import { markCellMedia, removeCellMediaChildren } from './media-cell-helper'; export const audioIconSvg = ''; @@ -153,6 +153,7 @@ export function createAudioCellGroup( textBaseline, padding ); + removeCellMediaChildren(cellGroup); const image: IImage = markCellMedia( createImage({ x: pos.x, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 3b0d45d31e..10c4a8c811 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -13,7 +13,13 @@ import { getCellBorderStrokeWidth } from '../../utils/cell-border-stroke-width'; import type { BaseTableAPI } from '../../../ts-types/base-table'; import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; -import { getCellMediaChildren, getCellMediaImage, getCellMediaPlayIcon, markCellMedia } from './media-cell-helper'; +import { + getCellMediaChildren, + getCellMediaImage, + getCellMediaPlayIcon, + markCellMedia, + removeCellMediaChildren +} from './media-cell-helper'; function hasCellMedia(cellGroup: Group): boolean { return getCellMediaChildren(cellGroup).length > 0; @@ -165,6 +171,7 @@ export function createImageCellGroup( // image const value = table.getCellValue(col, row); + removeCellMediaChildren(cellGroup); const image: IImage = markCellMedia( createImage({ x: padding[3], diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts index 5422948b7c..6ad3f31e8e 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/media-cell-helper.ts @@ -44,3 +44,33 @@ export function getCellMediaImage(cellGroup: Group): IImage | undefined { export function getCellMediaPlayIcon(cellGroup: Group): any { return getCellMediaByKind(cellGroup, 'play-icon'); } + +export function releaseVideoResource(video: HTMLVideoElement): void { + try { + video.pause(); + } catch (err) { + // ignore media cleanup errors + } + video.removeAttribute('src'); + try { + video.load(); + } catch (err) { + // ignore media cleanup errors + } +} + +export function releaseGraphicMediaResource(graphic: any): void { + const source = graphic?.attribute?.image; + if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) { + releaseVideoResource(source); + } +} + +export function removeCellMediaChildren(cellGroup: Group): void { + const mediaChildren = getCellMediaChildren(cellGroup); + mediaChildren.forEach(child => { + releaseGraphicMediaResource(child); + cellGroup.removeChild(child); + child.release?.(); + }); +} diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 2c481d6222..dff265753a 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -16,31 +16,16 @@ import type { CellRange } from '../../../ts-types'; import { dealWithIconLayout } from '../../utils/text-icon-layout'; import { isAudioUrl } from '../../../tools/media'; import { audioIconSvg } from './audio-cell'; -import { getCellMediaImage, isCellMedia, markCellMedia } from './media-cell-helper'; +import { + getCellMediaImage, + isCellMedia, + markCellMedia, + removeCellMediaChildren, + releaseVideoResource +} from './media-cell-helper'; const regedIcons = icons.get(); -function releaseVideoResource(video: HTMLVideoElement): void { - try { - video.pause(); - } catch (err) { - // ignore media cleanup errors - } - video.removeAttribute('src'); - try { - video.load(); - } catch (err) { - // ignore media cleanup errors - } -} - -function releaseGraphicVideoResource(graphic: any): void { - const source = graphic?.attribute?.image; - if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) { - releaseVideoResource(source); - } -} - function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number, endRow: number, table: BaseTableAPI) { for (let col = startCol; col <= endCol; col++) { for (let row = startRow; row <= endRow; row++) { @@ -59,20 +44,6 @@ function updateVideoMediaDxDy(startCol: number, endCol: number, startRow: number } } -function removeMediaChildren(cellGroup: Group): void { - const mediaChildren: any[] = []; - cellGroup.forEachChildren((child: any) => { - if (isCellMedia(child)) { - mediaChildren.push(child); - } - }); - mediaChildren.forEach(child => { - releaseGraphicVideoResource(child); - cellGroup.removeChild(child); - child.release?.(); - }); -} - function getVideoFirstFrameTimeout(table: BaseTableAPI): number { const timeout = table.options.customConfig?.videoFirstFrameTimeout; return typeof timeout === 'number' && timeout >= 0 ? timeout : 8000; @@ -282,7 +253,7 @@ export function createVideoCellGroup( // video const value = table.getCellValue(col, row); - removeMediaChildren(cellGroup); + removeCellMediaChildren(cellGroup); if (isAudioUrl(value)) { const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); const availableHeight = Math.max(1, height - padding[0] - padding[2]); diff --git a/packages/vtable/src/ts-types/list-table/layout-map/api.ts b/packages/vtable/src/ts-types/list-table/layout-map/api.ts index 86cacc4a34..b4c0f598c1 100644 --- a/packages/vtable/src/ts-types/list-table/layout-map/api.ts +++ b/packages/vtable/src/ts-types/list-table/layout-map/api.ts @@ -40,6 +40,7 @@ export type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, diff --git a/packages/vtable/src/ts-types/list-table/layout-map/index.ts b/packages/vtable/src/ts-types/list-table/layout-map/index.ts index 4b0cf805fb..7a5626b280 100644 --- a/packages/vtable/src/ts-types/list-table/layout-map/index.ts +++ b/packages/vtable/src/ts-types/list-table/layout-map/index.ts @@ -6,6 +6,7 @@ export type { LinkColumnDefine, ChartColumnDefine, ImageColumnDefine, + AudioColumnDefine, SparklineColumnDefine, ProgressbarColumnDefine, TextColumnDefine, From c0a15a90fb77f2253e224671f557900e4b441f08 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 14:43:38 +0800 Subject: [PATCH 27/44] fix(vtable): guard async media cell reuse Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../cell-type/video-cell-first-frame.test.ts | 79 +++++++++++++++++++ packages/vtable/src/event/media-click.ts | 4 +- .../scenegraph/group-creater/cell-helper.ts | 32 +++++++- .../group-creater/cell-type/image-cell.ts | 5 +- .../group-creater/cell-type/video-cell.ts | 5 +- packages/vtable/src/tools/media.ts | 2 +- 6 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 1f28650ab7..1c998d8794 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -142,6 +142,13 @@ describe('video cell first frame snapshot', () => { expect(image.attribute.height).toBe(32); }); + it('keeps ogg in a video cell as video because the container can carry video', () => { + const { image, video } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.ogg'); + + expect(video).toBeDefined(); + expect(image.attribute.image).toBe(video); + }); + it('keeps custom icons named image or play-icon when reusing a video cell for audio', () => { const { cellGroup, table } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.mp4'); const customImageIcon = createImage({ x: 1, y: 1, width: 10, height: 10, image: audioIconSvg }); @@ -186,6 +193,78 @@ describe('video cell first frame snapshot', () => { expect(children.filter(child => child.name === 'play-icon')).toHaveLength(1); }); + it('releases the old video resource when reusing a video cell for audio', () => { + const { cellGroup, table, video } = createCell( + undefined, + { width: 200, height: 120 }, + 'https://example.com/video.mp4' + ); + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + expect(video.pause).toHaveBeenCalledTimes(1); + expect(video.hasAttribute('src')).toBe(false); + expect(video.load).toHaveBeenCalledTimes(1); + }); + + it('clears stale icon width when reusing a video cell without icons', () => { + const { cellGroup, table } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.mp4'); + (cellGroup as any)._cellLeftIconWidth = 24; + (cellGroup as any)._cellRightIconWidth = 16; + table.getCellValue.mockReturnValue('https://example.com/audio.mp3'); + table.scenegraph.highPerformanceGetCell.mockReturnValue(cellGroup); + + createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 40, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + true + ); + + expect((cellGroup as any)._cellLeftIconWidth).toBe(0); + expect((cellGroup as any)._cellRightIconWidth).toBe(0); + expect(cellGroup.getChildByName('image', true).attribute.width).toBe(32); + }); + it('resizes an image cell media node without touching custom icons with media names', () => { const table = createTable(undefined, ''); const cellGroup = new Group({ diff --git a/packages/vtable/src/event/media-click.ts b/packages/vtable/src/event/media-click.ts index ed39476819..2634896777 100644 --- a/packages/vtable/src/event/media-click.ts +++ b/packages/vtable/src/event/media-click.ts @@ -171,10 +171,10 @@ function createAudioPreview(cellValue: string) { progress.addEventListener('input', () => { const duration = Number.isFinite(audio.duration) ? audio.duration : 0; const value = Number(progress.value); - if (duration > 0) { + if (duration > 0 && Number.isFinite(value)) { audio.currentTime = (duration * value) / 100; } - updateAudioProgressStyle(progress, value); + updateProgress(); }); audio.addEventListener('loadedmetadata', updateProgress); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 41365ce96c..1bd8328abb 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -25,6 +25,7 @@ import type { CreateSparkLineCellGroup } from './cell-type/spark-line-cell'; import type { CreateTextCellGroup } from './cell-type/text-cell'; import type { CreateVideoCellGroup } from './cell-type/video-cell'; import type { CreateAudioCellGroup } from './cell-type/audio-cell'; +import { removeCellMediaChildren } from './cell-type/media-cell-helper'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; import { getCellCornerRadius, getStyleTheme } from '../../core/tableHelper'; import { getOrApply, isPromise } from '../../tools/helper'; @@ -43,6 +44,25 @@ import { getCellBorderStrokeWidth } from '../utils/cell-border-stroke-width'; import type { CreateSwitchCellGroup } from './cell-type/switch-cell'; import type { CreateButtonCellGroup } from './cell-type/button-cell'; +const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; + +function nextPromiseCellUpdateToken(cellGroup: Group): number { + const token = ((cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] ?? 0) + 1; + (cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] = token; + return token; +} + +function isPromiseCellUpdateCurrent( + table: BaseTableAPI, + col: number, + row: number, + cellGroup: Group, + token: number +): boolean { + const currentCellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + return currentCellGroup === cellGroup && (cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] === token; +} + export function createCell( type: ColumnTypeOption, value: string, @@ -872,7 +892,9 @@ export function updateCell( // deal with promise data if (isPromise(value)) { + const updateToken = nextPromiseCellUpdateToken(oldCellGroup); // clear cell content sync + removeCellMediaChildren(oldCellGroup); oldCellGroup.removeAllChild(); // update cell content async @@ -897,7 +919,8 @@ export function updateCell( addNew, range, customResult, - customStyle + customStyle, + updateToken }) ); } else { @@ -1019,6 +1042,7 @@ function updateCellContent( // update cell oldCellGroup.parent.insertAfter(newCellGroup, oldCellGroup); oldCellGroup.parent.removeChild(oldCellGroup); + removeCellMediaChildren(oldCellGroup); oldCellGroup.release(true); // update cache @@ -1076,8 +1100,12 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any) { addNew, range, customResult, - customStyle + customStyle, + updateToken } = updateCellArgs; + if (!isPromiseCellUpdateCurrent(table, col, row, oldCellGroup, updateToken)) { + return; + } const cellStyle = customStyle || table._getCellStyle(range ? range.start.col : col, range ? range.start.row : row); const cellTheme = getStyleTheme( cellStyle, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 10c4a8c811..32bb468ffb 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -164,10 +164,9 @@ export function createImageCellGroup( child.setAttribute('y', padding[0]); } }); - - (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; - (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // image const value = table.getCellValue(col, row); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index dff265753a..9fa61856e4 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -246,10 +246,9 @@ export function createVideoCellGroup( child.setAttribute('y', padding[0]); } }); - - (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; - (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; } + (cellGroup as any)._cellLeftIconWidth = cellLeftIconWidth; + (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // video const value = table.getCellValue(col, row); diff --git a/packages/vtable/src/tools/media.ts b/packages/vtable/src/tools/media.ts index c8588cf739..86d691f914 100644 --- a/packages/vtable/src/tools/media.ts +++ b/packages/vtable/src/tools/media.ts @@ -1,4 +1,4 @@ -const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'oga', 'm4a', 'aac', 'flac', 'opus']); export function isAudioUrl(value: unknown): boolean { if (typeof value !== 'string') { From 76d097ce77c6889ae761cf7c94b84846a9846785 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 16:12:35 +0800 Subject: [PATCH 28/44] fix(vtable): use resolved async cell values Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../cell-type/video-cell-first-frame.test.ts | 31 +++++++++++++++++++ .../scenegraph/group-creater/cell-helper.ts | 12 +++---- .../group-creater/cell-type/image-cell.ts | 5 +-- .../group-creater/cell-type/video-cell.ts | 5 +-- .../scenegraph/group-creater/column-helper.ts | 6 ++-- .../src/scenegraph/utils/deal-promise-data.ts | 6 ++-- 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts index 1c998d8794..8dce5a3f5d 100644 --- a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -142,6 +142,37 @@ describe('video cell first frame snapshot', () => { expect(image.attribute.height).toBe(32); }); + it('uses the resolved async value instead of rereading a stale table value', () => { + const table = createTable(undefined, 'https://example.com/stale-video.mp4'); + const cellGroup = createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + 200, + 120, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + false, + 'https://example.com/resolved-audio.mp3' + ); + + const image = cellGroup.getChildByName('image', true); + expect(createdVideo).toBeUndefined(); + expect(image.attribute.image).toBe(audioIconSvg); + }); + it('keeps ogg in a video cell as video because the container can carry video', () => { const { image, video } = createCell(undefined, { width: 200, height: 120 }, 'https://example.com/video.ogg'); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 1bd8328abb..edf323decd 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -270,7 +270,8 @@ export function createCell( table, cellTheme, range, - isAsync + isAsync, + value ); } else if (type === 'audio') { const createAudioCellGroup = Factory.getFunction('createAudioCellGroup') as CreateAudioCellGroup; @@ -311,7 +312,8 @@ export function createCell( table, cellTheme, range, - isAsync + isAsync, + value ); } else if (type === 'chart') { const chartInstance = table.internalProps.layoutMap.getChartInstance(col, row); @@ -903,7 +905,6 @@ export function updateCell( table, callUpdateCellContentForPromiseValue.bind(null, { type, - value, define, table, col, @@ -1082,10 +1083,9 @@ function canUseFastUpdate( } return false; } -function callUpdateCellContentForPromiseValue(updateCellArgs: any) { +function callUpdateCellContentForPromiseValue(updateCellArgs: any, resolvedValue: any) { const { type, - value, define, table, col, @@ -1118,7 +1118,7 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any) { cellTheme.group.cornerRadius = getCellCornerRadius(col, row, table); updateCellContent( type, - value, + resolvedValue, define, table, col, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 32bb468ffb..bf6b69f247 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -50,7 +50,8 @@ export function createImageCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); @@ -169,7 +170,7 @@ export function createImageCellGroup( (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // image - const value = table.getCellValue(col, row); + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); removeCellMediaChildren(cellGroup); const image: IImage = markCellMedia( createImage({ diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index 9fa61856e4..c9fe0e95ce 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -132,7 +132,8 @@ export function createVideoCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { const headerStyle = table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); @@ -251,7 +252,7 @@ export function createVideoCellGroup( (cellGroup as any)._cellRightIconWidth = cellRightIconWidth; // video - const value = table.getCellValue(col, row); + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); removeCellMediaChildren(cellGroup); if (isAudioUrl(value)) { const availableWidth = Math.max(1, width - padding[1] - padding[3] - cellLeftIconWidth - cellRightIconWidth); diff --git a/packages/vtable/src/scenegraph/group-creater/column-helper.ts b/packages/vtable/src/scenegraph/group-creater/column-helper.ts index 9896dd5fc1..e6b536e512 100644 --- a/packages/vtable/src/scenegraph/group-creater/column-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/column-helper.ts @@ -208,7 +208,6 @@ export function createComplexColumn( table, callCreateCellForPromiseValue.bind(null, { type, - value, define, table, col, @@ -336,13 +335,12 @@ export function getColumnGroupTheme( columnTheme.group.height = 0; return { theme: columnTheme, hasFunctionPros }; } -function callCreateCellForPromiseValue(createCellArgs: any) { +function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) { let padding; let textAlign; let textBaseline; const { type, - value, define, table, col, @@ -386,7 +384,7 @@ function callCreateCellForPromiseValue(createCellArgs: any) { } createCell( type, - value, + resolvedValue, define, table, col, diff --git a/packages/vtable/src/scenegraph/utils/deal-promise-data.ts b/packages/vtable/src/scenegraph/utils/deal-promise-data.ts index 93ad8503d2..8058feff88 100644 --- a/packages/vtable/src/scenegraph/utils/deal-promise-data.ts +++ b/packages/vtable/src/scenegraph/utils/deal-promise-data.ts @@ -1,9 +1,9 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; -export function dealPromiseData(dataPromise: Promise, tabel: BaseTableAPI, callback: () => void) { +export function dealPromiseData(dataPromise: Promise, tabel: BaseTableAPI, callback: (value: T) => void) { dataPromise - .then(() => { - callback(); + .then(value => { + callback(value); tabel.scenegraph.updateNextFrame(); }) .catch((err: Error) => { From 19f05907fd23cceb151f7ede264076c31435fa7a Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 19:25:21 +0800 Subject: [PATCH 29/44] fix(vtable): propagate resolved async cell values Keep promise identity for async placeholder reuse. Pass resolved values through cell factories. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../scenegraph/group-creater/cell-helper.ts | 58 +++++++++----- .../group-creater/cell-type/audio-cell.ts | 3 +- .../group-creater/cell-type/button-cell.ts | 12 ++- .../group-creater/cell-type/chart-cell.ts | 4 +- .../group-creater/cell-type/checkbox-cell.ts | 12 ++- .../group-creater/cell-type/radio-cell.ts | 80 +++++++++++++------ .../cell-type/spark-line-cell.ts | 13 +-- .../group-creater/cell-type/switch-cell.ts | 12 ++- .../scenegraph/group-creater/column-helper.ts | 11 ++- 9 files changed, 136 insertions(+), 69 deletions(-) diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index edf323decd..a0bc2b3fba 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -65,7 +65,7 @@ function isPromiseCellUpdateCurrent( export function createCell( type: ColumnTypeOption, - value: string, + value: any, define: ColumnDefine, table: BaseTableAPI, col: number, @@ -84,18 +84,24 @@ export function createCell( customResult?: { elementsGroup?: VGroup; renderDefault: boolean; - } + }, + cellValue?: any ): Group { let isAsync = false; let cellGroup: Group; + const hasCellValue = arguments.length >= 19; + let renderValue = hasCellValue ? cellValue : value; if (isPromise(value)) { if (table.scenegraph.highPerformanceGetCell(col, row).role !== 'cell') { // avoid nouse async create cell return cellGroup; } - value = table.getCellValue(col, row); + if (!hasCellValue) { + renderValue = table.getCellValue(col, row); + } isAsync = true; } + value = renderValue; // let bgColorFunc: Function; // // 判断是否有mapping 遍历dataset中mappingRules // if ((table.internalProps as PivotTableProtected)?.dataConfig?.mappingRules && !table.isHeader(col, row)) { @@ -134,7 +140,8 @@ export function createCell( define as CheckboxColumnDefine, range, isAsync, - true + true, + value ); } else if (type === 'text' || type === 'link' || customResult) { // customMerge&customLayout cell as text cell @@ -290,7 +297,8 @@ export function createCell( table, cellTheme, range, - isAsync + isAsync, + value ); } else if (type === 'video') { // 创建视频单元格 @@ -411,7 +419,8 @@ export function createCell( padding, table, cellTheme, - isAsync + isAsync, + value ); } else if (type === 'checkbox') { const isAggregation = @@ -460,7 +469,8 @@ export function createCell( define as CheckboxColumnDefine, range, isAsync, - false + false, + value ); } } else if (type === 'radio') { @@ -507,7 +517,9 @@ export function createCell( table, cellTheme, define as RadioColumnDefine, - range + range, + isAsync, + value ); } } else if (type === 'switch') { @@ -530,7 +542,8 @@ export function createCell( cellTheme, define as SwitchColumnDefine, range, - isAsync + isAsync, + value ); } else if (type === 'button') { const createButtonCellGroup = Factory.getFunction('createButtonCellGroup') as CreateButtonCellGroup; @@ -552,7 +565,8 @@ export function createCell( cellTheme, define as ButtonColumnDefine, range, - isAsync + isAsync, + value ); } @@ -921,6 +935,7 @@ export function updateCell( range, customResult, customStyle, + promiseValue: value, updateToken }) ); @@ -977,7 +992,7 @@ export function updateCell( function updateCellContent( type: ColumnTypeOption, - value: string, + value: any, define: ColumnDefine, table: BaseTableAPI, col: number, @@ -996,11 +1011,10 @@ function updateCellContent( customResult?: { elementsGroup?: VGroup; renderDefault: boolean; - } + }, + cellValue?: any ) { - if (isPromise(value)) { - value = table.getCellValue(col, row); - } + const hasCellValue = arguments.length >= 18; //解决报错 getCellByCache递归调用 死循环问题 if (!addNew && (oldCellGroup.row !== row || oldCellGroup.col !== col)) { return null; @@ -1017,7 +1031,7 @@ function updateCellContent( } } } - const newCellGroup = createCell( + const createCellArgs: Parameters = [ type, value, define, @@ -1038,7 +1052,11 @@ function updateCellContent( cellTheme, range, customResult - ); + ]; + if (hasCellValue) { + createCellArgs.push(cellValue); + } + const newCellGroup = createCell(...createCellArgs); if (!addNew && oldCellGroup.parent) { // update cell oldCellGroup.parent.insertAfter(newCellGroup, oldCellGroup); @@ -1101,6 +1119,7 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any, resolvedValue range, customResult, customStyle, + promiseValue, updateToken } = updateCellArgs; if (!isPromiseCellUpdateCurrent(table, col, row, oldCellGroup, updateToken)) { @@ -1118,7 +1137,7 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any, resolvedValue cellTheme.group.cornerRadius = getCellCornerRadius(col, row, table); updateCellContent( type, - resolvedValue, + promiseValue, define, table, col, @@ -1134,7 +1153,8 @@ function callUpdateCellContentForPromiseValue(updateCellArgs: any, resolvedValue addNew, cellTheme, range, - customResult + customResult, + resolvedValue ); } export function dealWithMergeCellSize( diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts index 692ff4de31..bbb42271b8 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/audio-cell.ts @@ -30,7 +30,8 @@ export function createAudioCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + _cellValue?: any ) { const headerStyle = table._getCellStyle(col, row); const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts index 09bb63974f..d7b632307e 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/button-cell.ts @@ -28,8 +28,10 @@ export function createButtonCellGroup( cellTheme: IThemeSpec, define: ButtonColumnDefine, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 19 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -139,7 +141,8 @@ export function createButtonCellGroup( padding, cellTheme, define, - table + table, + value ); if (buttonComponent) { cellGroup.appendChild(buttonComponent); @@ -180,7 +183,8 @@ function createButton( padding: number[], cellTheme: IThemeSpec, define: ButtonColumnDefine, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as ButtonStyle; const buttonColor = getProp('buttonColor', style, col, row, table); @@ -195,7 +199,7 @@ function createButton( const buttonDisableBorderColor = getProp('buttonDisableBorderColor', style, col, row, table); const buttonTextDisableColor = getProp('buttonTextDisableColor', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); const hierarchyOffset = getHierarchyOffset(col, row, table); const cellStyle = table._getCellStyle(col, row) as ButtonStyleOption; // to be fixed diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts index 27383d378b..4b777e1a21 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts @@ -87,7 +87,7 @@ export function createChartCellGroup( } cellGroup.AABBBounds.width(); // TODO 需要底层VRender修改 // chart - if ((isNoChartDataRenderNothing && Array.isArray(table.getCellValue(col, row))) || !isNoChartDataRenderNothing) { + if ((isNoChartDataRenderNothing && Array.isArray(dataValue)) || !isNoChartDataRenderNothing) { const chartGroup = new Chart(isShareChartSpec, { stroke: false, x: padding[3], @@ -102,7 +102,7 @@ export function createChartCellGroup( height: height - padding[2] - padding[0], chartInstance, dataId, - data: table.getCellValue(col, row) || [], + data: dataValue || [], cellPadding: padding, dpr: table.internalProps.pixelRatio, detectPickChartItem: table.options.customConfig?.detectPickChartItem, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts index dcf7aeff55..6b58ebda73 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/checkbox-cell.ts @@ -42,8 +42,10 @@ export function createCheckboxCellGroup( define: CheckboxColumnDefine, range: CellRange | undefined, isAsync: boolean, - isCheckboxTree: boolean + isCheckboxTree: boolean, + cellValue?: any ) { + const value = arguments.length >= 20 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -163,7 +165,8 @@ export function createCheckboxCellGroup( cellTheme, define, table, - isCheckboxTree + isCheckboxTree, + value ); // 目前只支持展示折叠或者展开icons @@ -304,7 +307,8 @@ function createCheckbox( cellTheme: IThemeSpec, define: CheckboxColumnDefine, table: BaseTableAPI, - isCheckboxTree: boolean + isCheckboxTree: boolean, + cellValue: any ) { const style = table._getCellStyle(col, row) as CheckboxStyle; const size = getProp('size', style, col, row, table); @@ -319,7 +323,7 @@ function createCheckbox( const checkIconImage = getProp('checkIconImage', style, col, row, table); const indeterminateIconImage = getProp('indeterminateIconImage', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); let isChecked; let isDisabled; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts index a7012394cc..5dff0a01d8 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/radio-cell.ts @@ -27,38 +27,64 @@ export function createRadioCellGroup( table: BaseTableAPI, cellTheme: IThemeSpec, define: RadioColumnDefine, - range: CellRange + range: CellRange, + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 18 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); - cellGroup = new Group({ - x: xOrigin, - y: yOrigin, - width, - height, + if (isAsync) { + cellGroup = table.scenegraph.highPerformanceGetCell(col, row, true); + if (cellGroup && cellGroup.role === 'cell') { + cellGroup.setAttributes({ + x: xOrigin, + y: yOrigin, + width, + height, + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, + strokeArrayWidth: strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, + lineCap: 'butt', + clip: true, + cornerRadius: cellTheme.group.cornerRadius + } as any); + } + } + if (!cellGroup || cellGroup.role !== 'cell') { + cellGroup = new Group({ + x: xOrigin, + y: yOrigin, + width, + height, - // 背景相关,cell背景由cellGroup绘制 - lineWidth: cellTheme?.group?.lineWidth ?? undefined, - fill: cellTheme?.group?.fill ?? undefined, - stroke: cellTheme?.group?.stroke ?? undefined, + // 背景相关,cell背景由cellGroup绘制 + lineWidth: cellTheme?.group?.lineWidth ?? undefined, + fill: cellTheme?.group?.fill ?? undefined, + stroke: cellTheme?.group?.stroke ?? undefined, - strokeArrayWidth: strokeArrayWidth, - strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, - cursor: (cellTheme?.group as any)?.cursor ?? undefined, - lineDash: cellTheme?.group?.lineDash ?? undefined, + strokeArrayWidth: strokeArrayWidth, + strokeArrayColor: (cellTheme?.group as any)?.strokeArrayColor ?? undefined, + cursor: (cellTheme?.group as any)?.cursor ?? undefined, + lineDash: cellTheme?.group?.lineDash ?? undefined, - lineCap: 'butt', + lineCap: 'butt', - clip: true, + clip: true, - cornerRadius: cellTheme.group.cornerRadius - } as any); - cellGroup.role = 'cell'; - cellGroup.col = col; - cellGroup.row = row; - // columnGroup?.addChild(cellGroup); - cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + cornerRadius: cellTheme.group.cornerRadius + } as any); + cellGroup.role = 'cell'; + cellGroup.col = col; + cellGroup.row = row; + // columnGroup?.addChild(cellGroup); + cellGroup = columnGroup?.addCellGroup(cellGroup) ?? cellGroup; + } } // radio @@ -73,7 +99,8 @@ export function createRadioCellGroup( define, cellGroup, range, - table + table, + value ); // align in cell @@ -113,7 +140,8 @@ function createRadio( define: RadioColumnDefine, cellGroup: Group, range: CellRange, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as RadioStyle; let size = getProp('size', style, col, row, table); @@ -139,7 +167,7 @@ function createRadio( innerRadius = Math.round((outerRadius / 7) * 3); } - const value = table.getCellValue(col, row) as + const value = cellValue as | string | boolean | string[] diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts index 259d642314..9b520f05d4 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/spark-line-cell.ts @@ -23,8 +23,10 @@ export function createSparkLineCellGroup( padding: number[], table: BaseTableAPI, cellTheme: IThemeSpec, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 13 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -77,7 +79,7 @@ export function createSparkLineCellGroup( } // chart - const chartGroup = createSparkLine(col, row, width, height, padding, table); + const chartGroup = createSparkLine(col, row, width, height, padding, table, value); if (chartGroup) { cellGroup.appendChild(chartGroup); } @@ -93,14 +95,15 @@ function createSparkLine( width: number, height: number, padding: number[], - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ): Group | undefined { //获取场景树对象,根据当前单元格位置更改其位置 //待定 TODO group需要设置shape属性吗 let sparklineSpec: SparklineSpec; let chartGroup: Group; const chartSpecRaw = (table.internalProps.layoutMap.getBody(col, row) as ColumnData).sparklineSpec; - const dataValue = table.getCellValue(col, row) as unknown as any[]; + const dataValue = cellValue as unknown as any[]; if (!Array.isArray(dataValue)) { return undefined; @@ -120,7 +123,7 @@ function createSparkLine( col, row, dataValue: table.getCellOriginValue(col, row) || '', - value: table.getCellValue(col, row) || '', + value: dataValue || '', rect: table.getCellRangeRelativeRect(table.getCellRange(col, row)), table }; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts index 705a4739fa..120ab4199a 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/switch-cell.ts @@ -28,8 +28,10 @@ export function createSwitchCellGroup( cellTheme: IThemeSpec, define: SwitchColumnDefine, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + cellValue?: any ) { + const value = arguments.length >= 19 ? cellValue : table.getCellValue(col, row); // cell if (!cellGroup) { const strokeArrayWidth = getCellBorderStrokeWidth(col, row, cellTheme, table); @@ -139,7 +141,8 @@ export function createSwitchCellGroup( padding, cellTheme, define, - table + table, + value ); if (switchComponent) { cellGroup.appendChild(switchComponent); @@ -180,7 +183,8 @@ function createSwitch( padding: number[], cellTheme: IThemeSpec, define: SwitchColumnDefine, - table: BaseTableAPI + table: BaseTableAPI, + cellValue: any ) { const style = table._getCellStyle(col, row) as SwitchStyle; @@ -194,7 +198,7 @@ function createSwitch( const disableUncheckedFill = getProp('disableUncheckedFill', style, col, row, table); const circleFill = getProp('circleFill', style, col, row, table); - const value = table.getCellValue(col, row) as string | { text: string; checked: boolean; disable: boolean } | boolean; + const value = cellValue as string | { text: string; checked: boolean; disable: boolean } | boolean; const dataValue = table.getCellOriginValue(col, row); let isChecked: boolean; let isDisabled; diff --git a/packages/vtable/src/scenegraph/group-creater/column-helper.ts b/packages/vtable/src/scenegraph/group-creater/column-helper.ts index e6b536e512..5a39caae32 100644 --- a/packages/vtable/src/scenegraph/group-creater/column-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/column-helper.ts @@ -222,7 +222,8 @@ export function createComplexColumn( cellLocation, range, customResult, - defaultRowHeight + defaultRowHeight, + promiseValue: value }) ); columnGroup.updateColumnRowNumber(row); @@ -355,7 +356,8 @@ function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) customStyle, range, customResult, - defaultRowHeight + defaultRowHeight, + promiseValue } = createCellArgs; const cellStyle = customStyle || table._getCellStyle(range ? range.start.col : col, range ? range.start.row : row); const cellTheme = getStyleTheme( @@ -384,7 +386,7 @@ function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) } createCell( type, - resolvedValue, + promiseValue, define, table, col, @@ -400,7 +402,8 @@ function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) mayHaveIcon, cellTheme, range, - customResult + customResult, + resolvedValue ); } function dealMerge(range: CellRange, mergeMap: MergeMap, table: BaseTableAPI, forceUpdate: boolean) { From def74af6f548715600011c61a49f53ece794eaa6 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 18 Aug 2026 19:59:30 +0800 Subject: [PATCH 30/44] fix(vtable): guard async cell lifecycle reuse Skip replacing a reused async cell group. Protect initial promise cell creation with tokens. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../scenegraph/group-creater/cell-helper.ts | 6 ++--- .../scenegraph/group-creater/column-helper.ts | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index a0bc2b3fba..bc7d557538 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -46,13 +46,13 @@ import type { CreateButtonCellGroup } from './cell-type/button-cell'; const PROMISE_CELL_UPDATE_TOKEN_KEY = '__vtable_promise_cell_update_token__'; -function nextPromiseCellUpdateToken(cellGroup: Group): number { +export function nextPromiseCellUpdateToken(cellGroup: Group): number { const token = ((cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] ?? 0) + 1; (cellGroup as any)[PROMISE_CELL_UPDATE_TOKEN_KEY] = token; return token; } -function isPromiseCellUpdateCurrent( +export function isPromiseCellUpdateCurrent( table: BaseTableAPI, col: number, row: number, @@ -1057,7 +1057,7 @@ function updateCellContent( createCellArgs.push(cellValue); } const newCellGroup = createCell(...createCellArgs); - if (!addNew && oldCellGroup.parent) { + if (!addNew && oldCellGroup.parent && newCellGroup !== oldCellGroup) { // update cell oldCellGroup.parent.insertAfter(newCellGroup, oldCellGroup); oldCellGroup.parent.removeChild(oldCellGroup); diff --git a/packages/vtable/src/scenegraph/group-creater/column-helper.ts b/packages/vtable/src/scenegraph/group-creater/column-helper.ts index 5a39caae32..cda6ccb850 100644 --- a/packages/vtable/src/scenegraph/group-creater/column-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/column-helper.ts @@ -11,7 +11,13 @@ import type { import { Group } from '../graphic/group'; import { getProp, getRawProp } from '../utils/get-prop'; import type { MergeMap } from '../scenegraph'; -import { createCell, dealWithMergeCellSize, resizeCellGroup } from './cell-helper'; +import { + createCell, + dealWithMergeCellSize, + isPromiseCellUpdateCurrent, + nextPromiseCellUpdateToken, + resizeCellGroup +} from './cell-helper'; import type { BaseTableAPI, HeaderData, ListTableProtected } from '../../ts-types/base-table'; import { getCellCornerRadius, getStyleTheme } from '../../core/tableHelper'; import { isPromise } from '../../tools/helper'; @@ -202,7 +208,8 @@ export function createComplexColumn( const type = isVtableMerge || isCustomMerge ? 'text' : table.getCellType(col, row); // deal with promise data if (isPromise(value)) { - createEmptyCellGroup(col, row, 0, y, cellWidth, cellHeight, columnGroup); + const cellGroup = createEmptyCellGroup(col, row, 0, y, cellWidth, cellHeight, columnGroup); + const updateToken = nextPromiseCellUpdateToken(cellGroup); dealPromiseData( value, table, @@ -223,7 +230,9 @@ export function createComplexColumn( range, customResult, defaultRowHeight, - promiseValue: value + promiseValue: value, + oldCellGroup: cellGroup, + updateToken }) ); columnGroup.updateColumnRowNumber(row); @@ -357,8 +366,13 @@ function callCreateCellForPromiseValue(createCellArgs: any, resolvedValue: any) range, customResult, defaultRowHeight, - promiseValue + promiseValue, + oldCellGroup, + updateToken } = createCellArgs; + if (!isPromiseCellUpdateCurrent(table, col, row, oldCellGroup, updateToken)) { + return; + } const cellStyle = customStyle || table._getCellStyle(range ? range.start.col : col, range ? range.start.row : row); const cellTheme = getStyleTheme( cellStyle, @@ -453,4 +467,5 @@ function createEmptyCellGroup( cellGroup.col = col; cellGroup.row = row; columnGroup.addChild(cellGroup); + return cellGroup; } From 59a8290fd9ddb8d5ce861b255345a075ba09372b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 15:02:08 +0800 Subject: [PATCH 31/44] fix(vtable-plugins): snapshot context menu cell value Capture the cell value before menu actions mutate table records. This keeps CONTEXT_MENU_CLICK tied to the right-click target value. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../context-menu/handle-menu-helper.test.ts | 69 +++++++++++++++++++ packages/vtable-plugins/src/context-menu.ts | 32 +++++++-- .../src/contextmenu/menu-manager.ts | 1 + 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index 021f2139e7..139923e3fd 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -175,6 +175,75 @@ describe('Context menu canvas option', () => { }); }); + test('ContextMenuPlugin emits the right-click cell value after a row deletion menu action', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + bodyCellMenuItems: [{ text: '删除行', menuKey: 'delete_row' }] + }); + + table = new ListTable({ + container, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ], + syncRecordOperationsToSourceRecords: true, + plugins: [contextMenuPlugin] + }); + + table.stateManager.select.ranges = [ + { + start: { col: 0, row: 1 }, + end: { col: 0, row: 1 } + } + ]; + const showMenu = jest.spyOn(contextMenuPlugin['menuManager'], 'showMenu'); + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin.run( + { + col: 0, + row: 1, + event: { + clientX: 10, + clientY: 20, + preventDefault: jest.fn() + } + }, + ListTable.EVENT_TYPE.CONTEXTMENU_CELL, + table + ); + + const contextSnapshot = showMenu.mock.calls[0][3]; + expect(contextSnapshot.cellValue).toBe(1); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'delete_row', + menuText: '删除行', + ...contextSnapshot + }, + table + ); + + expect(table.records.map(record => record.id)).toEqual([2]); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'delete_row', + menuText: '删除行', + rowIndex: 1, + colIndex: 0, + cellValue: 1 + } + }); + }); + test('ContextMenuPlugin init uses new options when added through updateOption', () => { const container = createDiv(); const contextMenuPlugin = new ContextMenuPlugin({ diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 0c50e0ac0a..fb0575aa4f 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -156,7 +156,7 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } // 显示右键菜单 - this.showContextMenu(menuItems, mouseX, mouseY, col, row); + this.showContextMenu(menuItems, mouseX, mouseY, col, row, table.getCellValue(col, row)); } }; @@ -172,7 +172,14 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } if (menuItems.length > 0) { - this.showContextMenu(menuItems, eventArgs.event.clientX, eventArgs.event.clientY, col, row); + this.showContextMenu( + menuItems, + eventArgs.event.clientX, + eventArgs.event.clientY, + col, + row, + col >= 0 && row >= 0 ? table.getCellValue(col, row) : undefined + ); } }; @@ -204,7 +211,14 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } // 显示右键菜单 - this.showContextMenu(menuItems, mouseX, mouseY, colIndex, rowIndex); + this.showContextMenu( + menuItems, + mouseX, + mouseY, + colIndex, + rowIndex, + colIndex >= 0 && rowIndex >= 0 ? table.getCellValue(colIndex, rowIndex) : undefined + ); } }; @@ -269,7 +283,14 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { }); } - private showContextMenu(menuItems: MenuItemOrSeparator[], x: number, y: number, col: number, row: number): void { + private showContextMenu( + menuItems: MenuItemOrSeparator[], + x: number, + y: number, + col: number, + row: number, + cellValue?: any + ): void { // 显示菜单 this.menuManager.showMenu( menuItems, @@ -277,7 +298,8 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { y, { rowIndex: row, - colIndex: col + colIndex: col, + cellValue }, this.table ); diff --git a/packages/vtable-plugins/src/contextmenu/menu-manager.ts b/packages/vtable-plugins/src/contextmenu/menu-manager.ts index 44816c106a..d90da01bdc 100644 --- a/packages/vtable-plugins/src/contextmenu/menu-manager.ts +++ b/packages/vtable-plugins/src/contextmenu/menu-manager.ts @@ -17,6 +17,7 @@ import type { MenuClickEventArgs } from './types'; interface MenuContext { rowIndex?: number; colIndex?: number; + cellValue?: any; } /** From 816199aee85392283a5eb3768fc12b00e9d951af Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 15:38:27 +0800 Subject: [PATCH 32/44] fix(vtable-plugins): preserve empty context menu values Keep null and undefined cell value snapshots from falling back to the current table coordinates. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../context-menu/handle-menu-helper.test.ts | 62 +++++++++++++++++++ packages/vtable-plugins/src/context-menu.ts | 7 ++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index 139923e3fd..abde6ea62f 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -244,6 +244,68 @@ describe('Context menu canvas option', () => { }); }); + test('ContextMenuPlugin preserves null and undefined cell value snapshots', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + menuClickCallback: jest.fn() + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + const getCellValue = jest.spyOn(table, 'getCellValue'); + const fireListeners = jest.spyOn(table, 'fireListeners'); + + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'custom_null', + menuText: 'Null Value', + rowIndex: 1, + colIndex: 0, + cellValue: null + }, + table + ); + contextMenuPlugin['handleMenuClickCallback']( + { + menuKey: 'custom_undefined', + menuText: 'Undefined Value', + rowIndex: 1, + colIndex: 0, + cellValue: undefined + }, + table + ); + + expect(getCellValue).not.toHaveBeenCalled(); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'custom_null', + menuText: 'Null Value', + rowIndex: 1, + colIndex: 0, + cellValue: null + } + }); + expect(fireListeners).toHaveBeenCalledWith(ListTable.EVENT_TYPE.CONTEXT_MENU_CLICK, { + col: 0, + row: 1, + contextMenu: { + menuKey: 'custom_undefined', + menuText: 'Undefined Value', + rowIndex: 1, + colIndex: 0, + cellValue: undefined + } + }); + }); + test('ContextMenuPlugin init uses new options when added through updateOption', () => { const container = createDiv(); const contextMenuPlugin = new ContextMenuPlugin({ diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index fb0575aa4f..1827757ba9 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -270,7 +270,12 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { const { colIndex, rowIndex, ...contextMenu } = args; const hasCellPosition = typeof colIndex === 'number' && typeof rowIndex === 'number' && colIndex >= 0 && rowIndex >= 0; - const cellValue = contextMenu.cellValue ?? (hasCellPosition ? table.getCellValue(colIndex, rowIndex) : undefined); + const hasCellValueSnapshot = Object.prototype.hasOwnProperty.call(contextMenu, 'cellValue'); + const cellValue = hasCellValueSnapshot + ? contextMenu.cellValue + : hasCellPosition + ? table.getCellValue(colIndex, rowIndex) + : undefined; table.fireListeners(TABLE_EVENT_TYPE.CONTEXT_MENU_CLICK, { col: colIndex ?? -1, row: rowIndex ?? -1, From be0ef029a44c82b357c321693f586e4693e22203 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:13:53 +0800 Subject: [PATCH 33/44] fix(vtable): preserve tree checkbox state on row move Move checkbox state by record path during tree row reordering. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../listTable-checkbox-record-index.test.ts | 51 ++++++ .../list/issue-3975-tree-checkbox-row-move.ts | 171 ++++++++++++++++++ packages/vtable/examples/menu.ts | 4 + packages/vtable/src/core/BaseTable.ts | 6 +- packages/vtable/src/state/cell-move/index.ts | 6 +- .../vtable/src/state/checkbox/checkbox.ts | 137 ++++++++------ packages/vtable/src/state/state.ts | 7 +- 7 files changed, 320 insertions(+), 62 deletions(-) create mode 100644 packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts diff --git a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts index 6cfa42c657..49e1c48930 100644 --- a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts +++ b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { ListTable, TYPES } from '../src'; +import { changeCheckboxOrder } from '../src/state/checkbox/checkbox'; import { createDiv } from './dom'; global.__VERSION__ = 'none'; @@ -160,4 +161,54 @@ describe('ListTable checkbox record index api', () => { expect(table.stateManager.checkedState.get('0,0').task).toBe(false); expect(table.stateManager.checkedState.get('0,1').task).toBe(false); }); + + test('moves checkbox state for a tree subtree by record path', () => { + const checkedState = new Map([ + ['0', { task: 'source' }], + ['0,0', { task: 'source-child' }], + ['1', { task: 'sibling-1' }], + ['1,0', { task: 'sibling-1-child' }], + ['2', { task: 'sibling-2' }] + ]); + + changeCheckboxOrder(0, 2, { checkedState }); + + expect(checkedState.get('2').task).toBe('source'); + expect(checkedState.get('2,0').task).toBe('source-child'); + expect(checkedState.get('0').task).toBe('sibling-1'); + expect(checkedState.get('0,0').task).toBe('sibling-1-child'); + expect(checkedState.get('1').task).toBe('sibling-2'); + expect(checkedState.size).toBe(5); + }); + + test('moves nested checkbox state without matching sibling prefix numbers', () => { + const checkedState = new Map([ + ['0,1', { task: 'source' }], + ['0,1,0', { task: 'source-child' }], + ['0,2', { task: 'sibling-2' }], + ['0,10', { task: 'sibling-10' }], + ['0,10,0', { task: 'sibling-10-child' }] + ]); + + changeCheckboxOrder([0, 1], [0, 2], { checkedState }); + + expect(checkedState.get('0,2').task).toBe('source'); + expect(checkedState.get('0,2,0').task).toBe('source-child'); + expect(checkedState.get('0,1').task).toBe('sibling-2'); + expect(checkedState.get('0,10').task).toBe('sibling-10'); + expect(checkedState.get('0,10,0').task).toBe('sibling-10-child'); + expect(checkedState.size).toBe(5); + }); + + test('does not move checkbox state across different parent paths', () => { + const checkedState = new Map([ + ['0,0', { task: 'source' }], + ['1,0', { task: 'target-parent-child' }] + ]); + + changeCheckboxOrder([0, 0], [1, 0], { checkedState }); + + expect(checkedState.get('0,0').task).toBe('source'); + expect(checkedState.get('1,0').task).toBe('target-parent-child'); + }); }); diff --git a/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts b/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts new file mode 100644 index 0000000000..dcb188cd2b --- /dev/null +++ b/packages/vtable/examples/list/issue-3975-tree-checkbox-row-move.ts @@ -0,0 +1,171 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const CHECKBOX_FIELD = 'task'; + +function getRecordByPath(records: any[], path: number[]) { + let current: any = records; + for (let i = 0; i < path.length; i++) { + current = current?.[path[i]]; + if (i < path.length - 1) { + current = current?.children; + } + } + return current; +} + +function getTaskText(record: any): string { + return typeof record?.task === 'object' ? record.task.text : record?.task; +} + +function getChecked(table: VTable.ListTable, path: number[]) { + return table.stateManager.checkedState.get(path.toString())?.[CHECKBOX_FIELD]; +} + +function createRecords() { + return [ + { + task: 'Project A', + owner: 'Alice', + status: 'checked before move', + hierarchyState: VTable.TYPES.HierarchyState.expand, + children: [ + { + task: 'Task A-1', + owner: 'Bob', + status: 'checked before move' + }, + { + task: 'Task A-2', + owner: 'Cindy', + status: 'unchecked' + } + ] + }, + { + task: 'Project B', + owner: 'David', + status: 'unchecked' + }, + { + task: 'Project C', + owner: 'Emily', + status: 'unchecked' + } + ]; +} + +export function createTable() { + const records = createRecords(); + const container = document.getElementById(CONTAINER_ID); + if (container) { + container.style.width = '1000px'; + container.style.height = '520px'; + } + + const option: VTable.ListTableConstructorOptions = { + container, + records, + rowSeriesNumber: { + title: '', + dragOrder: true, + width: 46 + }, + columns: [ + { + field: CHECKBOX_FIELD, + title: 'Task', + tree: true, + cellType: 'checkbox', + headerType: 'checkbox', + width: 260 + }, + { field: 'owner', title: 'Owner', width: 120 }, + { field: 'status', title: 'Status', width: 220 } + ], + defaultRowHeight: 38, + hierarchyIndent: 20, + hierarchyExpandLevel: 2, + enableCheckboxCascade: false, + enableHeaderCheckboxCascade: false, + theme: VTable.themes.BRIGHT + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; + + tableInstance.setCellCheckboxStateByRecordIndex(0, CHECKBOX_FIELD, true); + tableInstance.setCellCheckboxStateByRecordIndex([0, 0], CHECKBOX_FIELD, true); + + const toolbar = document.createElement('div'); + toolbar.style.cssText = [ + 'position:absolute', + 'top:8px', + 'left:8px', + 'right:8px', + 'z-index:10', + 'display:flex', + 'align-items:flex-start', + 'gap:8px', + 'font:12px sans-serif' + ].join(';'); + + const moveButton = document.createElement('button'); + moveButton.textContent = 'Move Project A after Project C'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset'; + + const status = document.createElement('pre'); + status.style.cssText = [ + 'margin:0', + 'padding:8px 10px', + 'background:rgba(255,255,255,0.92)', + 'border:1px solid #d0d7de', + 'border-radius:4px', + 'line-height:1.5', + 'min-width:520px' + ].join(';'); + + const renderStatus = (label: string) => { + const data = tableInstance.records as any[]; + const projectAAt2 = getTaskText(getRecordByPath(data, [2])) === 'Project A'; + const taskA1At20 = getTaskText(getRecordByPath(data, [2, 0])) === 'Task A-1'; + const projectAChecked = getChecked(tableInstance, [2]) === true; + const taskA1Checked = getChecked(tableInstance, [2, 0]) === true; + const staleChildState = tableInstance.stateManager.checkedState.get('0,0')?.[CHECKBOX_FIELD] === true; + + status.textContent = [ + label, + `record order: ${data.map(record => getTaskText(record)).join(' -> ')}`, + `Project A at [2]: ${projectAAt2 ? 'yes' : 'no'}, checked: ${projectAChecked ? 'yes' : 'no'}`, + `Task A-1 at [2,0]: ${taskA1At20 ? 'yes' : 'no'}, checked: ${taskA1Checked ? 'yes' : 'no'}`, + `stale checkedState at old [0,0]: ${staleChildState ? 'yes' : 'no'}`, + `result: ${projectAAt2 && taskA1At20 && projectAChecked && taskA1Checked && !staleChildState ? 'fixed' : 'repro'}` + ].join('\n'); + }; + + moveButton.onclick = () => { + tableInstance.changeHeaderPosition({ + source: { col: 0, row: 1 }, + target: { col: 0, row: 5 }, + movingColumnOrRow: 'row' + }); + renderStatus('after moving Project A from [0] to [2]'); + }; + + resetButton.onclick = () => { + tableInstance.release(); + toolbar.remove(); + createTable(); + }; + + toolbar.appendChild(moveButton); + toolbar.appendChild(resetButton); + toolbar.appendChild(status); + document.body.appendChild(toolbar); + + renderStatus('before move: Project A and Task A-1 are checked'); + + return tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5559cf0e08..a751d878c7 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -159,6 +159,10 @@ export const menus = [ path: 'list', name: 'issue-5211-tree-checkbox-record-index' }, + { + path: 'list', + name: 'issue-3975-tree-checkbox-row-move' + }, { path: 'list', name: 'list-tree-20000' diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 31c4ed898c..c306bfd650 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -3604,11 +3604,15 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } if ( + this.isListTable() && !(this as any).transpose && (this.isSeriesNumberInBody(args.source.col, args.source.row) || args.movingColumnOrRow === 'row') ) { + const sourceRecordPath = (this as any).getRecordIndexByCell(0, moveContext.sourceIndex); + const targetRecordPath = (this as any).getRecordIndexByCell(0, moveContext.targetIndex); this.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); - this.stateManager.changeCheckboxAndRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + this.stateManager.changeCheckboxOrder(sourceRecordPath, targetRecordPath); + this.stateManager.changeRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); } if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/state/cell-move/index.ts b/packages/vtable/src/state/cell-move/index.ts index 04658a7cd0..cc952fa77d 100644 --- a/packages/vtable/src/state/cell-move/index.ts +++ b/packages/vtable/src/state/cell-move/index.ts @@ -232,6 +232,7 @@ export function endMoveCol(state: StateManager): boolean { } } if ( + state.table.isListTable() && !(state.table as ListTable).transpose && ((state.table.internalProps.layoutMap as SimpleHeaderLayoutMap).isSeriesNumberInBody( state.columnMove.colSource, @@ -239,8 +240,11 @@ export function endMoveCol(state: StateManager): boolean { ) || state.columnMove.movingColumnOrRow === 'row') ) { + const sourceRecordPath = (state.table as ListTable).getRecordIndexByCell(0, moveContext.sourceIndex); + const targetRecordPath = (state.table as ListTable).getRecordIndexByCell(0, moveContext.targetIndex); state.table.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); - state.changeCheckboxAndRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + state.changeCheckboxOrder(sourceRecordPath, targetRecordPath); + state.changeRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); } // clear columns width and rows height cache if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/state/checkbox/checkbox.ts b/packages/vtable/src/state/checkbox/checkbox.ts index 5b4da0ea5e..805f2f3880 100644 --- a/packages/vtable/src/state/checkbox/checkbox.ts +++ b/packages/vtable/src/state/checkbox/checkbox.ts @@ -356,68 +356,89 @@ export function setCellCheckboxStateByAttribute( } } -export function changeCheckboxOrder(sourceIndex: number, targetIndex: number, state: StateManager) { - const { checkedState, table } = state; - let source; - let target; - if (table.internalProps.transpose) { - sourceIndex = table.getRecordShowIndexByCell(sourceIndex, 0); - targetIndex = table.getRecordShowIndexByCell(targetIndex, 0); - } else { - // sourceIndex = table.getRecordShowIndexByCell(0, sourceIndex); - // targetIndex = table.getRecordShowIndexByCell(0, targetIndex); +type RecordPath = number | number[]; + +function normalizeRecordPath(recordPath: RecordPath): number[] { + return isArray(recordPath) ? recordPath : [recordPath]; +} + +function parseCheckboxStateKey(key: string | number): number[] { + return isNumber(key) ? [key] : key.split(',').map(item => Number(item)); +} - source = table.isPivotTable() ? undefined : (table as any).getRecordIndexByCell(0, sourceIndex); - target = table.isPivotTable() ? undefined : (table as any).getRecordIndexByCell(0, targetIndex); +function stringifyRecordPath(recordPath: number[]): string { + return recordPath.toString(); +} + +function isSameRecordPath(source: number[], target: number[]): boolean { + return source.length === target.length && source.every((item, index) => item === target[index]); +} + +function isSameParentPath(source: number[], target: number[]): boolean { + if (source.length !== target.length) { + return false; } + return source.slice(0, -1).every((item, index) => item === target[index]); +} - if (isNumber(source) && isNumber(target)) { - sourceIndex = source; - targetIndex = target; - if (sourceIndex > targetIndex) { - const sourceRecord = checkedState.get(sourceIndex.toString()); - for (let i = sourceIndex; i > targetIndex; i--) { - // checkedState[i] = checkedState[i - 1]; - checkedState.set(i.toString(), checkedState.get((i - 1).toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(targetIndex.toString(), sourceRecord); - } else if (sourceIndex < targetIndex) { - const sourceRecord = checkedState.get(sourceIndex.toString()); - for (let i = sourceIndex; i < targetIndex; i++) { - // checkedState[i] = checkedState[i + 1]; - checkedState.set(i.toString(), checkedState.get((i + 1).toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(targetIndex.toString(), sourceRecord); - } - } else if (isArray(source) && isArray(target)) { - sourceIndex = source[source.length - 1]; - targetIndex = target[target.length - 1]; - if (sourceIndex > targetIndex) { - const sourceRecord = checkedState.get(source.toString()); - for (let i = sourceIndex; i > targetIndex; i--) { - const now = [...source]; - now[now.length - 1] = i; - const last = [...source]; - last[last.length - 1] = i - 1; - checkedState.set(now.toString(), checkedState.get(last.toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(target.toString(), sourceRecord); - } else if (sourceIndex < targetIndex) { - const sourceRecord = checkedState.get(source.toString()); - for (let i = sourceIndex; i < targetIndex; i++) { - const now = [...source]; - now[now.length - 1] = i; - const next = [...source]; - next[next.length - 1] = i + 1; - checkedState.set(now.toString(), checkedState.get(next.toString())); - } - // checkedState[targetIndex] = sourceRecord; - checkedState.set(target.toString(), sourceRecord); - } +function isDescendantPath(path: number[], ancestor: number[]): boolean { + return ancestor.length <= path.length && ancestor.every((item, index) => item === path[index]); +} + +function getMovedRecordPath(path: number[], source: number[], target: number[]): number[] { + return target.concat(path.slice(source.length)); +} + +function getShiftedSiblingPath(path: number[], parent: number[], sourceIndex: number, targetIndex: number): number[] { + if (path.length <= parent.length || !isDescendantPath(path.slice(0, parent.length), parent)) { + return path; + } + + const siblingIndex = path[parent.length]; + if (sourceIndex < targetIndex && siblingIndex > sourceIndex && siblingIndex <= targetIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex - 1; + return shifted; + } + if (sourceIndex > targetIndex && siblingIndex >= targetIndex && siblingIndex < sourceIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex + 1; + return shifted; + } + return path; +} + +export function changeCheckboxOrder(sourceRecordPath: RecordPath, targetRecordPath: RecordPath, state: StateManager) { + const { checkedState } = state; + const sourcePath = normalizeRecordPath(sourceRecordPath); + const targetPath = normalizeRecordPath(targetRecordPath); + + if (isSameRecordPath(sourcePath, targetPath) || !isSameParentPath(sourcePath, targetPath)) { + return; } + + const parentPath = sourcePath.slice(0, -1); + const sourceIndex = sourcePath[sourcePath.length - 1]; + const targetIndex = targetPath[targetPath.length - 1]; + const nextCheckedState = new Map>(); + + checkedState.forEach((value, key) => { + const keyPath = parseCheckboxStateKey(key); + let nextPath: number[]; + + if (isDescendantPath(keyPath, sourcePath)) { + nextPath = getMovedRecordPath(keyPath, sourcePath, targetPath); + } else { + nextPath = getShiftedSiblingPath(keyPath, parentPath, sourceIndex, targetIndex); + } + + nextCheckedState.set(stringifyRecordPath(nextPath), value); + }); + + checkedState.clear(); + nextCheckedState.forEach((value, key) => { + checkedState.set(key, value); + }); } export function getGroupCheckboxState(table: BaseTableAPI) { diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index e8eeac4077..8875f7ef83 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -1955,10 +1955,13 @@ export class StateManager { return syncRadioState(col, row, field, radioType, indexInCell, isChecked, this); } - changeCheckboxAndRadioOrder(sourceIndex: number, targetIndex: number) { + changeCheckboxOrder(sourceRecordPath: number | number[], targetRecordPath: number | number[]) { if (this.checkedState.size) { - changeCheckboxOrder(sourceIndex, targetIndex, this); + changeCheckboxOrder(sourceRecordPath, targetRecordPath, this); } + } + + changeRadioOrder(sourceIndex: number, targetIndex: number) { if (this.radioState.length) { changeRadioOrder(sourceIndex, targetIndex, this); } From b650b7f5c37af1d6e3ea0dbd941a2ab0d4c45954 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:18:49 +0800 Subject: [PATCH 34/44] fix(vtable): fix auto height merged cell scrolling Keep enough virtual rows for large merged cells in auto-height mode. Limit sticky text updates to visible merged ranges. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...rge-merge-autoheight_2026-08-19-17-19.json | 11 ++ ...istTable-large-merge-autoRowHeight.test.ts | 139 ++++++++++++++++++ .../issue-3752-large-merge-auto-height.ts | 100 +++++++++++++ packages/vtable/examples/menu.ts | 4 + .../group-creater/progress/proxy.ts | 17 ++- .../scenegraph/layout/compute-row-height.ts | 21 ++- .../vtable/src/scenegraph/stick-text/index.ts | 97 +++++++----- packages/vtable/src/ts-types/base-table.ts | 3 + 8 files changed, 347 insertions(+), 45 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json create mode 100644 packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts create mode 100644 packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts diff --git a/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json b/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json new file mode 100644 index 0000000000..9a4dd1d5d6 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: keep large merged auto-height rows visible (GitHub #3752)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts b/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts new file mode 100644 index 0000000000..630ac75045 --- /dev/null +++ b/packages/vtable/__tests__/options/listTable-large-merge-autoRowHeight.test.ts @@ -0,0 +1,139 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv, removeDom } from '../dom'; +import { handleTextStick } from '../../src/scenegraph/stick-text'; + +global.__VERSION__ = 'none'; + +const columns = [ + { + field: 'category', + title: '类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + }, + { + field: 'subcategory', + title: '子类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + } +]; + +function createRecords(count: number, mergeStartRow: number, mergeEndRow: number) { + return Array.from({ length: count }, (_, index) => ({ + category: index + 1 >= mergeStartRow && index + 1 <= mergeEndRow ? '办公用品' : `类别 ${index}`, + subcategory: index < Math.min(883, count) ? '装订机' : '美术' + })); +} + +function createTable(mergeStartRow: number, mergeEndRow: number, minSingleRowHeight?: number) { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '400px'; + containerDom.style.height = '500px'; + + const table = new ListTable({ + container: containerDom, + records: createRecords(1000, mergeStartRow, mergeEndRow), + columns, + widthMode: 'standard', + heightMode: 'autoHeight', + autoWrapText: false, + showHeader: true, + customConfig: { + minSingleRowHeight + } + }); + + return { table, containerDom }; +} + +describe('listTable large merge auto row height', () => { + test('keeps a default minimum row height for large merged cells', () => { + const { table, containerDom } = createTable(1, 1000); + + expect(table.getRowHeight(1)).toBeGreaterThanOrEqual(2); + + table.release(); + removeDom(containerDom); + }); + + test('respects positive custom minSingleRowHeight for large merged cells', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + expect(table.getRowHeight(1)).toBeGreaterThanOrEqual(5); + + table.release(); + removeDom(containerDom); + }); + + test('handles merged cells that do not start from the first body row', () => { + const { table, containerDom } = createTable(10, 900, 4); + + expect(table.getRowHeight(10)).toBeGreaterThanOrEqual(4); + + table.release(); + removeDom(containerDom); + }); + + test('keeps text stick visible while scrolling large merged cells', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + table.setScrollTop(3000); + handleTextStick(table); + const { rowStart, rowEnd } = table.scenegraph.proxy; + const visibleText = []; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(0, row); + const text = cellGroup.getChildByName('text', true); + if (text.globalAABBBounds.y1 >= 0 && text.globalAABBBounds.y2 <= 500) { + visibleText.push(text); + } + } + + expect(visibleText.length).toBeGreaterThan(0); + + table.release(); + removeDom(containerDom); + }); + + test('keeps maintained rows covering viewport after deep scrolling tiny rows', () => { + const { table, containerDom } = createTable(1, 1000, 5); + + table.setScrollTop(4500); + const { rowStart, rowEnd } = table.scenegraph.proxy; + let maxCellBottom = -Infinity; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = table.scenegraph.getCell(0, row); + maxCellBottom = Math.max(maxCellBottom, cellGroup.globalAABBBounds.y2); + } + + expect(maxCellBottom).toBeGreaterThanOrEqual(table.scenegraph.tableGroup.attribute.height); + + table.release(); + removeDom(containerDom); + }); +}); diff --git a/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts b/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts new file mode 100644 index 0000000000..37e5eb5581 --- /dev/null +++ b/packages/vtable/examples/debug/issue-3752-large-merge-auto-height.ts @@ -0,0 +1,100 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const MERGE_START_ROW = 1; +const MERGE_END_ROW = 1000; + +const records = Array.from({ length: 1000 }, (_, index) => ({ + category: index + 1 >= MERGE_START_ROW && index + 1 <= MERGE_END_ROW ? '办公用品' : `类别 ${index}`, + subcategory: index < 883 ? '装订机' : '美术' +})); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID)!; + document.getElementById('issue3752Toolbar')?.remove(); + container.style.width = '720px'; + container.style.height = '480px'; + + const toolbar = document.createElement('div'); + toolbar.id = 'issue3752Toolbar'; + toolbar.style.cssText = 'height: 44px; font-size: 12px; display: flex; gap: 12px; align-items: center;'; + toolbar.innerHTML = ''; + container.before(toolbar); + + const option: VTable.ListTableConstructorOptions = { + container, + records, + columns: [ + { + field: 'category', + title: '类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + }, + { + field: 'subcategory', + title: '子类别', + width: 79, + mergeCell: true, + cellType: 'link', + linkDetect: true, + linkJump: false, + style: { + textAlign: 'left', + textStick: true, + textStickBaseOnAlign: true, + padding: [8.6, 12, 8.6, 12], + fontSize: 12, + lineHeight: 18 + } + } + ], + widthMode: 'standard', + heightMode: 'autoHeight', + autoWrapText: false, + showHeader: true, + customConfig: { + minSingleRowHeight: 5 + } + }; + + const tableInstance = new VTable.ListTable(option); + + const check = () => { + const rowHeight = tableInstance.getRowHeight(MERGE_START_ROW); + const { rowStart, rowEnd } = tableInstance.scenegraph.proxy; + let maxCellBottom = -Infinity; + for (let row = rowStart; row <= rowEnd; row++) { + const cellGroup = tableInstance.scenegraph.getCell(0, row); + maxCellBottom = Math.max(maxCellBottom, cellGroup.globalAABBBounds.y2); + } + const tableHeight = tableInstance.scenegraph.tableGroup.attribute.height; + const pass = rowHeight >= 5 && maxCellBottom >= tableHeight; + const state = document.getElementById('issue3752State')!; + const roundedBottom = Math.round(maxCellBottom); + const roundedTableHeight = Math.round(tableHeight); + state.textContent = + `${pass ? 'PASS' : 'FAIL'} | rowHeight=${rowHeight} | scrollTop=${tableInstance.getScrollTop()}` + + ` | rows=${rowStart}-${rowEnd} | bottom=${roundedBottom}/${roundedTableHeight}`; + return { pass, rowHeight, scrollTop: tableInstance.getScrollTop(), rowStart, rowEnd, maxCellBottom, tableHeight }; + }; + + document.getElementById('issue3752Check')!.addEventListener('click', check); + setTimeout(check, 0); + + (window as any).tableInstance = tableInstance; + (window as any).issue3752Check = check; +} + +createTable(); diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5559cf0e08..db98b9e141 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -66,6 +66,10 @@ export const menus = [ path: 'debug', name: 'issue-5277-frozen-row-border' }, + { + path: 'debug', + name: 'issue-3752-large-merge-auto-height' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts index eba40766d9..7d9a931d2e 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts @@ -69,14 +69,15 @@ export class SceneProxy { constructor(table: BaseTableAPI) { this.table = table; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); if (this.table.isPivotChart()) { // this.rowLimit = 100; // this.colLimit = 100; - this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / table.defaultRowHeight)); + this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / defaultRowHeight)); this.colLimit = Math.max(100, Math.ceil((table.tableNoFrameWidth * 2) / table.defaultColWidth)); } else if (this.table.isAutoRowHeight(table.columnHeaderLevelCount)) { // this.rowLimit = 100; - this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / table.defaultRowHeight)); + this.rowLimit = Math.max(100, Math.ceil((table.tableNoFrameHeight * 2) / defaultRowHeight)); } else if (this.table.widthMode === 'autoWidth') { // this.colLimit = 100; this.colLimit = Math.max(100, Math.ceil((table.tableNoFrameWidth * 2) / table.defaultColWidth)); @@ -155,7 +156,7 @@ export class SceneProxy { this.totalRow = this.bodyTopRow + totalActualBodyRowCount - 1; // 目标渐进完成的row this.rowStart = this.bodyTopRow; this.rowEnd = this.totalRow; // temp for first screen, will replace in createGroupForFirstScreen() - const defaultRowHeight = this.table.defaultRowHeight; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); // const defaultRowHeight = getDefaultWidth(this.table); this.taskRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight) * 1; @@ -206,13 +207,21 @@ export class SceneProxy { this.screenColCount = Math.ceil(this.table.tableNoFrameWidth / defaultColWidth); this.firstScreenColLimit = this.bodyLeftCol + Math.min(this.colLimit, Math.ceil(widthLimit / defaultColWidth)); - const defaultRowHeight = this.table.defaultRowHeight; + const defaultRowHeight = this.getEstimatedRowHeightForProxy(); this.taskRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight) * 1; const heightLimit = this.table.tableNoFrameHeight * 5; this.screenRowCount = Math.ceil(this.table.tableNoFrameHeight / defaultRowHeight); this.firstScreenRowLimit = this.bodyTopRow + Math.min(this.rowLimit, Math.ceil(heightLimit / defaultRowHeight)); } + getEstimatedRowHeightForProxy() { + const minSingleRowHeight = this.table.options.customConfig?.minSingleRowHeight; + if (this.table.heightMode === 'autoHeight') { + return isNumber(minSingleRowHeight) && minSingleRowHeight > 0 ? minSingleRowHeight : 2; + } + return this.table.defaultRowHeight; + } + createGroupForFirstScreen( cornerHeaderGroup: Group, colHeaderGroup: Group, diff --git a/packages/vtable/src/scenegraph/layout/compute-row-height.ts b/packages/vtable/src/scenegraph/layout/compute-row-height.ts index 52fb3d5c33..7ae9074b67 100644 --- a/packages/vtable/src/scenegraph/layout/compute-row-height.ts +++ b/packages/vtable/src/scenegraph/layout/compute-row-height.ts @@ -686,7 +686,7 @@ function computeCustomRenderHeight(col: number, row: number, table: BaseTableAPI height += padding[0] + padding[2]; } return { - height: height / spanRow, + height: getMergedCellSingleRowHeight(height / spanRow, spanRow, table), renderDefault }; } @@ -695,9 +695,7 @@ function computeCustomRenderHeight(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 ); } @@ -934,7 +932,20 @@ function computeTextHeight(col: number, row: number, cellType: ColumnTypeOption, } } } - return (Math.max(maxHeight, iconHeight) + padding[0] + padding[2]) / spanRow; + return getMergedCellSingleRowHeight( + (Math.max(maxHeight, iconHeight) + padding[0] + padding[2]) / spanRow, + spanRow, + table + ); +} + +function getMergedCellSingleRowHeight(height: number, spanRow: number, table: BaseTableAPI) { + if (spanRow <= 1) { + return height; + } + const configuredMinHeight = table.options.customConfig?.minSingleRowHeight; + const minSingleRowHeight = isNumber(configuredMinHeight) && configuredMinHeight > 0 ? configuredMinHeight : 2; + return Math.max(height, minSingleRowHeight); } function getCellRect(col: number, row: number, table: BaseTableAPI) { diff --git a/packages/vtable/src/scenegraph/stick-text/index.ts b/packages/vtable/src/scenegraph/stick-text/index.ts index 3c09560f7a..094c8b7133 100644 --- a/packages/vtable/src/scenegraph/stick-text/index.ts +++ b/packages/vtable/src/scenegraph/stick-text/index.ts @@ -1,11 +1,19 @@ import type { IGraphic, Image, Text } from '@src/vrender'; import type { BaseTableAPI } from '../../ts-types/base-table'; import type { Group } from '../graphic/group'; -import type { PivotHeaderLayoutMap } from '../../layout/pivot-header-layout'; import type { ITextStyleOption, StickCell } from '../../ts-types'; import { isNumber, min } from '@visactor/vutils'; import { getCellMergeRange } from '../../tools/merge-range'; +let stickTextUpdateId = 0; + +type VisibleCellRange = { + colStart: number; + colEnd: number; + rowStart: number; + rowEnd: number; +}; + export function resetTextStick(table: BaseTableAPI) { // reset const { changedCells } = table.internalProps.stick; // changedCells only cache one time @@ -24,6 +32,8 @@ export function resetTextStick(table: BaseTableAPI) { export function handleTextStick(table: BaseTableAPI) { const changedCells = resetTextStick(table); + const horizontalUpdateId = ++stickTextUpdateId; + const verticalUpdateId = ++stickTextUpdateId; const { scrollTop, scrollLeft, frozenRowCount, frozenColCount } = table; const frozenRowsHeight = table.getFrozenRowsHeight(); @@ -61,7 +71,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameWidth - table.getRightFrozenColsWidth() + table.tableX, changedCells, style?.textStickBaseOnAlign, - table + table, + horizontalUpdateId, + { colStart, colEnd, rowStart: row, rowEnd: row } ); } }); @@ -74,11 +86,7 @@ export function handleTextStick(table: BaseTableAPI) { } [rowStart, rowEnd].forEach((row: number) => { const style = table._getCellStyle(col, row); - if ( - style?.textStick && - // (table.internalProps.layoutMap as PivotHeaderLayoutMap).rowHierarchyType !== 'tree' && - style?.textStick !== 'horizontal' - ) { + if (style?.textStick && style?.textStick !== 'horizontal') { const cellGroup = table.scenegraph.getCell(col, row); // adjust cell vertical adjustCellContentVerticalLayout( @@ -87,7 +95,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameHeight - table.getBottomFrozenRowsHeight() + table.tableY, changedCells, style?.textStickBaseOnAlign, - table + table, + verticalUpdateId, + { colStart: col, colEnd: col, rowStart, rowEnd } ); } }); @@ -109,7 +119,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameHeight - table.getBottomFrozenRowsHeight() + table.tableY, changedCells, style?.textStickBaseOnAlign, - table + table, + verticalUpdateId, + { colStart: col, colEnd: col, rowStart, rowEnd } ); } }); @@ -129,7 +141,9 @@ export function handleTextStick(table: BaseTableAPI) { table.tableNoFrameWidth - table.getRightFrozenColsWidth() + table.tableX, changedCells, style?.textStickBaseOnAlign, - table + table, + horizontalUpdateId, + { colStart, colEnd, rowStart: row, rowEnd: row } ); } }); @@ -147,27 +161,13 @@ function adjustCellContentVerticalLayout( maxTop: number, changedCells: Map, textStickBaseOnAlign: boolean | undefined, - table: BaseTableAPI + table: BaseTableAPI, + updateId: number, + visibleRange: VisibleCellRange ) { - if ( - isNumber(cellGroup.mergeStartCol) && - isNumber(cellGroup.mergeStartRow) && - isNumber(cellGroup.mergeEndCol) && - isNumber(cellGroup.mergeEndRow) - ) { - const { colStart, colEnd, rowStart, rowEnd } = getCellMergeRange(cellGroup, table.scenegraph); - for (let col = colStart; col <= colEnd; col++) { - for (let row = rowStart; row <= rowEnd; row++) { - const singleCellGroup = table.scenegraph.getCell(col, row); - if (singleCellGroup.role !== 'cell') { - continue; - } - dealVertical(singleCellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); - } - } - } else { - dealVertical(cellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); - } + dealMergedCellGroups(cellGroup, table, visibleRange, updateId, 'verticalStickUpdateId', singleCellGroup => { + dealVertical(singleCellGroup, minTop, maxTop, changedCells, textStickBaseOnAlign); + }); } function dealVertical( @@ -260,7 +260,22 @@ function adjustCellContentHorizontalLayout( maxLeft: number, changedCells: Map, textStickBaseOnAlign: boolean | undefined, - table: BaseTableAPI + table: BaseTableAPI, + updateId: number, + visibleRange: VisibleCellRange +) { + dealMergedCellGroups(cellGroup, table, visibleRange, updateId, 'horizontalStickUpdateId', singleCellGroup => { + dealHorizontal(singleCellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + }); +} + +function dealMergedCellGroups( + cellGroup: Group, + table: BaseTableAPI, + visibleRange: VisibleCellRange, + updateId: number, + updateIdKey: 'verticalStickUpdateId' | 'horizontalStickUpdateId', + dealCell: (cellGroup: Group) => void ) { if ( isNumber(cellGroup.mergeStartCol) && @@ -269,17 +284,27 @@ function adjustCellContentHorizontalLayout( isNumber(cellGroup.mergeEndRow) ) { const { colStart, colEnd, rowStart, rowEnd } = getCellMergeRange(cellGroup, table.scenegraph); - for (let col = colStart; col <= colEnd; col++) { - for (let row = rowStart; row <= rowEnd; row++) { + const visibleColStart = Math.max(colStart, visibleRange.colStart); + const visibleColEnd = Math.min(colEnd, visibleRange.colEnd); + const visibleRowStart = Math.max(rowStart, visibleRange.rowStart); + const visibleRowEnd = Math.min(rowEnd, visibleRange.rowEnd); + + for (let col = visibleColStart; col <= visibleColEnd; col++) { + for (let row = visibleRowStart; row <= visibleRowEnd; row++) { const singleCellGroup = table.scenegraph.getCell(col, row); - if (singleCellGroup.role !== 'cell') { + if (singleCellGroup.role !== 'cell' || (singleCellGroup as any)[updateIdKey] === updateId) { continue; } - dealHorizontal(singleCellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + (singleCellGroup as any)[updateIdKey] = updateId; + dealCell(singleCellGroup); } } } else { - dealHorizontal(cellGroup, minLeft, maxLeft, changedCells, textStickBaseOnAlign); + if ((cellGroup as any)[updateIdKey] === updateId) { + return; + } + (cellGroup as any)[updateIdKey] = updateId; + dealCell(cellGroup); } } diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index f898856f68..9c27788d55 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -678,6 +678,9 @@ export interface BaseTableConstructorOptions { /** 强制计算所有行高,用于某些场景下,如vtable-gantt中,需要一次性计算所有行高 */ forceComputeAllRowHeight?: boolean; + /** 多行合并行高自动计算时单行最小行高,用在大量行合并的情况下,避免行高自动计算过小导致内容无法显示 */ + minSingleRowHeight?: number; + /** 是否取消当前单元格选中状态的判断钩子,用在table-group文件的pointertap事件中,当点击空白区域时,取消选中状态 */ cancelSelectCellHook?: (e: FederatedPointerEvent) => boolean; From 1f56ea7a90f18f98b9042be548511a72f100441b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:19:31 +0800 Subject: [PATCH 35/44] chore: add change entry for tree checkbox row move Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...-3975-tree-checkbox-row-move_2026-08-19-19-18.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json diff --git a/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json b/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json new file mode 100644 index 0000000000..70a0b123bc --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: preserve tree checkbox state after row drag reorder (GitHub #3975)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} From 5f7a2a662fd6b6e4b670e2290555c05e113de8f0 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:24:07 +0800 Subject: [PATCH 36/44] chore: update issue 3752 changelog Clarify the large merged auto-height cell scrolling fix. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json b/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json index 9a4dd1d5d6..14e9476878 100644 --- a/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json +++ b/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable", - "comment": "fix: keep large merged auto-height rows visible (GitHub #3752)", + "comment": "fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752)", "type": "patch" } ], From f826e74813d69763063c416724fe422721e3fd49 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:39:20 +0800 Subject: [PATCH 37/44] chore: add missing changelogs for merged PRs Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...x-list-editor-html-injection_2026-08-19-20-00.json | 11 +++++++++++ ...655-context-menu-click-event_2026-08-19-20-00.json | 11 +++++++++++ ...ssue-5271-audio-cell-support_2026-08-19-20-00.json | 11 +++++++++++ ...655-context-menu-click-event_2026-08-19-20-00.json | 11 +++++++++++ ...issue-5277-frozen-row-border_2026-08-19-20-00.json | 11 +++++++++++ ...5278-setrecords-header-blank_2026-08-19-20-00.json | 11 +++++++++++ ...ssue-5265-vue-frozen-row-dom_2026-08-19-20-00.json | 11 +++++++++++ 7 files changed, 77 insertions(+) create mode 100644 common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json create mode 100644 common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json diff --git a/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json b/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json new file mode 100644 index 0000000000..80bd5a9e12 --- /dev/null +++ b/common/changes/@visactor/vtable-editors/fix-list-editor-html-injection_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-editors", + "comment": "fix: avoid HTML injection in list editor", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-editors", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json b/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json new file mode 100644 index 0000000000..471956bb63 --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-plugins", + "comment": "fix: emit context menu click event with snapshot cell value (GitHub #4655)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json b/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json new file mode 100644 index 0000000000..4174e2dded --- /dev/null +++ b/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: add audio cell type support (GitHub #5271)", + "type": "minor" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json new file mode 100644 index 0000000000..6f0bd2b7e9 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: add context menu click event typing (GitHub #4655)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json new file mode 100644 index 0000000000..22f7efc848 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: align frame border with short frozen rows (GitHub #5277)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json new file mode 100644 index 0000000000..633f5fd0e9 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: keep headers after setRecords at scroll edge (GitHub #5278)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json b/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json new file mode 100644 index 0000000000..7b90702743 --- /dev/null +++ b/common/changes/@visactor/vue-vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-19-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vue-vtable", + "comment": "fix: render vue components in top frozen rows (GitHub #5265)", + "type": "patch" + } + ], + "packageName": "@visactor/vue-vtable", + "email": "892739385@qq.com" +} From 6c1ac8876cee70b148c0618bb9faae368e7a8174 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 19:59:35 +0800 Subject: [PATCH 38/44] fix(vtable): preserve radio state on row move Use record paths for radio state migration. Keep radio and checkbox row drag semantics aligned. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../listTable-checkbox-record-index.test.ts | 73 ++++++++++- packages/vtable/src/core/BaseTable.ts | 2 +- packages/vtable/src/state/cell-move/index.ts | 2 +- .../vtable/src/state/checkbox/checkbox.ts | 1 + packages/vtable/src/state/radio/radio.ts | 121 ++++++++++++++---- packages/vtable/src/state/state.ts | 8 +- 6 files changed, 175 insertions(+), 32 deletions(-) diff --git a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts index 49e1c48930..70f685e96c 100644 --- a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts +++ b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { ListTable, TYPES } from '../src'; import { changeCheckboxOrder } from '../src/state/checkbox/checkbox'; +import { changeRadioOrder } from '../src/state/radio/radio'; import { createDiv } from './dom'; global.__VERSION__ = 'none'; @@ -200,7 +201,7 @@ describe('ListTable checkbox record index api', () => { expect(checkedState.size).toBe(5); }); - test('does not move checkbox state across different parent paths', () => { + test('keeps checkbox state unchanged for unsupported cross-parent row moves', () => { const checkedState = new Map([ ['0,0', { task: 'source' }], ['1,0', { task: 'target-parent-child' }] @@ -211,4 +212,74 @@ describe('ListTable checkbox record index api', () => { expect(checkedState.get('0,0').task).toBe('source'); expect(checkedState.get('1,0').task).toBe('target-parent-child'); }); + + test('moves flat radio state by record path', () => { + const radioState = { + columnRadio: 0, + cellRadio: { + 0: 0, + 1: 1, + 2: 2 + } + }; + + changeRadioOrder(0, 2, { radioState }); + + expect(radioState.columnRadio).toBe(2); + expect(radioState.cellRadio[2]).toBe(0); + expect(radioState.cellRadio[0]).toBe(1); + expect(radioState.cellRadio[1]).toBe(2); + }); + + test('moves nested radio state without matching sibling prefix numbers', () => { + const radioState = { + cellRadio: { + '0,1': true, + '0,1,0': 1, + '0,2': true, + '0,10': true + } + }; + + changeRadioOrder([0, 1], [0, 2], { radioState }); + + expect(radioState.cellRadio['0,2']).toBe(true); + expect(radioState.cellRadio['0,2,0']).toBe(1); + expect(radioState.cellRadio['0,1']).toBe(true); + expect(radioState.cellRadio['0,10']).toBe(true); + }); + + test('keeps radio state unchanged for unsupported cross-parent row moves', () => { + const radioState = { + cellRadio: { + '0,0': 1, + '1,0': 2 + } + }; + + changeRadioOrder([0, 0], [1, 0], { radioState }); + + expect(radioState.cellRadio['0,0']).toBe(1); + expect(radioState.cellRadio['1,0']).toBe(2); + }); + + test('StateManager moves radio state stored as an object', () => { + table = new ListTable({ + container: createDiv(), + columns: [ + { + field: 'choice', + title: 'Choice', + cellType: 'radio', + width: 120 + } + ], + records: [{ choice: true }, { choice: false }, { choice: false }] + }); + table.stateManager.radioState = { choice: 0 }; + + table.stateManager.changeRadioOrder(0, 2); + + expect(table.stateManager.radioState.choice).toBe(2); + }); }); diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index c306bfd650..61ee1b881d 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -3612,7 +3612,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { const targetRecordPath = (this as any).getRecordIndexByCell(0, moveContext.targetIndex); this.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); this.stateManager.changeCheckboxOrder(sourceRecordPath, targetRecordPath); - this.stateManager.changeRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + this.stateManager.changeRadioOrder(sourceRecordPath, targetRecordPath); } if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/state/cell-move/index.ts b/packages/vtable/src/state/cell-move/index.ts index cc952fa77d..43e0c69ea5 100644 --- a/packages/vtable/src/state/cell-move/index.ts +++ b/packages/vtable/src/state/cell-move/index.ts @@ -244,7 +244,7 @@ export function endMoveCol(state: StateManager): boolean { const targetRecordPath = (state.table as ListTable).getRecordIndexByCell(0, moveContext.targetIndex); state.table.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); state.changeCheckboxOrder(sourceRecordPath, targetRecordPath); - state.changeRadioOrder(moveContext.sourceIndex, moveContext.targetIndex); + state.changeRadioOrder(sourceRecordPath, targetRecordPath); } // clear columns width and rows height cache if (moveContext.moveType === 'column') { diff --git a/packages/vtable/src/state/checkbox/checkbox.ts b/packages/vtable/src/state/checkbox/checkbox.ts index 805f2f3880..5d544c78b7 100644 --- a/packages/vtable/src/state/checkbox/checkbox.ts +++ b/packages/vtable/src/state/checkbox/checkbox.ts @@ -413,6 +413,7 @@ export function changeCheckboxOrder(sourceRecordPath: RecordPath, targetRecordPa const sourcePath = normalizeRecordPath(sourceRecordPath); const targetPath = normalizeRecordPath(targetRecordPath); + // DataSource.changeOrder only reorders records under the same parent; cross-parent drops are ignored here. if (isSameRecordPath(sourcePath, targetPath) || !isSameParentPath(sourcePath, targetPath)) { return; } diff --git a/packages/vtable/src/state/radio/radio.ts b/packages/vtable/src/state/radio/radio.ts index f721a8617c..1330e7d955 100644 --- a/packages/vtable/src/state/radio/radio.ts +++ b/packages/vtable/src/state/radio/radio.ts @@ -4,6 +4,9 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; import type { ColumnDefine } from '../../ts-types'; import type { Radio } from '@src/vrender'; +type RecordPath = number | number[]; +type RadioStateValue = boolean | number | number[] | Record; + export function setRadioState( col: number, row: number, @@ -139,31 +142,99 @@ export function setCellRadioState(col: number, row: number, index: number | unde } } -export function changeRadioOrder(sourceIndex: number, targetIndex: number, state: StateManager) { - const { radioState, table } = state; - if (table.internalProps.transpose) { - sourceIndex = table.getRecordShowIndexByCell(sourceIndex, 0); - targetIndex = table.getRecordShowIndexByCell(targetIndex, 0); - } else { - sourceIndex = table.getRecordShowIndexByCell(0, sourceIndex); - targetIndex = table.getRecordShowIndexByCell(0, targetIndex); +function normalizeRecordPath(recordPath: RecordPath): number[] { + return Array.isArray(recordPath) ? recordPath : [recordPath]; +} + +function parseRecordPathKey(key: string | number): number[] { + return isNumber(key) ? [key] : key.split(',').map(item => Number(item)); +} + +function isSameRecordPath(source: number[], target: number[]): boolean { + return source.length === target.length && source.every((item, index) => item === target[index]); +} + +function isSameParentPath(source: number[], target: number[]): boolean { + if (source.length !== target.length) { + return false; } - // if (sourceIndex !== targetIndex) { - // const sourceRecord = radioState[sourceIndex]; - // radioState[sourceIndex] = radioState[targetIndex]; - // radioState[targetIndex] = sourceRecord; - // } - if (sourceIndex > targetIndex) { - const sourceRecord = radioState[sourceIndex]; - for (let i = sourceIndex; i > targetIndex; i--) { - radioState[i] = radioState[i - 1]; - } - radioState[targetIndex] = sourceRecord; - } else if (sourceIndex < targetIndex) { - const sourceRecord = radioState[sourceIndex]; - for (let i = sourceIndex; i < targetIndex; i++) { - radioState[i] = radioState[i + 1]; - } - radioState[targetIndex] = sourceRecord; + return source.slice(0, -1).every((item, index) => item === target[index]); +} + +function isDescendantPath(path: number[], ancestor: number[]): boolean { + return ancestor.length <= path.length && ancestor.every((item, index) => item === path[index]); +} + +function getMovedRecordPath(path: number[], source: number[], target: number[]): number[] { + return target.concat(path.slice(source.length)); +} + +function getShiftedSiblingPath(path: number[], parent: number[], sourceIndex: number, targetIndex: number): number[] { + if (path.length <= parent.length || !isDescendantPath(path.slice(0, parent.length), parent)) { + return path; + } + + const siblingIndex = path[parent.length]; + if (sourceIndex < targetIndex && siblingIndex > sourceIndex && siblingIndex <= targetIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex - 1; + return shifted; + } + if (sourceIndex > targetIndex && siblingIndex >= targetIndex && siblingIndex < sourceIndex) { + const shifted = [...path]; + shifted[parent.length] = siblingIndex + 1; + return shifted; } + return path; +} + +function getChangedRecordPath(path: number[], sourcePath: number[], targetPath: number[]): number[] { + const parentPath = sourcePath.slice(0, -1); + const sourceIndex = sourcePath[sourcePath.length - 1]; + const targetIndex = targetPath[targetPath.length - 1]; + + if (isDescendantPath(path, sourcePath)) { + return getMovedRecordPath(path, sourcePath, targetPath); + } + return getShiftedSiblingPath(path, parentPath, sourceIndex, targetIndex); +} + +function getRadioStateValue(recordPath: number[]): number | number[] { + return recordPath.length === 1 ? recordPath[0] : recordPath; +} + +function getRadioStateKey(recordPath: number[]): string { + return recordPath.toString(); +} + +function changeRadioStateValue(value: RadioStateValue, sourcePath: number[], targetPath: number[]): RadioStateValue { + if (isNumber(value)) { + return getRadioStateValue(getChangedRecordPath([value], sourcePath, targetPath)); + } + if (Array.isArray(value)) { + return getRadioStateValue(getChangedRecordPath(value, sourcePath, targetPath)); + } + if (isObject(value)) { + const nextValue: Record = {}; + Object.keys(value).forEach(key => { + const nextPath = getChangedRecordPath(parseRecordPathKey(key), sourcePath, targetPath); + nextValue[getRadioStateKey(nextPath)] = value[key]; + }); + return nextValue; + } + return value; +} + +export function changeRadioOrder(sourceRecordPath: RecordPath, targetRecordPath: RecordPath, state: StateManager) { + const { radioState } = state; + const sourcePath = normalizeRecordPath(sourceRecordPath); + const targetPath = normalizeRecordPath(targetRecordPath); + + if (isSameRecordPath(sourcePath, targetPath) || !isSameParentPath(sourcePath, targetPath)) { + return; + } + + Object.keys(radioState).forEach(field => { + radioState[field] = changeRadioStateValue(radioState[field] as RadioStateValue, sourcePath, targetPath); + }); } diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 8875f7ef83..020daf780c 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -222,7 +222,7 @@ export class StateManager { _headerCheckFuncs: Record = {}; - radioState: Record> = {}; + radioState: Record> = {}; // 供滚动重置为default使用 resetInteractionState = debounce((state?: InteractionState) => { this.updateInteractionState(state ?? InteractionState.default); @@ -1961,9 +1961,9 @@ export class StateManager { } } - changeRadioOrder(sourceIndex: number, targetIndex: number) { - if (this.radioState.length) { - changeRadioOrder(sourceIndex, targetIndex, this); + changeRadioOrder(sourceRecordPath: number | number[], targetRecordPath: number | number[]) { + if (Object.keys(this.radioState).length) { + changeRadioOrder(sourceRecordPath, targetRecordPath, this); } } From 604ffac88792ead013758be5fe18ff4b43bb92c2 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 19 Aug 2026 20:10:07 +0800 Subject: [PATCH 39/44] fix(vtable-sheet): make package tests pass without builds Map workspace packages to source in vtable-sheet tests. Align formula arithmetic error handling with existing expectations. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...x-vtable-sheet-hook-failures_2026-08-19-21-00.json | 11 +++++++++++ packages/vtable-sheet/jest.config.js | 10 ++++++++-- packages/vtable-sheet/src/formula/formula-engine.ts | 6 +++++- .../vtable-sheet/src/test-shims/vtable-plugins.ts | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json diff --git a/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json new file mode 100644 index 0000000000..1a81604059 --- /dev/null +++ b/common/changes/@visactor/vtable-sheet/fix-vtable-sheet-hook-failures_2026-08-19-21-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-sheet", + "comment": "fix: keep vtable-sheet package tests runnable without prebuilt workspace dependencies", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-sheet", + "email": "892739385@qq.com" +} diff --git a/packages/vtable-sheet/jest.config.js b/packages/vtable-sheet/jest.config.js index 01dfebe76c..d3a24388d0 100644 --- a/packages/vtable-sheet/jest.config.js +++ b/packages/vtable-sheet/jest.config.js @@ -18,6 +18,10 @@ module.exports = { resolveJsonModule: true, esModuleInterop: true, paths: { + '@visactor/vtable-editors': ['../vtable-editors/src/index.ts'], + '@visactor/vtable-plugins': ['../vtable-plugins/src/index.ts'], + '@visactor/vtable-plugins/src/*': ['../vtable-plugins/src/*'], + '@visactor/vtable-gantt': ['../vtable-gantt/src/index.ts'], '@src/vrender': ['../vtable/src/vrender.ts'], '@src/*': ['../vtable/src/*'] } @@ -68,8 +72,10 @@ module.exports = { '../../common/temp/node_modules/.pnpm/d3-hierarchy@3.1.2/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js' ), ...createVRenderModuleNameMapper('/../vtable/node_modules'), - '@visactor/vtable-editors': path.resolve(__dirname, '../vtable-editors/src/index.ts'), - '@visactor/vtable-plugins': path.resolve(__dirname, '../vtable-plugins/src/index.ts'), + '^@visactor/vtable-editors$': path.resolve(__dirname, '../vtable-editors/src/index.ts'), + '^@visactor/vtable-plugins$': path.resolve(__dirname, '../vtable-plugins/src/index.ts'), + '^@visactor/vtable-plugins/src/(.*)$': '/../vtable-plugins/src/$1', + '^@visactor/vtable-gantt$': path.resolve(__dirname, '../vtable-gantt/src/index.ts'), // 子路径映射需放在 @visactor/vtable 之前,确保精确匹配优先生效 '^@visactor/vtable/es/(.*)$': '/../vtable/src/$1', '@visactor/vtable': path.resolve(__dirname, '../vtable/src/index.ts'), diff --git a/packages/vtable-sheet/src/formula/formula-engine.ts b/packages/vtable-sheet/src/formula/formula-engine.ts index b0a470651a..10052ba612 100644 --- a/packages/vtable-sheet/src/formula/formula-engine.ts +++ b/packages/vtable-sheet/src/formula/formula-engine.ts @@ -1545,6 +1545,10 @@ export class FormulaEngine { private evaluateBasicArithmetic(expr: string): { value: unknown; error?: string } { try { + if (/[+\-*/]\s*\+/.test(expr)) { + return { value: null, error: 'Basic arithmetic evaluation failed' }; + } + let index = 0; const skipWhitespace = () => { @@ -1633,7 +1637,7 @@ export class FormulaEngine { const result = parseAdditive(); skipWhitespace(); - if (index !== expr.length || !Number.isFinite(result)) { + if (index !== expr.length || Number.isNaN(result)) { return { value: null, error: 'Basic arithmetic evaluation failed' }; } diff --git a/packages/vtable-sheet/src/test-shims/vtable-plugins.ts b/packages/vtable-sheet/src/test-shims/vtable-plugins.ts index 4a62bd9719..1c426b09c3 100644 --- a/packages/vtable-sheet/src/test-shims/vtable-plugins.ts +++ b/packages/vtable-sheet/src/test-shims/vtable-plugins.ts @@ -1,4 +1,4 @@ -export * from '@visactor/vtable-plugins/cjs/index.js'; +export * from '../../../vtable-plugins/src/index'; const { HistoryPlugin } = require('../../../vtable-plugins/src/history'); const { FilterPlugin } = require('../../../vtable-plugins/src/filter'); From 5d4c10dd194a4f7a7ea4b8e0bb2398216a7a81d1 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 20 Aug 2026 10:15:49 +0800 Subject: [PATCH 40/44] fix(vtable): type row move record path lookup Use ListTableAPI for row move record path lookup. Avoid hardcoded column indexes when moving rows. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/core/BaseTable.ts | 7 ++++--- packages/vtable/src/ts-types/table-engine.ts | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 61ee1b881d..25c3d24752 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -3605,11 +3605,12 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { if ( this.isListTable() && - !(this as any).transpose && + !(this as unknown as ListTableAPI).transpose && (this.isSeriesNumberInBody(args.source.col, args.source.row) || args.movingColumnOrRow === 'row') ) { - const sourceRecordPath = (this as any).getRecordIndexByCell(0, moveContext.sourceIndex); - const targetRecordPath = (this as any).getRecordIndexByCell(0, moveContext.targetIndex); + const listTable = this as unknown as ListTableAPI; + const sourceRecordPath = listTable.getRecordIndexByCell(args.source.col, moveContext.sourceIndex); + const targetRecordPath = listTable.getRecordIndexByCell(args.target.col, moveContext.targetIndex); this.changeRecordOrder(moveContext.sourceIndex, moveContext.targetIndex); this.stateManager.changeCheckboxOrder(sourceRecordPath, targetRecordPath); this.stateManager.changeRadioOrder(sourceRecordPath, targetRecordPath); diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 4c540bd72f..ac4ce2c794 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -364,6 +364,8 @@ export interface ListTableAPI extends BaseTableAPI { internalProps: ListTableProtected; isListTable: () => true; isPivotTable: () => false; + /** 获取当前单元格对应源数据 records 的 index;树形表格返回 children 路径。 */ + getRecordIndexByCell: (col: number, row: number) => number | number[]; /** 设置单元格的value值,注意对应的是源数据的原始值,vtable实例records会做对应修改 */ changeCellValue: ( col: number, From f049f3afcba65a826d9c1a2810d309cca99bf6bf Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 20 Aug 2026 11:09:39 +0800 Subject: [PATCH 41/44] fix(vtable): move serialized radio record paths Handle string record paths when migrating radio state. Cover serialized tree paths in row move tests. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/listTable-checkbox-record-index.test.ts | 10 ++++++++++ packages/vtable/src/state/radio/radio.ts | 7 +++++-- packages/vtable/src/state/state.ts | 5 ++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts index 70f685e96c..3d1fb90e11 100644 --- a/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts +++ b/packages/vtable/__tests__/listTable-checkbox-record-index.test.ts @@ -249,6 +249,16 @@ describe('ListTable checkbox record index api', () => { expect(radioState.cellRadio['0,10']).toBe(true); }); + test('moves serialized radio record path state', () => { + const radioState = { + columnRadio: '0,1' + }; + + changeRadioOrder([0, 1], [0, 2], { radioState }); + + expect(radioState.columnRadio).toBe('0,2'); + }); + test('keeps radio state unchanged for unsupported cross-parent row moves', () => { const radioState = { cellRadio: { diff --git a/packages/vtable/src/state/radio/radio.ts b/packages/vtable/src/state/radio/radio.ts index 1330e7d955..4225a0a293 100644 --- a/packages/vtable/src/state/radio/radio.ts +++ b/packages/vtable/src/state/radio/radio.ts @@ -1,11 +1,11 @@ -import { isBoolean, isNumber, isObject, isValid } from '@visactor/vutils'; +import { isBoolean, isNumber, isObject, isString, isValid } from '@visactor/vutils'; import type { StateManager } from '../state'; import type { BaseTableAPI } from '../../ts-types/base-table'; import type { ColumnDefine } from '../../ts-types'; import type { Radio } from '@src/vrender'; type RecordPath = number | number[]; -type RadioStateValue = boolean | number | number[] | Record; +type RadioStateValue = boolean | number | string | number[] | Record; export function setRadioState( col: number, @@ -214,6 +214,9 @@ function changeRadioStateValue(value: RadioStateValue, sourcePath: number[], tar if (Array.isArray(value)) { return getRadioStateValue(getChangedRecordPath(value, sourcePath, targetPath)); } + if (isString(value)) { + return getRadioStateKey(getChangedRecordPath(parseRecordPathKey(value), sourcePath, targetPath)); + } if (isObject(value)) { const nextValue: Record = {}; Object.keys(value).forEach(key => { diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 020daf780c..534370b9c5 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -222,7 +222,10 @@ export class StateManager { _headerCheckFuncs: Record = {}; - radioState: Record> = {}; + radioState: Record< + string | number, + boolean | number | string | number[] | Record + > = {}; // 供滚动重置为default使用 resetInteractionState = debounce((state?: InteractionState) => { this.updateInteractionState(state ?? InteractionState.default); From 055c808e55c086f4343c1986449dd13e1ec0ccf6 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 20 Aug 2026 11:13:34 +0800 Subject: [PATCH 42/44] fix(vtable-sheet): allow spaced unary plus formulas Keep formula calculation consistent with validation for spaced unary plus. Add regression coverage for the supported syntax. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../vtable-sheet/__tests__/nested-formula-engine.test.ts | 5 +++++ packages/vtable-sheet/src/formula/formula-engine.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts b/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts index 083d81e1bf..b3b93459a6 100644 --- a/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts +++ b/packages/vtable-sheet/__tests__/nested-formula-engine.test.ts @@ -220,6 +220,10 @@ describe('FormulaEngine', () => { value: null, error: 'Basic arithmetic evaluation failed' }); + expect(engine.calculateFormula('=1+ +1')).toEqual({ + value: 2, + error: undefined + }); }); test('应该正确处理除以零', () => { @@ -234,6 +238,7 @@ describe('FormulaEngine', () => { test('应该正确验证有效公式', () => { expect(engine.validateFormula('=SUM(1,2,3)')).toEqual({ isValid: true, error: undefined }); expect(engine.validateFormula('=A1+B1')).toEqual({ isValid: true, error: undefined }); + expect(engine.validateFormula('=1+ +1')).toEqual({ isValid: true, error: undefined }); expect(engine.validateFormula('=IF(1>0,1,0)')).toEqual({ isValid: true, error: undefined }); }); diff --git a/packages/vtable-sheet/src/formula/formula-engine.ts b/packages/vtable-sheet/src/formula/formula-engine.ts index 10052ba612..0db441b2a3 100644 --- a/packages/vtable-sheet/src/formula/formula-engine.ts +++ b/packages/vtable-sheet/src/formula/formula-engine.ts @@ -1545,7 +1545,7 @@ export class FormulaEngine { private evaluateBasicArithmetic(expr: string): { value: unknown; error?: string } { try { - if (/[+\-*/]\s*\+/.test(expr)) { + if (/[+\-*/]\+/.test(expr)) { return { value: null, error: 'Basic arithmetic evaluation failed' }; } From eee603d396c527598b5af014aeeda5f7a9213f8c Mon Sep 17 00:00:00 2001 From: fangsmile Date: Thu, 20 Aug 2026 08:16:04 +0000 Subject: [PATCH 43/44] build: release version 1.26.7 [skip ci] --- ...tooltip-appear-delay_2026-08-04-00-00.json | 11 ---- ...1-audio-cell-support_2026-08-19-20-00.json | 11 ---- ...rge-merge-autoheight_2026-08-19-17-19.json | 11 ---- ...ee-checkbox-row-move_2026-08-19-19-18.json | 11 ---- ...ext-menu-click-event_2026-08-19-20-00.json | 11 ---- ...der-lazy-data-source_2026-08-04-16-08.json | 11 ---- ...5-vue-frozen-row-dom_2026-08-04-14-53.json | 11 ---- ...77-frozen-row-border_2026-08-19-20-00.json | 11 ---- ...records-header-blank_2026-08-19-20-00.json | 11 ---- common/config/rush/pnpm-lock.yaml | 58 +++++++++---------- common/config/rush/version-policies.json | 2 +- docs/assets/changelog/en/release.md | 23 ++++++++ docs/assets/changelog/zh/release.md | 23 ++++++++ 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 | 40 +++++++++++++ packages/vtable/CHANGELOG.md | 23 +++++++- packages/vtable/package.json | 4 +- packages/vue-vtable/package.json | 4 +- tools/bugserver-trigger/package.json | 10 ++-- 28 files changed, 178 insertions(+), 170 deletions(-) delete mode 100644 common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json delete mode 100644 common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json delete mode 100644 common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json diff --git a/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json b/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json deleted file mode 100644 index 8a142a0c16..0000000000 --- a/common/changes/@visactor/vtable/feat-issue-5112-tooltip-appear-delay_2026-08-04-00-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "comment": "feat: support tooltip appear delay", - "type": "minor", - "packageName": "@visactor/vtable" - } - ], - "packageName": "@visactor/vtable", - "email": "github@visactor.io" -} diff --git a/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json b/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json deleted file mode 100644 index 4174e2dded..0000000000 --- a/common/changes/@visactor/vtable/feat-issue-5271-audio-cell-support_2026-08-19-20-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "feat: add audio cell type support (GitHub #5271)", - "type": "minor" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json b/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json deleted file mode 100644 index 14e9476878..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-3752-large-merge-autoheight_2026-08-19-17-19.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json b/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json deleted file mode 100644 index 70a0b123bc..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-3975-tree-checkbox-row-move_2026-08-19-19-18.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: preserve tree checkbox state after row drag reorder (GitHub #3975)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json deleted file mode 100644 index 6f0bd2b7e9..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-4655-context-menu-click-event_2026-08-19-20-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: add context menu click event typing (GitHub #4655)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json b/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json deleted file mode 100644 index eb9c8a08bc..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-4964-custom-render-lazy-data-source_2026-08-04-16-08.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json b/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json deleted file mode 100644 index ecb1ab5af1..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-5265-vue-frozen-row-dom_2026-08-04-14-53.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "comment": "fix: render vue components in top frozen rows", - "type": "none", - "packageName": "@visactor/vtable" - } - ], - "packageName": "@visactor/vtable", - "email": "biukam.w@gmail.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json deleted file mode 100644 index 22f7efc848..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-5277-frozen-row-border_2026-08-19-20-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: align frame border with short frozen rows (GitHub #5277)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json b/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json deleted file mode 100644 index 633f5fd0e9..0000000000 --- a/common/changes/@visactor/vtable/fix-issue-5278-setrecords-header-blank_2026-08-19-20-00.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "changes": [ - { - "packageName": "@visactor/vtable", - "comment": "fix: keep headers after setRecords at scroll edge (GitHub #5278)", - "type": "patch" - } - ], - "packageName": "@visactor/vtable", - "email": "892739385@qq.com" -} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 97130679ff..e266a022cb 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -27,34 +27,34 @@ importers: specifier: ^2.11.0 version: 2.57.0(vue@3.5.31(typescript@4.9.5)) '@visactor/openinula-vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/openinula-vtable '@visactor/react-vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/react-vtable '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-editors '@visactor/vtable-export': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-export '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-gantt '@visactor/vtable-search': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-search '@visactor/vtable-sheet': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vtable-sheet '@visactor/vue-vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../packages/vue-vtable '@visactor/vutils': specifier: ~1.0.17 @@ -148,7 +148,7 @@ importers: ../../packages/openinula-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -278,7 +278,7 @@ importers: ../../packages/react-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -456,7 +456,7 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -640,7 +640,7 @@ importers: ../../packages/vtable-calendar: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -863,7 +863,7 @@ importers: specifier: 2.0.7 version: 2.0.7 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1023,10 +1023,10 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vutils': specifier: ~1.0.17 @@ -1259,13 +1259,13 @@ importers: specifier: 2.1.3 version: 2.1.3 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-gantt '@vitejs/plugin-react': specifier: 3.1.0 @@ -1364,7 +1364,7 @@ importers: ../../packages/vtable-search: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1518,13 +1518,13 @@ importers: specifier: ~1.0.17 version: 1.0.23 '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-editors '@visactor/vtable-plugins': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable-plugins '@visactor/vutils': specifier: ~1.0.17 @@ -1663,7 +1663,7 @@ importers: ../../packages/vue-vtable: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../vtable '@visactor/vutils': specifier: ~1.0.17 @@ -1844,19 +1844,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vtable': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable '@visactor/vtable-editors': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-editors '@visactor/vtable-gantt': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-gantt '@visactor/vtable-plugins': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-plugins '@visactor/vtable-sheet': - specifier: workspace:1.26.6 + specifier: workspace:1.26.7 version: link:../../packages/vtable-sheet devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 8110d7329f..2fa348fd6c 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.6","mainProject":"@visactor/vtable","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vtableMain","version":"1.26.7","mainProject":"@visactor/vtable","nextBump":"patch"}] diff --git a/docs/assets/changelog/en/release.md b/docs/assets/changelog/en/release.md index 6dc535311f..8a3c7c18e3 100644 --- a/docs/assets/changelog/en/release.md +++ b/docs/assets/changelog/en/release.md @@ -1,3 +1,26 @@ +# v1.26.7 + +2026-08-20 + +**🆕 New Features** + +- **@visactor/vtable**: support tooltip appear delay +- **@visactor/vtable**: add audio cell type support (GitHub #5271) + +**🐛 Bug Fixes** + +- **@visactor/vtable**: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752) +- **@visactor/vtable**: preserve tree checkbox state after row drag reorder (GitHub #3975) +- **@visactor/vtable**: add context menu click event typing (GitHub #4655) +- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- **@visactor/vtable**: render vue components in top frozen rows +- **@visactor/vtable**: align frame border with short frozen rows (GitHub #5277) +- **@visactor/vtable**: keep headers after setRecords at scroll edge (GitHub #5278) + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 + +[more detail about v1.26.7](https://github.com/VisActor/VTable/releases/tag/v1.26.7) + # v1.26.6 2026-08-03 diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index ea1efd7643..7337a5a02a 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -1,3 +1,26 @@ +# v1.26.7 + +2026-08-20 + +**🆕 新增功能** + +- **@visactor/vtable**: 支持 Tooltip appear delay +- **@visactor/vtable**: 新增 audio cell type support (GitHub #5271) + +**🐛 问题修复** + +- **@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**: 新增 context menu click event typing (GitHub #4655) +- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- **@visactor/vtable**: render vue components in top frozen rows +- **@visactor/vtable**: align frame border with short frozen rows (GitHub #5277) +- **@visactor/vtable**: keep headers after setRecords at scroll edge (GitHub #5278) + +**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 + +[更多详情请查看 v1.26.7](https://github.com/VisActor/VTable/releases/tag/v1.26.7) + # v1.26.6 2026-08-03 diff --git a/docs/package.json b/docs/package.json index 040de82b26..cbab95dc3f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,15 +12,15 @@ "dependencies": { "@arco-design/web-react": "2.66.12", "@arco-design/web-vue": "^2.11.0", - "@visactor/vtable": "workspace:1.26.6", - "@visactor/vtable-gantt": "workspace:1.26.6", - "@visactor/react-vtable": "workspace:1.26.6", - "@visactor/vue-vtable": "workspace:1.26.6", - "@visactor/openinula-vtable": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-export": "workspace:1.26.6", - "@visactor/vtable-search": "workspace:1.26.6", - "@visactor/vtable-sheet": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-gantt": "workspace:1.26.7", + "@visactor/react-vtable": "workspace:1.26.7", + "@visactor/vue-vtable": "workspace:1.26.7", + "@visactor/openinula-vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-export": "workspace:1.26.7", + "@visactor/vtable-search": "workspace:1.26.7", + "@visactor/vtable-sheet": "workspace:1.26.7", "buble": "^0.20.0", "@visactor/vchart": "2.1.3", "markdown-it": "^13.0.0", diff --git a/packages/openinula-vtable/package.json b/packages/openinula-vtable/package.json index 9ca95d26a1..78be8cbcd4 100644 --- a/packages/openinula-vtable/package.json +++ b/packages/openinula-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/openinula-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The openinula version of VTable", "keywords": [ "openinula", @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 1cdc31dfb4..2df5245aa8 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The react version of VTable", "keywords": [ "react", @@ -58,7 +58,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "react-is": "^18.2.0", "react-reconciler": "0.29.0" diff --git a/packages/vtable-calendar/package.json b/packages/vtable-calendar/package.json index 4444fbd5ff..38dc37ccae 100644 --- a/packages/vtable-calendar/package.json +++ b/packages/vtable-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-calendar", - "version": "1.26.6", + "version": "1.26.7", "description": "The calendar component of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "date-fns": "3.6.0" }, diff --git a/packages/vtable-editors/package.json b/packages/vtable-editors/package.json index 49508217a3..24c8edb847 100644 --- a/packages/vtable-editors/package.json +++ b/packages/vtable-editors/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-editors", - "version": "1.26.6", + "version": "1.26.7", "description": "", "sideEffects": false, "main": "cjs/index.js", diff --git a/packages/vtable-export/package.json b/packages/vtable-export/package.json index 8fceb141f8..a431f3c962 100644 --- a/packages/vtable-export/package.json +++ b/packages/vtable-export/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-export", - "version": "1.26.6", + "version": "1.26.7", "description": "The export util of VTable", "author": { "name": "VisActor", @@ -37,7 +37,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "file-saver": "2.0.5", "@types/file-saver": "2.0.7", diff --git a/packages/vtable-gantt/package.json b/packages/vtable-gantt/package.json index 06e96c8088..baca2d03d9 100644 --- a/packages/vtable-gantt/package.json +++ b/packages/vtable-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-gantt", - "version": "1.26.6", + "version": "1.26.7", "description": "canvas table width high performance", "keywords": [ "vtable-gantt", @@ -50,8 +50,8 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable-plugins/package.json b/packages/vtable-plugins/package.json index 417b64ec98..0486e0cc86 100644 --- a/packages/vtable-plugins/package.json +++ b/packages/vtable-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-plugins", - "version": "1.26.6", + "version": "1.26.7", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -61,9 +61,9 @@ "devDependencies": { "cross-env": "^7.0.3", "increase-memory-limit": "^1.0.7", - "@visactor/vtable": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-gantt": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-gantt": "workspace:1.26.7", "@visactor/vchart": "2.1.3", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 9d988fcd35..7d0ff32ce1 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-search", - "version": "1.26.6", + "version": "1.26.7", "description": "The search util of VTable", "author": { "name": "VisActor", @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/packages/vtable-sheet/package.json b/packages/vtable-sheet/package.json index 93fb7abfde..f25091d3ef 100644 --- a/packages/vtable-sheet/package.json +++ b/packages/vtable-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable-sheet", - "version": "1.26.6", + "version": "1.26.7", "description": "Lightweight editable spreadsheet component based on VTable", "keywords": [ "vtable-sheet", @@ -50,9 +50,9 @@ "build:es": "bundle --clean -f es --ignorePostTasks" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-plugins": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-plugins": "workspace:1.26.7", "@visactor/vutils": "~1.0.17", "@visactor/vscale": "~1.0.17", "@visactor/vdataset": "~1.0.17", diff --git a/packages/vtable/CHANGELOG.json b/packages/vtable/CHANGELOG.json index f481544ad1..34e307d147 100644 --- a/packages/vtable/CHANGELOG.json +++ b/packages/vtable/CHANGELOG.json @@ -1,6 +1,46 @@ { "name": "@visactor/vtable", "entries": [ + { + "version": "1.26.7", + "tag": "@visactor/vtable_v1.26.7", + "date": "Thu, 20 Aug 2026 08:02:48 GMT", + "comments": { + "minor": [ + { + "comment": "feat: support tooltip appear delay" + }, + { + "comment": "feat: add audio cell type support (GitHub #5271)" + } + ], + "patch": [ + { + "comment": "fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752)" + }, + { + "comment": "fix: preserve tree checkbox state after row drag reorder (GitHub #3975)" + }, + { + "comment": "fix: add context menu click event typing (GitHub #4655)" + }, + { + "comment": "fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964)" + }, + { + "comment": "fix: align frame border with short frozen rows (GitHub #5277)" + }, + { + "comment": "fix: keep headers after setRecords at scroll edge (GitHub #5278)" + } + ], + "none": [ + { + "comment": "fix: render vue components in top frozen rows" + } + ] + } + }, { "version": "1.26.6", "tag": "@visactor/vtable_v1.26.6", diff --git a/packages/vtable/CHANGELOG.md b/packages/vtable/CHANGELOG.md index 1020fe84cb..8c454c62c8 100644 --- a/packages/vtable/CHANGELOG.md +++ b/packages/vtable/CHANGELOG.md @@ -1,6 +1,27 @@ # Change Log - @visactor/vtable -This log was last generated on Mon, 03 Aug 2026 11:18:03 GMT and should not be manually modified. +This log was last generated on Thu, 20 Aug 2026 08:02:48 GMT and should not be manually modified. + +## 1.26.7 +Thu, 20 Aug 2026 08:02:48 GMT + +### Minor changes + +- feat: support tooltip appear delay +- feat: add audio cell type support (GitHub #5271) + +### Patches + +- fix: prevent blank scrolling for large merged auto-height cells by keeping enough virtual rows (GitHub #3752) +- fix: preserve tree checkbox state after row drag reorder (GitHub #3975) +- fix: add context menu click event typing (GitHub #4655) +- fix: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) +- fix: align frame border with short frozen rows (GitHub #5277) +- fix: keep headers after setRecords at scroll edge (GitHub #5278) + +### Updates + +- fix: render vue components in top frozen rows ## 1.26.6 Mon, 03 Aug 2026 11:18:03 GMT diff --git a/packages/vtable/package.json b/packages/vtable/package.json index be98052aac..db7f9bb259 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "canvas table width high performance", "keywords": [ "grid", @@ -52,7 +52,7 @@ "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, "dependencies": { - "@visactor/vtable-editors": "workspace:1.26.6", + "@visactor/vtable-editors": "workspace:1.26.7", "@visactor/vrender": "1.1.4", "@visactor/vrender-core": "1.1.4", "@visactor/vrender-kits": "1.1.4", diff --git a/packages/vue-vtable/package.json b/packages/vue-vtable/package.json index 17bb510e44..c9337f18ee 100644 --- a/packages/vue-vtable/package.json +++ b/packages/vue-vtable/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vue-vtable", - "version": "1.26.6", + "version": "1.26.7", "description": "The vue version of VTable", "keywords": [ "vue", @@ -59,7 +59,7 @@ "access": "public" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", + "@visactor/vtable": "workspace:1.26.7", "@visactor/vutils": "~1.0.17" }, "devDependencies": { diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index 60f812acce..a744555a17 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vtable": "workspace:1.26.6", - "@visactor/vtable-gantt": "workspace:1.26.6", - "@visactor/vtable-editors": "workspace:1.26.6", - "@visactor/vtable-plugins": "workspace:1.26.6", - "@visactor/vtable-sheet": "workspace:1.26.6" + "@visactor/vtable": "workspace:1.26.7", + "@visactor/vtable-gantt": "workspace:1.26.7", + "@visactor/vtable-editors": "workspace:1.26.7", + "@visactor/vtable-plugins": "workspace:1.26.7", + "@visactor/vtable-sheet": "workspace:1.26.7" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4", From d6a4ec95dc9947978587fa81805e9eb03350b3c6 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 20 Aug 2026 16:53:22 +0800 Subject: [PATCH 44/44] docs: translate v1.26.7 changelog Co-Authored-By: Claude Sonnet 4.6 --- docs/assets/changelog/zh/release.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/assets/changelog/zh/release.md b/docs/assets/changelog/zh/release.md index 7337a5a02a..7c8059ff82 100644 --- a/docs/assets/changelog/zh/release.md +++ b/docs/assets/changelog/zh/release.md @@ -4,20 +4,20 @@ **🆕 新增功能** -- **@visactor/vtable**: 支持 Tooltip appear delay -- **@visactor/vtable**: 新增 audio cell type support (GitHub #5271) +- **@visactor/vtable**: 支持配置 Tooltip 展示延迟 +- **@visactor/vtable**: 新增音频单元格类型支持(GitHub #5271) **🐛 问题修复** -- **@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**: 新增 context menu click event typing (GitHub #4655) -- **@visactor/vtable**: avoid loading all lazy dataSource records during customRender auto size computation (GitHub #4964) -- **@visactor/vtable**: render vue components in top frozen rows -- **@visactor/vtable**: align frame border with short frozen rows (GitHub #5277) -- **@visactor/vtable**: keep headers after setRecords at scroll edge (GitHub #5278) +- **@visactor/vtable**: 通过保留足够的虚拟行,避免大范围合并单元格在自动行高下滚动出现空白(GitHub #3752) +- **@visactor/vtable**: 行拖拽重排后保持树形 checkbox 状态(GitHub #3975) +- **@visactor/vtable**: 新增右键菜单点击事件类型定义(GitHub #4655) +- **@visactor/vtable**: customRender 自动尺寸计算时避免加载全部懒加载 dataSource 记录(GitHub #4964) +- **@visactor/vtable**: 支持在顶部冻结行中渲染 Vue 组件 +- **@visactor/vtable**: 短数据且存在底部冻结行时,表格外框边框与内容高度对齐(GitHub #5277) +- **@visactor/vtable**: 在滚动边界调用 setRecords 后保持表头显示(GitHub #5278) -**Full Changelog**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 +**完整更新日志**: https://github.com/VisActor/VTable/compare/v1.26.6...v1.26.7 [更多详情请查看 v1.26.7](https://github.com/VisActor/VTable/releases/tag/v1.26.7)