feat(sheets): split large set-range-values mutation (#6191)

This commit is contained in:
WEI ZHANG
2025-11-26 21:04:38 +08:00
committed by GitHub
parent 93ce16e4d0
commit e161f5ebb1
3 changed files with 266 additions and 3 deletions
@@ -15,9 +15,10 @@
*/
import type { IMutationInfo, IRange } from '@univerjs/core';
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import { SetRangeValuesMutation } from '@univerjs/sheets';
import { describe, expect, it } from 'vitest';
import { getRepeatRange, mergeSetRangeValues } from '../utils';
import { getRepeatRange, mergeSetRangeValues, spilitLargeSetRangeValuesMutations } from '../utils';
describe('test getRepeatRange', () => {
it('repeat row 2 times', () => {
@@ -195,3 +196,166 @@ describe('test "mergeSetRangeValues"', () => {
]);
});
});
describe('test "spilitLargeSetRangeValuesMutations"', () => {
it('should not split if mutation is not SetRangeValuesMutation', () => {
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: 'other.mutation',
params: { unitId: '1', subUnitId: '1', cellValue: {} },
};
const result = spilitLargeSetRangeValuesMutations(mutation);
expect(result).toStrictEqual([mutation]);
});
it('should not split if cellValue is empty', () => {
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue: undefined },
};
const result = spilitLargeSetRangeValuesMutations(mutation);
expect(result).toStrictEqual([mutation]);
});
it('should not split if cell count is below threshold', () => {
const cellValue: Record<number, Record<number, { v: string }>> = {};
// Create 100 cells (10x10)
for (let row = 0; row < 10; row++) {
cellValue[row] = {};
for (let col = 0; col < 10; col++) {
cellValue[row][col] = { v: `cell_${row}_${col}` };
}
}
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue },
};
const result = spilitLargeSetRangeValuesMutations(mutation, { threshold: 6000 });
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual(mutation);
});
it('should split large mutation into chunks based on maxCellsPerChunk', () => {
const cellValue: Record<number, Record<number, { v: string }>> = {};
// Create 150 cells (50 rows x 3 cols)
for (let row = 0; row < 50; row++) {
cellValue[row] = {};
for (let col = 0; col < 3; col++) {
cellValue[row][col] = { v: `cell_${row}_${col}` };
}
}
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue },
};
// With 3 cols and maxCellsPerChunk=30, each chunk should have 10 rows (30/3=10)
// So 50 rows should be split into 5 chunks
const result = spilitLargeSetRangeValuesMutations(mutation, {
threshold: 100,
maxCellsPerChunk: 30,
});
expect(result).toHaveLength(5);
result.forEach((chunk) => {
expect(chunk.id).toBe(SetRangeValuesMutation.id);
expect(chunk.params.unitId).toBe('1');
expect(chunk.params.subUnitId).toBe('1');
});
});
it('should preserve cell data correctly when splitting', () => {
const cellValue: Record<number, Record<number, { v: string }>> = {};
// Create 20 cells (10 rows x 2 cols)
for (let row = 0; row < 10; row++) {
cellValue[row] = {};
for (let col = 0; col < 2; col++) {
cellValue[row][col] = { v: `cell_${row}_${col}` };
}
}
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue },
};
// With 2 cols and maxCellsPerChunk=6, each chunk should have 3 rows (6/2=3)
const result = spilitLargeSetRangeValuesMutations(mutation, {
threshold: 10,
maxCellsPerChunk: 6,
});
expect(result).toHaveLength(4); // 10 rows / 3 rows per chunk = 4 chunks
// Verify first chunk has rows 0-2
const firstChunk = result[0].params.cellValue;
expect(firstChunk![0]![0]!.v).toBe('cell_0_0');
expect(firstChunk![2]![1]!.v).toBe('cell_2_1');
// Verify last chunk has rows 9
const lastChunk = result[3].params.cellValue;
expect(lastChunk![9]![0]!.v).toBe('cell_9_0');
expect(lastChunk![9]![1]!.v).toBe('cell_9_1');
});
it('should handle sparse matrices correctly', () => {
const cellValue: Record<number, Record<number, { v: string }>> = {
0: { 0: { v: 'cell_0_0' } },
5: { 5: { v: 'cell_5_5' } },
10: { 10: { v: 'cell_10_10' } },
};
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue },
};
const result = spilitLargeSetRangeValuesMutations(mutation, {
threshold: 2,
maxCellsPerChunk: 1,
});
// Should be split into chunks
expect(result.length).toBeGreaterThan(1);
// Verify all cells are preserved
const allCells: Record<string, Record<string, { v: string }>> = {};
result.forEach((chunk) => {
const chunkCellValue = chunk.params.cellValue!;
Object.keys(chunkCellValue).forEach((rowKey) => {
if (!allCells[rowKey]) allCells[rowKey] = {};
Object.keys(chunkCellValue[Number(rowKey)]).forEach((colKey) => {
const cellData = chunkCellValue[Number(rowKey)][Number(colKey)]!;
allCells[rowKey][colKey] = { v: cellData.v as string };
});
});
});
expect(allCells['0']['0'].v).toBe('cell_0_0');
expect(allCells['5']['5'].v).toBe('cell_5_5');
expect(allCells['10']['10'].v).toBe('cell_10_10');
});
it('should handle custom options correctly', () => {
const cellValue: Record<number, Record<number, { v: string }>> = {};
// Create 100 cells (100 rows x 1 col)
for (let row = 0; row < 100; row++) {
cellValue[row] = { 0: { v: `cell_${row}_0` } };
}
const mutation: IMutationInfo<ISetRangeValuesMutationParams> = {
id: SetRangeValuesMutation.id,
params: { unitId: '1', subUnitId: '1', cellValue },
};
// With 1 col and maxCellsPerChunk=25, each chunk should have 25 rows
const result = spilitLargeSetRangeValuesMutations(mutation, {
threshold: 50,
maxCellsPerChunk: 25,
});
expect(result).toHaveLength(4); // 100 rows / 25 rows per chunk = 4 chunks
});
});
@@ -24,7 +24,7 @@ import type {
Workbook,
Worksheet,
} from '@univerjs/core';
import type { ISetSelectionsOperationParams } from '@univerjs/sheets';
import type { ISetRangeValuesMutationParams, ISetSelectionsOperationParams } from '@univerjs/sheets';
import type { Observable } from 'rxjs';
import type { IDiscreteRange } from '../../controllers/utils/range-tools';
import type {
@@ -84,7 +84,7 @@ import { UniverPastePlugin } from './html-to-usm/paste-plugins/plugin-univer';
import { WordPastePlugin } from './html-to-usm/paste-plugins/plugin-word';
import { COPY_TYPE } from './type';
import { USMToHtmlService } from './usm-to-html/convertor';
import { convertTextToTable, discreteRangeContainsRange, htmlContainsImage, htmlIsFromExcel, mergeSetRangeValues, rangeIntersectWithDiscreteRange } from './utils';
import { convertTextToTable, discreteRangeContainsRange, htmlContainsImage, htmlIsFromExcel, mergeSetRangeValues, rangeIntersectWithDiscreteRange, spilitLargeSetRangeValuesMutations } from './utils';
export const PREDEFINED_HOOK_NAME = {
DEFAULT_COPY: 'default-copy',
@@ -819,6 +819,21 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
redoMutationsInfo = mergeSetRangeValues(redoMutationsInfo);
undoMutationsInfo = mergeSetRangeValues(undoMutationsInfo);
// Split large SetRangeValuesMutation to avoid performance issues
redoMutationsInfo = redoMutationsInfo.flatMap((mutation) =>
spilitLargeSetRangeValuesMutations(mutation as IMutationInfo<ISetRangeValuesMutationParams>, {
threshold: 20000,
maxCellsPerChunk: 10000,
})
);
undoMutationsInfo = undoMutationsInfo.flatMap((mutation) =>
spilitLargeSetRangeValuesMutations(mutation as IMutationInfo<ISetRangeValuesMutationParams>, {
threshold: 20000,
maxCellsPerChunk: 10000,
})
);
undoMutationsInfo.push({ id: SetWorksheetActiveOperation.id, params: { unitId: target.unitId, subUnitId: target.subUnitId } });
this._logService.log('[SheetClipboardService]', 'pasting mutations', {
@@ -180,6 +180,90 @@ export function mergeSetRangeValues(mutations: IMutationInfo[]) {
return newMutations;
}
// eslint-disable-next-line max-lines-per-function
export function spilitLargeSetRangeValuesMutations(
mutation: IMutationInfo<ISetRangeValuesMutationParams>,
options: { maxCellsPerChunk?: number; threshold?: number; maxChunks?: number } = {}
): IMutationInfo<ISetRangeValuesMutationParams>[] {
const { maxCellsPerChunk = 2500, threshold = 6000, maxChunks = 10 } = options;
if (mutation.id !== SetRangeValuesMutation.id) {
return [mutation];
}
const { cellValue } = mutation.params;
if (!cellValue) {
return [mutation];
}
const matrix = new ObjectMatrix(cellValue);
// 计算单元格数量和边界
let cellCount = 0;
let minRow = Infinity;
let maxRow = -Infinity;
let minCol = Infinity;
let maxCol = -Infinity;
matrix.forValue((row, col) => {
cellCount++;
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minCol = Math.min(minCol, col);
maxCol = Math.max(maxCol, col);
});
// 如果没有数据或单元格数量小于阈值,不拆分
if (minRow === Infinity || cellCount <= threshold) {
return [mutation];
}
const chunks: IMutationInfo<ISetRangeValuesMutationParams>[] = [];
// 根据列数计算每个块的行数
const colCount = maxCol - minCol + 1;
let rowsPerChunk = Math.max(1, Math.floor(maxCellsPerChunk / colCount));
// 确保不会拆分成超过 maxChunks 个块
const totalRows = maxRow - minRow + 1;
const estimatedChunks = Math.ceil(totalRows / rowsPerChunk);
if (estimatedChunks > maxChunks) {
// 重新计算 rowsPerChunk,使得拆分后的块数不超过 maxChunks
rowsPerChunk = Math.ceil(totalRows / maxChunks);
}
// 按照计算出的行数进行拆分
for (let rowStart = minRow; rowStart <= maxRow; rowStart += rowsPerChunk) {
const rowEnd = Math.min(rowStart + rowsPerChunk - 1, maxRow);
const chunkMatrix = new ObjectMatrix<Nullable<ICellData>>();
let hasData = false;
// 提取当前块的数据
for (let row = rowStart; row <= rowEnd; row++) {
for (let col = minCol; col <= maxCol; col++) {
const value = matrix.getValue(row, col);
if (value !== undefined) {
chunkMatrix.setValue(row, col, value);
hasData = true;
}
}
}
// 只有当块中有数据时才创建 mutation
if (hasData) {
chunks.push({
...mutation,
params: {
...mutation.params,
cellValue: chunkMatrix.getMatrix(),
},
});
}
}
return chunks.length > 0 ? chunks : [mutation];
}
export function rangeIntersectWithDiscreteRange(range: IRange, discrete: IDiscreteRange) {
const { startRow, endRow, startColumn, endColumn } = range;
for (let i = startRow; i <= endRow; i++) {