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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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; }