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..eaff08a6b2 --- /dev/null +++ b/packages/vtable/__tests__/listTable-select-auto-merge.test.ts @@ -0,0 +1,242 @@ +// @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('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); + 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/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/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..64fec9ee6f 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 { @@ -787,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 +830,14 @@ export class StateManager { } else { selectEnd(this.table.scenegraph); } + // 即使最后一项是重复区域,也要在去重后完成整批选区的合并。 + if (mergeRanges) { + const mergedRanges = autoMergeCellRanges(this.select.ranges, this.table); + if (mergedRanges !== this.select.ranges) { + this.select.ranges = mergedRanges; + this.table.scenegraph.recreateAllSelectRangeComponents(); + } + } // 触发SELECTED_CELL const lastCol = this.select.ranges[this.select.ranges.length - 1].end.col; 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; };