From 0defa930b1c282c1d0f026466fc9b74f16a0906a Mon Sep 17 00:00:00 2001 From: Rui-Sun <25134429+Rui-Sun@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:16:20 +0800 Subject: [PATCH 01/11] feat(vtable): auto-merge rectangular body multi-ranges --- ...ct-auto-merge-ranges_2026-09-18-12-20.json | 10 + .../listTable-select-auto-merge.test.ts | 228 ++++++++++++++++++ .../src/state/select/auto-merge-ranges.ts | 214 ++++++++++++++++ packages/vtable/src/state/state.ts | 6 + packages/vtable/src/ts-types/base-table.ts | 2 + 5 files changed, 460 insertions(+) create mode 100644 common/changes/@visactor/vtable/feat-select-auto-merge-ranges_2026-09-18-12-20.json create mode 100644 packages/vtable/__tests__/listTable-select-auto-merge.test.ts create mode 100644 packages/vtable/src/state/select/auto-merge-ranges.ts diff --git a/common/changes/@visactor/vtable/feat-select-auto-merge-ranges_2026-09-18-12-20.json b/common/changes/@visactor/vtable/feat-select-auto-merge-ranges_2026-09-18-12-20.json new file mode 100644 index 0000000000..ce2969386d --- /dev/null +++ b/common/changes/@visactor/vtable/feat-select-auto-merge-ranges_2026-09-18-12-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat(vtable): support auto-merging rectangular body multi-ranges", + "type": "minor" + } + ], + "packageName": "@visactor/vtable" +} diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts new file mode 100644 index 0000000000..f1874e7db9 --- /dev/null +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -0,0 +1,228 @@ +// @ts-nocheck +import { ListTable } from '../src'; +import { createDiv, removeDom } from './dom'; + +global.__VERSION__ = 'none'; + +const columns = Array.from({ length: 6 }, (_, index) => ({ + field: `f${index}`, + title: `F${index}`, + width: 120 +})); + +const records = Array.from({ length: 8 }, (_, rowIndex) => { + const record = {}; + columns.forEach((column, columnIndex) => { + record[column.field] = `r${rowIndex}-c${columnIndex}`; + }); + return record; +}); + +describe('listTable select auto merge ranges', () => { + const tables: ListTable[] = []; + const containers: HTMLElement[] = []; + + function createTable(select = {}, extraOptions = {}) { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '900px'; + container.style.height = '600px'; + + const table = new ListTable({ + container, + columns, + records, + defaultColWidth: 120, + select, + ...extraOptions + }); + + tables.push(table); + containers.push(container); + return table; + } + + function getMainBodyCell(table: ListTable, colOffset = 0, rowOffset = 0) { + return { + col: Math.max(table.rowHeaderLevelCount + table.leftRowSeriesNumberCount, table.frozenColCount) + colOffset, + row: Math.max(table.columnHeaderLevelCount, table.frozenRowCount) + rowOffset + }; + } + + function addSkipBodyMerge(range) { + return { + start: { ...range.start }, + end: { ...range.end }, + skipBodyMerge: true + }; + } + + function cloneRanges(ranges) { + return ranges.map(range => ({ + start: { ...range.start }, + end: { ...range.end }, + ...(range.skipBodyMerge ? { skipBodyMerge: true } : {}) + })); + } + + function dragSelect(table: ListTable, start, end, enableCtrlSelectMode = false) { + table.stateManager.updateSelectPos(start.col, start.row, false, enableCtrlSelectMode, false, false, true); + table.stateManager.updateInteractionState('grabing'); + table.stateManager.updateSelectPos(end.col, end.row, false, enableCtrlSelectMode, false, false, true); + table.stateManager.endSelectCells(true, false); + table.stateManager.updateInteractionState('default'); + } + + afterEach(() => { + tables.splice(0).forEach(table => table.release()); + containers.splice(0).forEach(container => removeDom(container)); + }); + + test('keeps the original multi-range result when auto merge is disabled', () => { + const table = createTable(); + const start = getMainBodyCell(table, 1, 1); + + table.selectCells([ + { start, end: { col: start.col + 1, row: start.row + 1 } }, + { start: { col: start.col + 2, row: start.row }, end: { col: start.col + 3, row: start.row + 1 } } + ]); + + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ start, end: { col: start.col + 1, row: start.row + 1 } }), + addSkipBodyMerge({ + start: { col: start.col + 2, row: start.row }, + end: { col: start.col + 3, row: start.row + 1 } + }) + ]); + }); + + test('merges adjacent and overlapping body ranges into a single rectangle', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + + table.selectCells([ + { start, end: { col: start.col + 1, row: start.row + 1 } }, + { start: { col: start.col + 2, row: start.row }, end: { col: start.col + 3, row: start.row + 1 } } + ]); + + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ + start, + end: { col: start.col + 3, row: start.row + 1 } + }) + ]); + + table.selectCells([ + { start, end: { col: start.col + 2, row: start.row + 1 } }, + { start: { col: start.col + 1, row: start.row }, end: { col: start.col + 3, row: start.row + 1 } } + ]); + + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ + start, + end: { col: start.col + 3, row: start.row + 1 } + }) + ]); + }); + + test.each([ + { + name: 'L-shaped ranges', + ranges: start => [ + { start, end: start }, + { start: { col: start.col + 1, row: start.row }, end: { col: start.col + 1, row: start.row } }, + { start: { col: start.col, row: start.row + 1 }, end: { col: start.col, row: start.row + 1 } } + ] + }, + { + name: 'ranges with a hole', + ranges: start => [ + { start, end: { col: start.col + 2, row: start.row } }, + { start: { col: start.col, row: start.row + 1 }, end: { col: start.col, row: start.row + 2 } }, + { + start: { col: start.col + 2, row: start.row + 1 }, + end: { col: start.col + 2, row: start.row + 2 } + }, + { start: { col: start.col + 1, row: start.row + 2 }, end: { col: start.col + 1, row: start.row + 2 } } + ] + }, + { + name: 'disconnected ranges', + ranges: start => [ + { start, end: { col: start.col + 1, row: start.row } }, + { start: { col: start.col + 3, row: start.row }, end: { col: start.col + 4, row: start.row } } + ] + } + ])('keeps multiple ranges for $name', ({ ranges }) => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + const selectedRanges = ranges(start); + + table.selectCells(selectedRanges); + + expect(table.getSelectedCellRanges()).toEqual(selectedRanges.map(addSkipBodyMerge)); + }); + + test('uses the merged ranges for ctrl multi-select and selected_cell events', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + const selectedEvents = []; + + table.on('selected_cell', event => { + selectedEvents.push(cloneRanges(event.ranges)); + }); + + table.selectCell(start.col, start.row, false, false, false, true); + table.selectCell(start.col + 1, start.row, false, true, false, true); + + const expectedRange = addSkipBodyMerge({ + start, + end: { col: start.col + 1, row: start.row } + }); + + expect(table.getSelectedCellRanges()).toEqual([expectedRange]); + expect(selectedEvents[selectedEvents.length - 1]).toEqual([expectedRange]); + }); + + test('uses the same merge logic for drag multi-select', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + + dragSelect(table, start, { col: start.col + 1, row: start.row }); + dragSelect(table, { col: start.col + 2, row: start.row }, { col: start.col + 3, row: start.row }, true); + + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ + start, + end: { col: start.col + 3, row: start.row } + }) + ]); + }); + + test('does not merge ranges across frozen body partitions', () => { + const table = createTable({ autoMergeRanges: true }, { frozenColCount: 2 }); + const row = getMainBodyCell(table, 0, 1).row; + + table.selectCells([ + { + start: { col: table.frozenColCount - 1, row }, + end: { col: table.frozenColCount - 1, row } + }, + { + start: { col: table.frozenColCount, row }, + end: { col: table.frozenColCount, row } + } + ]); + + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ + start: { col: table.frozenColCount - 1, row }, + end: { col: table.frozenColCount - 1, row } + }), + addSkipBodyMerge({ + start: { col: table.frozenColCount, row }, + end: { col: table.frozenColCount, row } + }) + ]); + }); +}); diff --git a/packages/vtable/src/state/select/auto-merge-ranges.ts b/packages/vtable/src/state/select/auto-merge-ranges.ts new file mode 100644 index 0000000000..d13c4f5647 --- /dev/null +++ b/packages/vtable/src/state/select/auto-merge-ranges.ts @@ -0,0 +1,214 @@ +import type { CellRange } from '../../ts-types'; +import type { BaseTableAPI } from '../../ts-types/base-table'; + +type SelectRange = CellRange & { skipBodyMerge?: boolean }; + +type IndexedRange = { + index: number; + range: SelectRange; + normalizedRange: CellRange; +}; + +/** + * 将普通 body 主区域内、连通且恰好铺满外接矩形的多选区合并成单个矩形选区。 + */ +export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): SelectRange[] { + if (!table.options.select?.autoMergeRanges || ranges.length < 2) { + return ranges; + } + + const indexedRanges = ranges.map((range, index) => ({ + index, + range, + normalizedRange: normalizeRange(range) + })); + const eligibleRanges = indexedRanges.filter(range => isMainBodyRange(range.normalizedRange, table)); + + if (eligibleRanges.length < 2) { + return ranges; + } + + const visited = new Set(); + const mergedComponents = new Map(); + const removedIndexes = new Set(); + + for (let i = 0; i < eligibleRanges.length; i++) { + const componentSeed = eligibleRanges[i]; + if (visited.has(componentSeed.index)) { + continue; + } + + const component: IndexedRange[] = []; + const queue = [componentSeed]; + visited.add(componentSeed.index); + + while (queue.length) { + const current = queue.shift(); + if (!current) { + continue; + } + component.push(current); + + for (let nextIndex = 0; nextIndex < eligibleRanges.length; nextIndex++) { + const candidate = eligibleRanges[nextIndex]; + if (visited.has(candidate.index)) { + continue; + } + if (!isConnectedRange(current.normalizedRange, candidate.normalizedRange)) { + continue; + } + visited.add(candidate.index); + queue.push(candidate); + } + } + + if (component.length < 2) { + continue; + } + + const boundingRange = getBoundingRange(component); + const boundingArea = getRangeArea(boundingRange); + const unionArea = getUnionArea(component.map(item => item.normalizedRange)); + if (unionArea !== boundingArea) { + continue; + } + + const componentIndexes = component.map(item => item.index).sort((a, b) => a - b); + const targetIndex = componentIndexes[componentIndexes.length - 1]; + + mergedComponents.set(targetIndex, { + start: { ...boundingRange.start }, + end: { ...boundingRange.end }, + ...(component.every(item => item.range.skipBodyMerge) ? { skipBodyMerge: true } : {}) + }); + componentIndexes + .filter(index => index !== targetIndex) + .forEach(index => { + removedIndexes.add(index); + }); + } + + if (!mergedComponents.size) { + return ranges; + } + + return ranges.reduce((result, range, index) => { + if (removedIndexes.has(index)) { + return result; + } + result.push(mergedComponents.get(index) ?? range); + return result; + }, []); +} + +function normalizeRange(range: CellRange): CellRange { + return { + start: { + col: Math.min(range.start.col, range.end.col), + row: Math.min(range.start.row, range.end.row) + }, + end: { + col: Math.max(range.start.col, range.end.col), + row: Math.max(range.start.row, range.end.row) + } + }; +} + +function isMainBodyRange(range: CellRange, table: BaseTableAPI): boolean { + const startCol = Math.max(table.rowHeaderLevelCount + table.leftRowSeriesNumberCount, table.frozenColCount); + const startRow = Math.max(table.columnHeaderLevelCount, table.frozenRowCount); + const endCol = table.colCount - table.rightFrozenColCount - 1; + const endRow = table.rowCount - table.bottomFrozenRowCount - 1; + + if (startCol > endCol || startRow > endRow) { + return false; + } + + return ( + range.start.col >= startCol && + range.end.col <= endCol && + range.start.row >= startRow && + range.end.row <= endRow && + table.getCellLocation(range.start.col, range.start.row) === 'body' && + table.getCellLocation(range.end.col, range.end.row) === 'body' + ); +} + +function isConnectedRange(left: CellRange, right: CellRange): boolean { + const rowOverlap = left.start.row <= right.end.row && right.start.row <= left.end.row; + const colOverlap = left.start.col <= right.end.col && right.start.col <= left.end.col; + if (rowOverlap && colOverlap) { + return true; + } + + const horizontalTouch = rowOverlap && (left.end.col + 1 === right.start.col || right.end.col + 1 === left.start.col); + if (horizontalTouch) { + return true; + } + + return colOverlap && (left.end.row + 1 === right.start.row || right.end.row + 1 === left.start.row); +} + +function getBoundingRange(ranges: IndexedRange[]): CellRange { + return ranges.reduce( + (boundingRange, item) => ({ + start: { + col: Math.min(boundingRange.start.col, item.normalizedRange.start.col), + row: Math.min(boundingRange.start.row, item.normalizedRange.start.row) + }, + end: { + col: Math.max(boundingRange.end.col, item.normalizedRange.end.col), + row: Math.max(boundingRange.end.row, item.normalizedRange.end.row) + } + }), + { + start: { ...ranges[0].normalizedRange.start }, + end: { ...ranges[0].normalizedRange.end } + } + ); +} + +function getRangeArea(range: CellRange): number { + return (range.end.col - range.start.col + 1) * (range.end.row - range.start.row + 1); +} + +function getUnionArea(ranges: CellRange[]): number { + const xBoundaries = Array.from( + new Set(ranges.flatMap(range => [range.start.col, range.end.col + 1])) + ).sort((left, right) => left - right); + let area = 0; + + for (let i = 0; i < xBoundaries.length - 1; i++) { + const xStart = xBoundaries[i]; + const xEnd = xBoundaries[i + 1]; + if (xStart === xEnd) { + continue; + } + + const activeIntervals = ranges + .filter(range => range.start.col < xEnd && range.end.col + 1 > xStart) + .map(range => [range.start.row, range.end.row + 1] as const) + .sort((left, right) => left[0] - right[0]); + + if (!activeIntervals.length) { + continue; + } + + let coveredHeight = 0; + let [currentStart, currentEnd] = activeIntervals[0]; + for (let intervalIndex = 1; intervalIndex < activeIntervals.length; intervalIndex++) { + const [nextStart, nextEnd] = activeIntervals[intervalIndex]; + if (nextStart > currentEnd) { + coveredHeight += currentEnd - currentStart; + currentStart = nextStart; + currentEnd = nextEnd; + continue; + } + currentEnd = Math.max(currentEnd, nextEnd); + } + coveredHeight += currentEnd - currentStart; + area += (xEnd - xStart) * coveredHeight; + } + + return area; +} diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index f5f9060f8f..36f427bd6e 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -54,6 +54,7 @@ import type { PivotTable } from '../PivotTable'; import { traverseObject } from '../tools/util'; import type { ColumnData } from '../ts-types/list-table/layout-map/api'; import { addCustomSelectRanges, deletaCustomSelectRanges } from './select/custom-select'; +import { autoMergeCellRanges } from './select/auto-merge-ranges'; import { expendCellRange } from '../tools/merge-range'; export class StateManager { @@ -827,6 +828,11 @@ export class StateManager { this.table.scenegraph.selectingRangeComponents.clear(); } else { selectEnd(this.table.scenegraph); + const mergedRanges = autoMergeCellRanges(this.select.ranges, this.table); + if (mergedRanges !== this.select.ranges) { + this.select.ranges = mergedRanges; + this.table.scenegraph.recreateAllSelectRangeComponents(); + } } // 触发SELECTED_CELL diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index 9c27788d55..139d8af147 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -462,6 +462,8 @@ export interface BaseTableConstructorOptions { highlightInRange?: boolean; /** 是否将选中的单元格自动滚动到视口内 默认为true */ makeSelectCellVisible?: boolean; + /** 是否自动归并普通 body 主区域内可拼成完整矩形的多选范围 */ + autoMergeRanges?: boolean; /** 右键点击单元格是否禁用选择单元格 */ disableSelectOnContextMenu?: boolean; }; From 068ce10bf9952261756398061882419a3fc8dfea Mon Sep 17 00:00:00 2001 From: Rui-Sun <25134429+Rui-Sun@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:50:09 +0800 Subject: [PATCH 02/11] feat(vtable): auto-merge rectangular body multi-ranges --- .../__tests__/listTable-select-auto-merge.test.ts | 14 ++++++++++++++ packages/vtable/src/core/BaseTable.ts | 3 ++- packages/vtable/src/state/state.ts | 6 +++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index f1874e7db9..eaff08a6b2 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -163,6 +163,20 @@ describe('listTable select auto merge ranges', () => { expect(table.getSelectedCellRanges()).toEqual(selectedRanges.map(addSkipBodyMerge)); }); + test('merges a complete batch even when its last range is a duplicate', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + const next = { col: start.col + 1, row: start.row }; + + table.selectCells([ + { start, end: start }, + { start: next, end: next }, + { start: next, end: next } + ]); + + expect(table.getSelectedCellRanges()).toEqual([addSkipBodyMerge({ start, end: next })]); + }); + test('uses the merged ranges for ctrl multi-select and selected_cell events', () => { const table = createTable({ autoMergeRanges: true }); const start = getMainBodyCell(table, 1, 1); diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index c43a2b34cc..cc6b2df465 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -3361,7 +3361,8 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { true ); } - this.stateManager.endSelectCells(false, false); + // 批量设置选区时,收集完所有区域后再判断整个连通分量是否为矩形。 + this.stateManager.endSelectCells(false, false, index === cellRanges.length - 1); this.stateManager.updateInteractionState(InteractionState.default); }); // 选择后 会自动滚动到所选区域最后一行一列的位置 这里再设置回滚动前位置 diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 36f427bd6e..64fec9ee6f 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -788,7 +788,8 @@ export class StateManager { isSelecting(): boolean { return this.select.selecting; } - endSelectCells(fireListener: boolean = true, fireClear: boolean = true) { + /** 结束当前选区;批量设置时可延迟到最后一个区域再执行多选区合并。 */ + endSelectCells(fireListener: boolean = true, fireClear: boolean = true, mergeRanges: boolean = true) { if (this.select.selecting) { this.select.selecting = false; if (this.select.ranges.length === 0) { @@ -828,6 +829,9 @@ export class StateManager { this.table.scenegraph.selectingRangeComponents.clear(); } else { selectEnd(this.table.scenegraph); + } + // 即使最后一项是重复区域,也要在去重后完成整批选区的合并。 + if (mergeRanges) { const mergedRanges = autoMergeCellRanges(this.select.ranges, this.table); if (mergedRanges !== this.select.ranges) { this.select.ranges = mergedRanges; From 45262f4544b4cee7ae8e16e5b1ddc9c2ceaf47e6 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 16:24:58 +0800 Subject: [PATCH 03/11] fix: preserve header selection origins and selection event coordinates --- .../listTable-select-auto-merge.test.ts | 42 +++++++++++++++++++ .../src/state/select/auto-merge-ranges.ts | 11 ++++- .../src/state/select/update-position.ts | 4 ++ packages/vtable/src/state/state.ts | 4 +- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index eaff08a6b2..7c5893136b 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -198,6 +198,48 @@ describe('listTable select auto merge ranges', () => { expect(selectedEvents[selectedEvents.length - 1]).toEqual([expectedRange]); }); + test('keeps header body projections separate when later body cells are selected', () => { + const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); + + table.selectCell(2, 0, false, false, false); + table.selectCell(3, 0, false, true, false); + const projections = [2, 3].map(col => + addSkipBodyMerge({ start: { col, row: table.columnHeaderLevelCount }, end: { col, row: table.rowCount - 1 } }) + ); + expect(table.getSelectedCellRanges()).toEqual(projections); + + table.selectCell(4, 2, false, true, false, true); + expect(table.getSelectedCellRanges()).toEqual([ + ...projections, + addSkipBodyMerge({ start: { col: 4, row: 2 }, end: { col: 4, row: 2 } }) + ]); + }); + + test('still merges explicit body API ranges with headerSelectMode body', () => { + const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); + table.selectCells([ + { start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }, + { start: { col: 3, row: 2 }, end: { col: 3, row: 3 } } + ]); + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ start: { col: 2, row: 2 }, end: { col: 3, row: 3 } }) + ]); + }); + + test('preserves the last selected_cell coordinates when Ctrl selection merges leftwards', () => { + const table = createTable({ autoMergeRanges: true }); + const events = []; + table.on('selected_cell', event => { + events.push({ col: event.col, row: event.row, ranges: cloneRanges(event.ranges) }); + }); + + table.selectCell(3, 2, false, false, false, true); + table.selectCell(2, 2, false, true, false, true); + const ranges = [addSkipBodyMerge({ start: { col: 2, row: 2 }, end: { col: 3, row: 2 } })]; + expect(table.getSelectedCellRanges()).toEqual(ranges); + expect(events[events.length - 1]).toEqual({ col: 2, row: 2, ranges }); + }); + test('uses the same merge logic for drag multi-select', () => { const table = createTable({ autoMergeRanges: true }); const start = getMainBodyCell(table, 1, 1); diff --git a/packages/vtable/src/state/select/auto-merge-ranges.ts b/packages/vtable/src/state/select/auto-merge-ranges.ts index d13c4f5647..205c7a2634 100644 --- a/packages/vtable/src/state/select/auto-merge-ranges.ts +++ b/packages/vtable/src/state/select/auto-merge-ranges.ts @@ -3,6 +3,13 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; type SelectRange = CellRange & { skipBodyMerge?: boolean }; +const headerSelectRanges = new WeakSet(); + +/** 保留表头投影选区的来源,不向公开选区对象增加字段。 */ +export function markHeaderSelectRange(range: CellRange): void { + headerSelectRanges.add(range); +} + type IndexedRange = { index: number; range: SelectRange; @@ -22,7 +29,9 @@ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): range, normalizedRange: normalizeRange(range) })); - const eligibleRanges = indexedRanges.filter(range => isMainBodyRange(range.normalizedRange, table)); + const eligibleRanges = indexedRanges.filter( + range => !headerSelectRanges.has(range.range) && isMainBodyRange(range.normalizedRange, table) + ); if (eligibleRanges.length < 2) { return ranges; diff --git a/packages/vtable/src/state/select/update-position.ts b/packages/vtable/src/state/select/update-position.ts index 02c93abf73..885471169a 100644 --- a/packages/vtable/src/state/select/update-position.ts +++ b/packages/vtable/src/state/select/update-position.ts @@ -6,6 +6,7 @@ import type { SelectAllOnCtrlAOption } from '../../ts-types'; import { InteractionState } from '../../ts-types'; import type { StateManager } from '../state'; import { isCellDisableSelect } from './is-cell-select-highlight'; +import { markHeaderSelectRange } from './auto-merge-ranges'; /** * @description: 更新select位置 * @param {StateManager} state @@ -340,6 +341,9 @@ export function updateSelectPosition( cellPos.row = row; // scenegraph.setCellNormalStyle(col, row); const currentRange = state.select.ranges?.[state.select.ranges.length - 1]; + if (currentRange && state.select.headerSelectMode !== 'cell' && table.isHeader(col, row)) { + markHeaderSelectRange(currentRange); + } if ( currentRange && currentRange.start.row <= table.rowCount - 1 && diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 78e613e0b4..01257fd810 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -831,6 +831,8 @@ export class StateManager { } else { selectEnd(this.table.scenegraph); } + // 归并只改变范围集合,selected_cell 仍报告原选择结束位置。 + const { col: lastCol, row: lastRow } = this.select.ranges[this.select.ranges.length - 1].end; // 即使最后一项是重复区域,也要在去重后完成整批选区的合并。 if (mergeRanges) { const mergedRanges = autoMergeCellRanges(this.select.ranges, this.table); @@ -841,8 +843,6 @@ export class StateManager { } // 触发SELECTED_CELL - const lastCol = this.select.ranges[this.select.ranges.length - 1].end.col; - const lastRow = this.select.ranges[this.select.ranges.length - 1].end.row; fireListener && this.table.fireListeners(TABLE_EVENT_TYPE.SELECTED_CELL, { ranges: this.select.ranges, From 90605414062217ff7b1f83548dea3cfc228faedf Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 16:41:39 +0800 Subject: [PATCH 04/11] fix: retain series and corner selection origins --- .../listTable-select-auto-merge.test.ts | 19 +++++++++++++++++++ .../src/state/select/auto-merge-ranges.ts | 2 +- .../src/state/select/update-position.ts | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index 7c5893136b..ea4ef25d82 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -215,6 +215,25 @@ describe('listTable select auto merge ranges', () => { ]); }); + test('keeps series number body projections separate', () => { + const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }, { rowSeriesNumber: {} }); + table.selectCell(0, 2, false, false, false); + table.selectCell(0, 3, false, true, false); + expect(table.getSelectedCellRanges()).toEqual([2, 3].map(row => + addSkipBodyMerge({ start: { col: table.leftRowSeriesNumberCount, row }, end: { col: table.colCount - 1, row } }) + )); + }); + + test('keeps corner body projections separate when ordinary headers use cell mode', () => { + const table = createTable({ autoMergeRanges: true, headerSelectMode: 'cell', cornerHeaderSelectMode: 'body' }, { rowSeriesNumber: {} }); + table.selectCell(0, 0, false, false, false); + table.selectCell(2, 2, false, true, false, true); + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ start: { col: table.leftRowSeriesNumberCount, row: table.columnHeaderLevelCount }, end: { col: table.colCount - 1, row: table.rowCount - 1 } }), + addSkipBodyMerge({ start: { col: 2, row: 2 }, end: { col: 2, row: 2 } }) + ]); + }); + test('still merges explicit body API ranges with headerSelectMode body', () => { const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); table.selectCells([ diff --git a/packages/vtable/src/state/select/auto-merge-ranges.ts b/packages/vtable/src/state/select/auto-merge-ranges.ts index 205c7a2634..dc59ccce1e 100644 --- a/packages/vtable/src/state/select/auto-merge-ranges.ts +++ b/packages/vtable/src/state/select/auto-merge-ranges.ts @@ -5,7 +5,7 @@ type SelectRange = CellRange & { skipBodyMerge?: boolean }; const headerSelectRanges = new WeakSet(); -/** 保留表头投影选区的来源,不向公开选区对象增加字段。 */ +/** 保留表头和序号投影选区的来源,不向公开选区对象增加字段。 */ export function markHeaderSelectRange(range: CellRange): void { headerSelectRanges.add(range); } diff --git a/packages/vtable/src/state/select/update-position.ts b/packages/vtable/src/state/select/update-position.ts index 885471169a..e6970b3717 100644 --- a/packages/vtable/src/state/select/update-position.ts +++ b/packages/vtable/src/state/select/update-position.ts @@ -341,7 +341,7 @@ export function updateSelectPosition( cellPos.row = row; // scenegraph.setCellNormalStyle(col, row); const currentRange = state.select.ranges?.[state.select.ranges.length - 1]; - if (currentRange && state.select.headerSelectMode !== 'cell' && table.isHeader(col, row)) { + if (currentRange && (table.isHeader(col, row) || table.isSeriesNumber(col, row))) { markHeaderSelectRange(currentRange); } if ( From d076a22cd98a0414201fdaf6097b380623d3891e Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 17:08:44 +0800 Subject: [PATCH 05/11] fix: cover shift projections and stabilize width-mode fixture --- .../vtable/__tests__/listTable-1W.test.ts | 24 ++++++++++++------- .../listTable-select-auto-merge.test.ts | 12 ++++++++++ .../src/state/select/update-position.ts | 8 ++++--- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/vtable/__tests__/listTable-1W.test.ts b/packages/vtable/__tests__/listTable-1W.test.ts index 9c018758e3..b48adf70c6 100644 --- a/packages/vtable/__tests__/listTable-1W.test.ts +++ b/packages/vtable/__tests__/listTable-1W.test.ts @@ -10,6 +10,13 @@ describe('listTable-1W init test', () => { containerDom.style.width = '1000px'; containerDom.style.height = '800px'; + let randomState = 0x12345678; + /** 固定万行数据的文本分布,让 autoWidth 的精确断言可以复现。 */ + function fixtureRandom() { + randomState = (Math.imul(1664525, randomState) + 1013904223) >>> 0; + return randomState / 0x100000000; + } + function generateRandomHobbies() { const hobbies = [ 'Reading books', @@ -26,11 +33,11 @@ describe('listTable-1W init test', () => { 'Swimming' ]; - const numHobbies = Math.floor(Math.random() * 3) + 1; // 生成 1-3 之间的随机整数 + const numHobbies = Math.floor(fixtureRandom() * 3) + 1; // 生成 1-3 之间的随机整数 const selectedHobbies = []; for (let i = 0; i < numHobbies; i++) { - const randomIndex = Math.floor(Math.random() * hobbies.length); + const randomIndex = Math.floor(fixtureRandom() * hobbies.length); const hobby = hobbies[randomIndex]; selectedHobbies.push(hobby); hobbies.splice(randomIndex, 1); // 确保每个爱好只选一次 @@ -38,13 +45,14 @@ describe('listTable-1W init test', () => { return selectedHobbies.join(', '); } + /** 固定UTC日期,避免执行机时区改变测试文本。 */ function generateRandomBirthday() { const start = new Date('1970-01-01'); const end = new Date('2000-12-31'); - const randomDate = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); - const year = randomDate.getFullYear(); - const month = randomDate.getMonth() + 1; - const day = randomDate.getDate(); + const randomDate = new Date(start.getTime() + fixtureRandom() * (end.getTime() - start.getTime())); + const year = randomDate.getUTCFullYear(); + const month = randomDate.getUTCMonth() + 1; + const day = randomDate.getUTCDate(); return `${year}-${month < 10 ? '0' + month : month}-${day < 10 ? '0' + day : day}`; } @@ -84,8 +92,8 @@ describe('listTable-1W init test', () => { '188', '189' ]; - const prefix = areaCode[Math.floor(Math.random() * areaCode.length)]; - const suffix = String(Math.random()).substr(2, 8); + const prefix = areaCode[Math.floor(fixtureRandom() * areaCode.length)]; + const suffix = String(fixtureRandom()).substr(2, 8); return prefix + suffix; } diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index ea4ef25d82..115678f5ad 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -234,6 +234,18 @@ describe('listTable select auto merge ranges', () => { ]); }); + test('keeps Shift header projections separate from existing ordinary body ranges', () => { + const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); + const first = { start: { col: 2, row: table.columnHeaderLevelCount }, end: { col: 2, row: table.rowCount - 1 } }; + table.selectCells([first]); + table.selectCell(4, 2, false, true, false, true); + table.selectCell(3, 0, true, false, false); + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge(first), + { start: { col: 3, row: table.columnHeaderLevelCount }, end: { col: 4, row: table.rowCount - 1 }, skipBodyMerge: true } + ]); + }); + test('still merges explicit body API ranges with headerSelectMode body', () => { const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); table.selectCells([ diff --git a/packages/vtable/src/state/select/update-position.ts b/packages/vtable/src/state/select/update-position.ts index e6970b3717..0f4a41114f 100644 --- a/packages/vtable/src/state/select/update-position.ts +++ b/packages/vtable/src/state/select/update-position.ts @@ -341,9 +341,6 @@ export function updateSelectPosition( cellPos.row = row; // scenegraph.setCellNormalStyle(col, row); const currentRange = state.select.ranges?.[state.select.ranges.length - 1]; - if (currentRange && (table.isHeader(col, row) || table.isSeriesNumber(col, row))) { - markHeaderSelectRange(currentRange); - } if ( currentRange && currentRange.start.row <= table.rowCount - 1 && @@ -354,6 +351,11 @@ export function updateSelectPosition( scenegraph.updateCellSelectBorder(currentRange, extendSelectRange); } } + // Shift 扩展到表头也会产生 body 投影,统一保留本次选择交互的来源。 + const selectedRange = state.select.ranges[state.select.ranges.length - 1]; + if (selectedRange && (table.isHeader(col, row) || table.isSeriesNumber(col, row))) { + markHeaderSelectRange(selectedRange); + } } else if ( (interactionState === InteractionState.grabing || table.eventManager.isDraging) && !table.stateManager.isResizeCol() From cdc9d705f8828713a189bfe0e40e43bbcae10c92 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 17:28:52 +0800 Subject: [PATCH 06/11] fix: retain header origins across drag selections --- .../listTable-select-auto-merge.test.ts | 20 +++++++++++++++++++ .../src/state/select/update-position.ts | 14 +++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index 115678f5ad..2373a05682 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -246,6 +246,26 @@ describe('listTable select auto merge ranges', () => { ]); }); + test.each(['column', 'series', 'corner'])('keeps Ctrl-drag %s projections separate', target => { + const table = createTable( + { autoMergeRanges: true, headerSelectMode: 'body' }, + target === 'column' ? {} : { rowSeriesNumber: {} } + ); + const first = target === 'column' + ? { start: { col: 2, row: table.columnHeaderLevelCount }, end: { col: 2, row: table.rowCount - 1 } } + : target === 'series' + ? { start: { col: table.leftRowSeriesNumberCount, row: 2 }, end: { col: table.colCount - 1, row: 2 } } + : { start: { col: 2, row: 2 }, end: { col: 2, row: 2 } }; + const start = target === 'series' ? { col: 2, row: 3 } : { col: 3, row: 2 }; + const end = target === 'column' ? { col: 3, row: 0 } : { col: 0, row: target === 'series' ? 3 : 0 }; + const projection = target === 'column' + ? { start: { col: 3, row: table.columnHeaderLevelCount }, end: { col: 3, row: table.rowCount - 1 } } + : { start: { col: table.leftRowSeriesNumberCount, row: target === 'series' ? 3 : table.columnHeaderLevelCount }, end: { col: table.colCount - 1, row: target === 'series' ? 3 : table.rowCount - 1 } }; + table.selectCells([first]); + dragSelect(table, start, end, true); + expect(table.getSelectedCellRanges()).toEqual([addSkipBodyMerge(first), addSkipBodyMerge(projection)]); + }); + test('still merges explicit body API ranges with headerSelectMode body', () => { const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }); table.selectCells([ diff --git a/packages/vtable/src/state/select/update-position.ts b/packages/vtable/src/state/select/update-position.ts index 0f4a41114f..f6c2c0675d 100644 --- a/packages/vtable/src/state/select/update-position.ts +++ b/packages/vtable/src/state/select/update-position.ts @@ -2,7 +2,7 @@ import { isValid } from '@visactor/vutils'; import type { ListTable } from '../..'; import type { SimpleHeaderLayoutMap } from '../../layout'; import type { Scenegraph } from '../../scenegraph/scenegraph'; -import type { SelectAllOnCtrlAOption } from '../../ts-types'; +import type { CellRange, SelectAllOnCtrlAOption } from '../../ts-types'; import { InteractionState } from '../../ts-types'; import type { StateManager } from '../state'; import { isCellDisableSelect } from './is-cell-select-highlight'; @@ -28,6 +28,7 @@ export function updateSelectPosition( const { table, interactionState } = state; const { scenegraph } = table; const { cellPos } = state.select; + let selectedRange: CellRange | undefined; state.select.isSelectAll = isSelectAll; if (col !== -1 && row !== -1 && makeSelectCellVisible) { @@ -351,11 +352,7 @@ export function updateSelectPosition( scenegraph.updateCellSelectBorder(currentRange, extendSelectRange); } } - // Shift 扩展到表头也会产生 body 投影,统一保留本次选择交互的来源。 - const selectedRange = state.select.ranges[state.select.ranges.length - 1]; - if (selectedRange && (table.isHeader(col, row) || table.isSeriesNumber(col, row))) { - markHeaderSelectRange(selectedRange); - } + selectedRange = state.select.ranges[state.select.ranges.length - 1]; } else if ( (interactionState === InteractionState.grabing || table.eventManager.isDraging) && !table.stateManager.isResizeCol() @@ -499,6 +496,11 @@ export function updateSelectPosition( scenegraph.updateCellSelectBorder(currentRange, extendSelectRange); } } + selectedRange = currentRange; + } + // 点击、Shift 和拖动均可投影到 body,在共同出口保留本次选择来源。 + if (selectedRange && (table.isHeader(col, row) || table.isSeriesNumber(col, row))) { + markHeaderSelectRange(selectedRange); } scenegraph.updateNextFrame(); } From 32a430dd038ecd513bc6697dd3e956b1e4207ca6 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 17:52:59 +0800 Subject: [PATCH 07/11] ci: use Node 22 for Playwright tests --- .github/workflows/unit-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 8741077488..4ab6ab415f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -15,8 +15,8 @@ jobs: strategy: matrix: - node-version: [18.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + # 与受控 CI 统一 Node 22,满足 Playwright 1.63 的运行要求。 + node-version: ['22.22.2'] steps: - uses: actions/checkout@v3 From 37cfa00f2784335b9f0999aee8feffc557a47fd4 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Sat, 17 Oct 2026 18:21:36 +0800 Subject: [PATCH 08/11] perf: index selection neighbors and sweep covered area --- .../select-auto-merge-ranges.test.ts | 85 ++++++++++ .../src/state/select/auto-merge-ranges.ts | 153 +++++++++++------- 2 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 packages/vtable/__tests__/select-auto-merge-ranges.test.ts diff --git a/packages/vtable/__tests__/select-auto-merge-ranges.test.ts b/packages/vtable/__tests__/select-auto-merge-ranges.test.ts new file mode 100644 index 0000000000..d8f87380f0 --- /dev/null +++ b/packages/vtable/__tests__/select-auto-merge-ranges.test.ts @@ -0,0 +1,85 @@ +import { autoMergeCellRanges } from '../src/state/select/auto-merge-ranges'; +import type { CellRange } from '../src/ts-types'; +import type { BaseTableAPI } from '../src/ts-types/base-table'; + +// 几何函数只读取这些表格边界,大范围回归不需要构造图元。 +const table = { + options: { select: { autoMergeRanges: true } }, + rowHeaderLevelCount: 0, + leftRowSeriesNumberCount: 0, + frozenColCount: 0, + columnHeaderLevelCount: 0, + frozenRowCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 0, + colCount: 100001, + rowCount: 100001, + getCellLocation: () => 'body' +} as unknown as BaseTableAPI; + +/** 构造包含首尾坐标的普通 body 矩形。 */ +function range(col: number, row: number, endCol = col, endRow = row): CellRange { + return { start: { col, row }, end: { col: endCol, row: endRow } }; +} + +test.each(['horizontal', 'vertical', 'grid'])('keeps 10000 sparse %s selections separate', axis => { + const ranges = Array.from({ length: 10000 }, (_, index) => + axis === 'horizontal' + ? range(index * 3 + 1, 1) + : axis === 'vertical' + ? range(1, index * 3 + 1) + : range((index % 100) * 3 + 1, Math.floor(index / 100) * 3 + 1) + ); + expect(autoMergeCellRanges(ranges, table)).toBe(ranges); +}); + +test.each(['horizontal', 'vertical'])('merges 10000 contiguous %s cells', axis => { + const ranges = Array.from({ length: 10000 }, (_, index) => + axis === 'horizontal' ? range(index + 1, 1) : range(1, index + 1) + ); + expect(autoMergeCellRanges(ranges, table)).toEqual([ + axis === 'horizontal' ? range(1, 1, 10000, 1) : range(1, 1, 1, 10000) + ]); +}); + +test('keeps an overlapping union with holes even when summed areas equal the bounding area', () => { + const ranges = [range(1, 1, 3, 1), range(1, 2, 1, 3), range(1, 1, 2, 2)]; + expect(autoMergeCellRanges(ranges, table)).toBe(ranges); +}); + +test('merges a 10000-cell tile and preserves corner-only and disconnected selections', () => { + const cells = Array.from({ length: 10000 }, (_, index) => range((index % 100) + 1, Math.floor(index / 100) + 1)); + const corner = range(101, 101); + const disconnected = range(104, 1); + expect(autoMergeCellRanges([...cells, corner, disconnected], table)).toEqual([ + range(1, 1, 100, 100), + corner, + disconnected + ]); +}); + +test('preserves exact cell coverage for deterministic overlapping and reversed ranges', () => { + let seed = 0x12345678; + const coordinate = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return 1 + ((seed >>> 24) % 8); + }; + // 逐格枚举作为独立参照,不复用归并的空间索引或面积算法。 + const coverage = (ranges: CellRange[]) => { + const cells = new Set(); + for (const item of ranges) { + for (let col = Math.min(item.start.col, item.end.col); col <= Math.max(item.start.col, item.end.col); col++) { + for (let row = Math.min(item.start.row, item.end.row); row <= Math.max(item.start.row, item.end.row); row++) { + cells.add(col + ',' + row); + } + } + } + return [...cells].sort(); + }; + for (let sample = 0; sample < 100; sample++) { + const ranges = Array.from({ length: 12 }, () => range(coordinate(), coordinate(), coordinate(), coordinate())); + const before = JSON.stringify(ranges); + expect(coverage(autoMergeCellRanges(ranges, table))).toEqual(coverage(ranges)); + expect(JSON.stringify(ranges)).toBe(before); + } +}); diff --git a/packages/vtable/src/state/select/auto-merge-ranges.ts b/packages/vtable/src/state/select/auto-merge-ranges.ts index dc59ccce1e..c2c4afdf1a 100644 --- a/packages/vtable/src/state/select/auto-merge-ranges.ts +++ b/packages/vtable/src/state/select/auto-merge-ranges.ts @@ -14,8 +14,14 @@ type IndexedRange = { index: number; range: SelectRange; normalizedRange: CellRange; + visited: boolean; }; +type RangeNode = { bounds: CellRange; empty: boolean } & ( + | { item: IndexedRange } + | { left: RangeNode; right: RangeNode } +); + /** * 将普通 body 主区域内、连通且恰好铺满外接矩形的多选区合并成单个矩形选区。 */ @@ -27,7 +33,8 @@ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): const indexedRanges = ranges.map((range, index) => ({ index, range, - normalizedRange: normalizeRange(range) + normalizedRange: normalizeRange(range), + visited: false })); const eligibleRanges = indexedRanges.filter( range => !headerSelectRanges.has(range.range) && isMainBodyRange(range.normalizedRange, table) @@ -37,38 +44,20 @@ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): return ranges; } - const visited = new Set(); + const tree = buildRangeTree(eligibleRanges); const mergedComponents = new Map(); const removedIndexes = new Set(); for (let i = 0; i < eligibleRanges.length; i++) { const componentSeed = eligibleRanges[i]; - if (visited.has(componentSeed.index)) { + if (componentSeed.visited) { continue; } const component: IndexedRange[] = []; - const queue = [componentSeed]; - visited.add(componentSeed.index); - - while (queue.length) { - const current = queue.shift(); - if (!current) { - continue; - } - component.push(current); - - for (let nextIndex = 0; nextIndex < eligibleRanges.length; nextIndex++) { - const candidate = eligibleRanges[nextIndex]; - if (visited.has(candidate.index)) { - continue; - } - if (!isConnectedRange(current.normalizedRange, candidate.normalizedRange)) { - continue; - } - visited.add(candidate.index); - queue.push(candidate); - } + collectConnectedRanges(tree, componentSeed.normalizedRange, component); + for (let cursor = 0; cursor < component.length; cursor++) { + collectConnectedRanges(tree, component[cursor].normalizedRange, component); } if (component.length < 2) { @@ -110,6 +99,49 @@ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): }, []); } +/** 按两个坐标轴交替划分包围盒,只检索邻近选区。 */ +function buildRangeTree(ranges: IndexedRange[], depth = 0): RangeNode { + if (ranges.length === 1) { + return { bounds: ranges[0].normalizedRange, item: ranges[0], empty: false }; + } + const axis = depth % 2 ? 'row' : 'col'; + ranges.sort((left, right) => left.normalizedRange.start[axis] - right.normalizedRange.start[axis]); + const middle = Math.floor(ranges.length / 2); + const left = buildRangeTree(ranges.slice(0, middle), depth + 1); + const right = buildRangeTree(ranges.slice(middle), depth + 1); + return { + left, + right, + empty: false, + bounds: { + start: { + col: Math.min(left.bounds.start.col, right.bounds.start.col), + row: Math.min(left.bounds.start.row, right.bounds.start.row) + }, + end: { + col: Math.max(left.bounds.end.col, right.bounds.end.col), + row: Math.max(left.bounds.end.row, right.bounds.end.row) + } + } + }; +} + +/** 收集连通的未访问选区并从索引移除,避免对同一分量重复扫描。 */ +function collectConnectedRanges(node: RangeNode, range: CellRange, result: IndexedRange[]): void { + if (node.empty || !isConnectedRange(node.bounds, range)) { + return; + } + if ('item' in node) { + node.empty = true; + node.item.visited = true; + result.push(node.item); + return; + } + collectConnectedRanges(node.left, range, result); + collectConnectedRanges(node.right, range, result); + node.empty = node.left.empty && node.right.empty; +} + function normalizeRange(range: CellRange): CellRange { return { start: { @@ -181,43 +213,48 @@ function getRangeArea(range: CellRange): number { return (range.end.col - range.start.col + 1) * (range.end.row - range.start.row + 1); } +/** 扫描列边界并维护行覆盖长度,避免每个列带重新扫描全部选区。 */ function getUnionArea(ranges: CellRange[]): number { - const xBoundaries = Array.from( - new Set(ranges.flatMap(range => [range.start.col, range.end.col + 1])) - ).sort((left, right) => left - right); - let area = 0; - - for (let i = 0; i < xBoundaries.length - 1; i++) { - const xStart = xBoundaries[i]; - const xEnd = xBoundaries[i + 1]; - if (xStart === xEnd) { - continue; - } - - const activeIntervals = ranges - .filter(range => range.start.col < xEnd && range.end.col + 1 > xStart) - .map(range => [range.start.row, range.end.row + 1] as const) - .sort((left, right) => left[0] - right[0]); - - if (!activeIntervals.length) { - continue; - } - - let coveredHeight = 0; - let [currentStart, currentEnd] = activeIntervals[0]; - for (let intervalIndex = 1; intervalIndex < activeIntervals.length; intervalIndex++) { - const [nextStart, nextEnd] = activeIntervals[intervalIndex]; - if (nextStart > currentEnd) { - coveredHeight += currentEnd - currentStart; - currentStart = nextStart; - currentEnd = nextEnd; - continue; + const rows = Array.from(new Set(ranges.flatMap(range => [range.start.row, range.end.row + 1]))).sort((a, b) => a - b); + const rowIndexes = new Map(rows.map((row, index) => [row, index])); + const events = ranges + .flatMap(range => { + const start = rowIndexes.get(range.start.row)!; + const end = rowIndexes.get(range.end.row + 1)!; + return [ + { col: range.start.col, start, end, delta: 1 }, + { col: range.end.col + 1, start, end, delta: -1 } + ]; + }) + .sort((a, b) => a.col - b.col); + const covers = new Int32Array(rows.length * 4); + const lengths = new Float64Array(rows.length * 4); + + // 每条事件仅更新覆盖到的行区间;有覆盖时直接使用整段长度。 + function update(node: number, left: number, right: number, start: number, end: number, delta: number): void { + if (start <= left && right <= end) { + covers[node] += delta; + } else { + const middle = Math.floor((left + right) / 2); + if (start < middle) { + update(node * 2, left, middle, start, end, delta); + } + if (end > middle) { + update(node * 2 + 1, middle, right, start, end, delta); } - currentEnd = Math.max(currentEnd, nextEnd); } - coveredHeight += currentEnd - currentStart; - area += (xEnd - xStart) * coveredHeight; + lengths[node] = covers[node] + ? rows[right] - rows[left] + : right - left === 1 + ? 0 + : lengths[node * 2] + lengths[node * 2 + 1]; + } + let area = 0; + let previous = events[0].col; + for (const event of events) { + area += (event.col - previous) * lengths[1]; + update(1, 0, rows.length - 1, event.start, event.end, event.delta); + previous = event.col; } - return area; } From 12aec154345adac27d8993787d2fbb12ecb36179 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Mon, 19 Oct 2026 12:06:50 +0800 Subject: [PATCH 09/11] test: start a fresh premerge workflow acceptance run From e90623347ec2a72ad0a0b7bd0be00ad8d9f4b65f Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Mon, 19 Oct 2026 12:24:49 +0800 Subject: [PATCH 10/11] test: rerun premerge flow after first-run CI access repair From 26501654ac7ab055e637b2fa2d960c4b1723ee85 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Mon, 19 Oct 2026 12:43:46 +0800 Subject: [PATCH 11/11] fix: preserve merged selection anchors and reuse sparse borders --- .../listTable-select-auto-merge.test.ts | 143 ++++++++++++++++-- packages/vtable/src/scenegraph/scenegraph.ts | 24 +++ .../scenegraph/select/create-select-border.ts | 13 +- .../scenegraph/select/delete-select-border.ts | 17 ++- .../src/state/select/auto-merge-ranges.ts | 19 ++- .../src/state/select/update-position.ts | 3 +- packages/vtable/src/state/state.ts | 7 +- 7 files changed, 206 insertions(+), 20 deletions(-) diff --git a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts index 2373a05682..29c95a7941 100644 --- a/packages/vtable/__tests__/listTable-select-auto-merge.test.ts +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -1,6 +1,8 @@ // @ts-nocheck import { ListTable } from '../src'; import { createDiv, removeDom } from './dom'; +import { autoMergeCellRanges } from '../src/state/select/auto-merge-ranges'; +import { getCellSelectBorderKey } from '../src/scenegraph/select/create-select-border'; global.__VERSION__ = 'none'; @@ -219,17 +221,25 @@ describe('listTable select auto merge ranges', () => { const table = createTable({ autoMergeRanges: true, headerSelectMode: 'body' }, { rowSeriesNumber: {} }); table.selectCell(0, 2, false, false, false); table.selectCell(0, 3, false, true, false); - expect(table.getSelectedCellRanges()).toEqual([2, 3].map(row => - addSkipBodyMerge({ start: { col: table.leftRowSeriesNumberCount, row }, end: { col: table.colCount - 1, row } }) - )); + expect(table.getSelectedCellRanges()).toEqual( + [2, 3].map(row => + addSkipBodyMerge({ start: { col: table.leftRowSeriesNumberCount, row }, end: { col: table.colCount - 1, row } }) + ) + ); }); test('keeps corner body projections separate when ordinary headers use cell mode', () => { - const table = createTable({ autoMergeRanges: true, headerSelectMode: 'cell', cornerHeaderSelectMode: 'body' }, { rowSeriesNumber: {} }); + const table = createTable( + { autoMergeRanges: true, headerSelectMode: 'cell', cornerHeaderSelectMode: 'body' }, + { rowSeriesNumber: {} } + ); table.selectCell(0, 0, false, false, false); table.selectCell(2, 2, false, true, false, true); expect(table.getSelectedCellRanges()).toEqual([ - addSkipBodyMerge({ start: { col: table.leftRowSeriesNumberCount, row: table.columnHeaderLevelCount }, end: { col: table.colCount - 1, row: table.rowCount - 1 } }), + addSkipBodyMerge({ + start: { col: table.leftRowSeriesNumberCount, row: table.columnHeaderLevelCount }, + end: { col: table.colCount - 1, row: table.rowCount - 1 } + }), addSkipBodyMerge({ start: { col: 2, row: 2 }, end: { col: 2, row: 2 } }) ]); }); @@ -242,7 +252,11 @@ describe('listTable select auto merge ranges', () => { table.selectCell(3, 0, true, false, false); expect(table.getSelectedCellRanges()).toEqual([ addSkipBodyMerge(first), - { start: { col: 3, row: table.columnHeaderLevelCount }, end: { col: 4, row: table.rowCount - 1 }, skipBodyMerge: true } + { + start: { col: 3, row: table.columnHeaderLevelCount }, + end: { col: 4, row: table.rowCount - 1 }, + skipBodyMerge: true + } ]); }); @@ -251,16 +265,21 @@ describe('listTable select auto merge ranges', () => { { autoMergeRanges: true, headerSelectMode: 'body' }, target === 'column' ? {} : { rowSeriesNumber: {} } ); - const first = target === 'column' - ? { start: { col: 2, row: table.columnHeaderLevelCount }, end: { col: 2, row: table.rowCount - 1 } } - : target === 'series' + const first = + target === 'column' + ? { start: { col: 2, row: table.columnHeaderLevelCount }, end: { col: 2, row: table.rowCount - 1 } } + : target === 'series' ? { start: { col: table.leftRowSeriesNumberCount, row: 2 }, end: { col: table.colCount - 1, row: 2 } } : { start: { col: 2, row: 2 }, end: { col: 2, row: 2 } }; const start = target === 'series' ? { col: 2, row: 3 } : { col: 3, row: 2 }; const end = target === 'column' ? { col: 3, row: 0 } : { col: 0, row: target === 'series' ? 3 : 0 }; - const projection = target === 'column' - ? { start: { col: 3, row: table.columnHeaderLevelCount }, end: { col: 3, row: table.rowCount - 1 } } - : { start: { col: table.leftRowSeriesNumberCount, row: target === 'series' ? 3 : table.columnHeaderLevelCount }, end: { col: table.colCount - 1, row: target === 'series' ? 3 : table.rowCount - 1 } }; + const projection = + target === 'column' + ? { start: { col: 3, row: table.columnHeaderLevelCount }, end: { col: 3, row: table.rowCount - 1 } } + : { + start: { col: table.leftRowSeriesNumberCount, row: target === 'series' ? 3 : table.columnHeaderLevelCount }, + end: { col: table.colCount - 1, row: target === 'series' ? 3 : table.rowCount - 1 } + }; table.selectCells([first]); dragSelect(table, start, end, true); expect(table.getSelectedCellRanges()).toEqual([addSkipBodyMerge(first), addSkipBodyMerge(projection)]); @@ -306,6 +325,106 @@ describe('listTable select auto merge ranges', () => { ]); }); + test('keeps the latest selection anchor for Shift continuation after merging', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 3, 1); + const anchor = { col: start.col + 1, row: start.row }; + table.selectCell(start.col, start.row, false, false, false, true); + table.selectCell(anchor.col, anchor.row, false, true, false, true); + + table.selectCell(start.col - 1, start.row, true, false, false, true); + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ start: anchor, end: { col: start.col - 1, row: start.row } }) + ]); + table.selectCell(anchor.col + 1, anchor.row, true, false, false, true); + expect(table.getSelectedCellRanges()).toEqual([ + addSkipBodyMerge({ start: anchor, end: { col: anchor.col + 1, row: anchor.row } }) + ]); + }); + + test('retains unrelated selection graphics when a local component merges', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 1, 1); + const far = getMainBodyCell(table, 4, 4); + table.selectCells([ + { start: far, end: far }, + { start, end: start } + ]); + const retained = [...table.scenegraph.selectedRangeComponents.values()][0]; + const deleted = jest.spyOn(retained.rect, 'delete'); + + table.selectCell(start.col + 1, start.row, false, true, false, true); + expect([...table.scenegraph.selectedRangeComponents.values()]).toContain(retained); + expect(deleted).not.toHaveBeenCalled(); + expect(table.scenegraph.selectedRangeComponents.size).toBe(2); + }); + + test('keeps the latest unmerged range active when earlier components merge', () => { + const table = createTable({ autoMergeRanges: true }); + const start = getMainBodyCell(table, 3, 1); + const far = getMainBodyCell(table, 0, 4); + table.selectCells([ + { start, end: start }, + { start: { col: start.col + 1, row: start.row }, end: { col: start.col + 1, row: start.row } }, + { start: far, end: far } + ]); + const merged = [...table.scenegraph.selectedRangeComponents.entries()].find(([key]) => + key.startsWith(`${start.col}-${start.row}-${start.col + 1}-${start.row}-`) + )[1]; + table.selectCell(far.col + 1, far.row, true, false, false, true); + expect([...table.scenegraph.selectedRangeComponents.values()]).toContain(merged); + expect(table.scenegraph.selectedRangeComponents.size).toBe(2); + }); + + test('a local merge among 10000 sparse ranges replaces only two graphics', () => { + const table = createTable({ autoMergeRanges: true }); + const previous = Array.from({ length: 10000 }, (_, index) => ({ + start: { col: 1, row: index * 3 + 1 }, + end: { col: 1, row: index * 3 + 1 } + })); + previous.push({ start: { col: 2, row: 1 }, end: { col: 2, row: 1 } }); + const geometryTable = { + options: { select: { autoMergeRanges: true } }, + rowHeaderLevelCount: 0, + leftRowSeriesNumberCount: 0, + frozenColCount: 0, + columnHeaderLevelCount: 0, + frozenRowCount: 0, + rightFrozenColCount: 0, + bottomFrozenRowCount: 0, + colCount: 100001, + rowCount: 100001, + getCellLocation: () => 'body' + }; + const ranges = autoMergeCellRanges(previous, geometryTable); + const deleted = jest.fn(); + const components = new Map( + previous.map(range => [ + getCellSelectBorderKey(range.start.col, range.start.row, range.end.col, range.end.row), + { rect: { delete: deleted }, role: 'body' } + ]) + ); + const retainedKey = getCellSelectBorderKey(1, 4, 1, 4); + const retained = components.get(retainedKey); + // 只验证图元增删数量和复用身份,不将此夹具当作浏览器性能测量。 + const scene = { + table: { stateManager: { select: { ranges } } }, + selectedRangeComponents: components, + updateCellSelectBorder: jest.fn(range => + components.set(getCellSelectBorderKey(range.start.col, range.start.row, range.end.col, range.end.row), { + rect: { delete: jest.fn() }, + role: 'body' + }) + ), + moveSelectingRangeComponentsToSelectedRangeComponents: jest.fn() + }; + table.scenegraph.updateMergedBodySelectRangeComponents.call(scene, previous); + expect(deleted).toHaveBeenCalledTimes(2); + expect(scene.updateCellSelectBorder).toHaveBeenCalledTimes(1); + expect(components.size).toBe(10000); + expect(components.get(retainedKey)).toBe(retained); + }); + test('does not merge ranges across frozen body partitions', () => { const table = createTable({ autoMergeRanges: true }, { frozenColCount: 2 }); const row = getMainBodyCell(table, 0, 1).row; diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index f2a18bbad8..b2ba3b8b11 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -49,6 +49,7 @@ import { deleteAllSelectBorder, deleteAllSelectingBorder, deleteLastSelectedRangeComponents, + deleteSelectedBodyRangeComponent, removeFillHandleFromSelectComponents } from './select/delete-select-border'; import { updateRow } from './layout/update-row'; @@ -990,6 +991,29 @@ export class Scenegraph { moveSelectingRangeComponentsToSelectedRangeComponents(this); }); } + + /** 普通 body 选区归并只替换参与归并的图元,保留其他选框及最后选择标识。 */ + updateMergedBodySelectRangeComponents(previousRanges: CellRange[]) { + const ranges = this.table.stateManager.select.ranges; + const current = new Set(ranges); + const previous = new Set(previousRanges); + const lastSelectId = this.lastSelectId; + const lastUnchanged = ranges[ranges.length - 1] === previousRanges[previousRanges.length - 1]; + for (const range of previousRanges) { + if (!current.has(range)) { + deleteSelectedBodyRangeComponent(this, range); + } + } + for (const range of ranges) { + if (!previous.has(range)) { + this.updateCellSelectBorder(range); + this.moveSelectingRangeComponentsToSelectedRangeComponents(); + } + } + if (lastUnchanged) { + this.lastSelectId = lastSelectId; + } + } /** * @description: 列宽调整结果更新列宽 * @param {number} col diff --git a/packages/vtable/src/scenegraph/select/create-select-border.ts b/packages/vtable/src/scenegraph/select/create-select-border.ts index 8a731ca199..1cab8d0d7f 100644 --- a/packages/vtable/src/scenegraph/select/create-select-border.ts +++ b/packages/vtable/src/scenegraph/select/create-select-border.ts @@ -3,6 +3,17 @@ import type { CellSubLocation } from '../../ts-types'; import type { Scenegraph } from '../scenegraph'; import type { BaseTableAPI } from '../../ts-types/base-table'; +/** 统一选框段落键;普通 body 范围使用完整边界作为选择标识。 */ +export function getCellSelectBorderKey( + startCol: number, + startRow: number, + endCol: number, + endRow: number, + selectId = `${startCol}${startRow}${endCol}${endRow}` +): string { + return `${startCol}-${startRow}-${endCol}-${endRow}-${selectId}`; +} + /** * 选框(select border)的创建入口。 * @@ -115,7 +126,7 @@ export function createCellSelectBorder( scene.lastSelectId = selectId; // key 以“标准化后的 start/end + selectId”组成:同一选择范围在不同区域拆分后 key 不同, // 但 selectId 相同,可用于 shift 续选等场景的删除/替换。 - scene.selectingRangeComponents.set(`${startCol}-${startRow}-${endCol}-${endRow}-${selectId}`, { + scene.selectingRangeComponents.set(getCellSelectBorderKey(startCol, startRow, endCol, endRow, selectId), { rect, fillhandle, role: selectRangeType diff --git a/packages/vtable/src/scenegraph/select/delete-select-border.ts b/packages/vtable/src/scenegraph/select/delete-select-border.ts index cb44172f9f..8f9e03f935 100644 --- a/packages/vtable/src/scenegraph/select/delete-select-border.ts +++ b/packages/vtable/src/scenegraph/select/delete-select-border.ts @@ -1,6 +1,21 @@ import type { IRect } from '@src/vrender'; import type { Scenegraph } from '../scenegraph'; -import type { CellSubLocation } from '../../ts-types'; +import type { CellRange, CellSubLocation } from '../../ts-types'; +import { getCellSelectBorderKey } from './create-select-border'; + +/** 归并仅涉及普通 body 单段选框,按完整边界直接删除,不遍历或重建无关图元。 */ +export function deleteSelectedBodyRangeComponent(scene: Scenegraph, range: CellRange): void { + const key = getCellSelectBorderKey( + Math.min(range.start.col, range.end.col), + Math.min(range.start.row, range.end.row), + Math.max(range.start.col, range.end.col), + Math.max(range.start.row, range.end.row) + ); + const component = scene.selectedRangeComponents.get(key); + component?.rect.delete(); + component?.fillhandle?.delete(); + scene.selectedRangeComponents.delete(key); +} /** 按住shift 则继续上次选中范围 需要将现有的删除掉 */ export function deleteLastSelectedRangeComponents(scene: Scenegraph) { diff --git a/packages/vtable/src/state/select/auto-merge-ranges.ts b/packages/vtable/src/state/select/auto-merge-ranges.ts index c2c4afdf1a..28d5ec751f 100644 --- a/packages/vtable/src/state/select/auto-merge-ranges.ts +++ b/packages/vtable/src/state/select/auto-merge-ranges.ts @@ -4,10 +4,21 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; type SelectRange = CellRange & { skipBodyMerge?: boolean }; const headerSelectRanges = new WeakSet(); +const mergedRangeAnchors = new WeakMap(); /** 保留表头和序号投影选区的来源,不向公开选区对象增加字段。 */ export function markHeaderSelectRange(range: CellRange): void { headerSelectRanges.add(range); + mergedRangeAnchors.delete(range); +} + +/** Shift 首次续选时恢复归并前的活动锚点,后续仍沿用标准 start/end 状态。 */ +export function restoreMergedRangeAnchor(range: CellRange): void { + const anchor = mergedRangeAnchors.get(range); + if (anchor) { + range.start = { ...anchor }; + mergedRangeAnchors.delete(range); + } } type IndexedRange = { @@ -24,6 +35,7 @@ type RangeNode = { bounds: CellRange; empty: boolean } & ( /** * 将普通 body 主区域内、连通且恰好铺满外接矩形的多选区合并成单个矩形选区。 + * 未归并项保留对象身份,场景图据此复用其选框。 */ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): SelectRange[] { if (!table.options.select?.autoMergeRanges || ranges.length < 2) { @@ -74,11 +86,14 @@ export function autoMergeCellRanges(ranges: SelectRange[], table: BaseTableAPI): const componentIndexes = component.map(item => item.index).sort((a, b) => a - b); const targetIndex = componentIndexes[componentIndexes.length - 1]; - mergedComponents.set(targetIndex, { + const mergedRange: SelectRange = { start: { ...boundingRange.start }, end: { ...boundingRange.end }, ...(component.every(item => item.range.skipBodyMerge) ? { skipBodyMerge: true } : {}) - }); + }; + const lastRange = ranges[targetIndex]; + mergedRangeAnchors.set(mergedRange, { ...(mergedRangeAnchors.get(lastRange) ?? lastRange.start) }); + mergedComponents.set(targetIndex, mergedRange); componentIndexes .filter(index => index !== targetIndex) .forEach(index => { diff --git a/packages/vtable/src/state/select/update-position.ts b/packages/vtable/src/state/select/update-position.ts index f6c2c0675d..4c70b123e8 100644 --- a/packages/vtable/src/state/select/update-position.ts +++ b/packages/vtable/src/state/select/update-position.ts @@ -6,7 +6,7 @@ import type { CellRange, SelectAllOnCtrlAOption } from '../../ts-types'; import { InteractionState } from '../../ts-types'; import type { StateManager } from '../state'; import { isCellDisableSelect } from './is-cell-select-highlight'; -import { markHeaderSelectRange } from './auto-merge-ranges'; +import { markHeaderSelectRange, restoreMergedRangeAnchor } from './auto-merge-ranges'; /** * @description: 更新select位置 * @param {StateManager} state @@ -141,6 +141,7 @@ export function updateSelectPosition( currentRange.end = { col: endCol, row: endRow }; } } else { + restoreMergedRangeAnchor(currentRange); currentRange.end = { col, row }; } // 禁用shift多选时,选中范围的end应该和start一致 diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 01257fd810..a27617157e 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -835,10 +835,11 @@ export class StateManager { const { col: lastCol, row: lastRow } = this.select.ranges[this.select.ranges.length - 1].end; // 即使最后一项是重复区域,也要在去重后完成整批选区的合并。 if (mergeRanges) { - const mergedRanges = autoMergeCellRanges(this.select.ranges, this.table); - if (mergedRanges !== this.select.ranges) { + const previousRanges = this.select.ranges; + const mergedRanges = autoMergeCellRanges(previousRanges, this.table); + if (mergedRanges !== previousRanges) { this.select.ranges = mergedRanges; - this.table.scenegraph.recreateAllSelectRangeComponents(); + this.table.scenegraph.updateMergedBodySelectRangeComponents(previousRanges); } }