feat(sheets): split insert sheet command on large data (#6214)

This commit is contained in:
WEI ZHANG
2025-12-03 15:15:28 +08:00
committed by GitHub
parent 7c1c2c9b20
commit 09cc69a6a9
39 changed files with 1500 additions and 108 deletions
+3 -1
View File
@@ -79,6 +79,7 @@ export { EventState, EventSubject, fromEventSubject, type IEventObserver } from
export { AuthzIoLocalService } from './services/authz-io/authz-io-local.service';
export { IAuthzIoService } from './services/authz-io/type';
export {
COMMAND_LOG_EXECUTION_CONFIG_KEY,
type CommandListener,
CommandService,
CommandType,
@@ -148,6 +149,7 @@ export { afterTime, bufferDebounceTime, convertObservableToBehaviorSubject, from
export { textDiff } from './shared/text-diff';
export { awaitTime, delayAnimationFrame } from './shared/timer';
export { isNodeEnv } from './shared/tools';
export * from './sheets/clone';
export { Range } from './sheets/range';
export { getCellCoordByIndexSimple, getCellPositionByIndexSimple, getCellWithCoordByIndexCore, SheetSkeleton } from './sheets/sheet-skeleton';
export type { IGetRowColByPosOptions } from './sheets/sheet-skeleton';
@@ -167,8 +169,8 @@ export {
DEFAULT_WORKSHEET_ROW_TITLE_WIDTH_KEY,
mergeWorksheetSnapshotWithDefault,
} from './sheets/sheet-snapshot-utils';
export { Styles } from './sheets/styles';
export * from './sheets/typedef';
export type { IPosition } from './sheets/typedef';
export { addLinkToDocumentModel, isNotNullOrUndefined, isRangesEqual, isUnitRangesEqual } from './sheets/util';
@@ -17,6 +17,7 @@
import type { IMultiCommand } from '../command.service';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Injector } from '../../../common/di';
import { ConfigService, IConfigService } from '../../config/config.service';
import { ContextService, IContextService } from '../../context/context.service';
import { DesktopLogService, ILogService } from '../../log/log.service';
import {
@@ -39,6 +40,7 @@ describe('Test CommandService', () => {
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
commandService = injector.get(ICommandService);
commandService.registerCommand({
@@ -22,9 +22,17 @@ import { createIdentifier, Inject, Injector } from '../../common/di';
import { CustomCommandExecutionError } from '../../common/error';
import { sequence, sequenceAsync } from '../../common/sequence';
import { Disposable, DisposableCollection, toDisposable } from '../../shared/lifecycle';
import { IConfigService } from '../config/config.service';
import { IContextService } from '../context/context.service';
import { ILogService } from '../log/log.service';
/**
* The config key for enabling command execution logging.
* Set via `logCommandExecution` in `IUniverConfig` when calling `new Univer()`.
* @default true
*/
export const COMMAND_LOG_EXECUTION_CONFIG_KEY = 'command.logExecution';
/**
* The type of a command.
*/
@@ -103,6 +111,16 @@ export interface IMutationCommonParams {
* It is used to indicate which {@link CommandType.COMMAND} triggers the mutation.
*/
trigger?: string;
/**
* Mark this mutation as a split chunk from a large mutation.
* When collaboration layer encounters this flag, it will send this mutation
* in a separate changeset to avoid oversized payloads.
*
* This is typically set by operations that split large data (e.g., copy worksheet,
* paste large ranges) into smaller chunks for better network transmission.
*/
__splitChunk__?: boolean;
}
/**
@@ -174,6 +192,11 @@ export interface IExecutionOptions {
fromCollab?: boolean;
/** @deprecated */
fromChangeset?: boolean;
/**
* This mutation should be synced to changeset but not executed locally.
* The actual execution will be handled asynchronously via onlyLocal.
*/
syncOnly?: boolean;
[key: PropertyKey]: string | number | boolean | undefined;
}
@@ -230,6 +253,7 @@ export interface ICommandService {
syncExecuteCommand<P extends object = object, R = boolean>(id: string, params?: P, options?: IExecutionOptions): R;
/**
* Register a callback function that will be executed after a command is executed.
* Note: This will NOT be called for commands with syncOnly option.
* @param listener
*/
onCommandExecuted(listener: CommandListener): IDisposable;
@@ -238,6 +262,13 @@ export interface ICommandService {
* @param listener
*/
beforeCommandExecuted(listener: CommandListener): IDisposable;
/**
* Register a callback function specifically for collaboration sync.
* This will only be called for mutations (not commands/operations) that need to be synced,
* including syncOnly mutations.
* @param listener
*/
onMutationExecutedForCollab(listener: CommandListener): IDisposable;
}
class CommandRegistry {
@@ -292,6 +323,7 @@ export class CommandService extends Disposable implements ICommandService {
private readonly _beforeCommandExecutionListeners: CommandListener[] = [];
private readonly _commandExecutedListeners: CommandListener[] = [];
private readonly _collabMutationListeners: CommandListener[] = [];
private _multiCommandDisposables = new Map<string, IDisposable>();
@@ -301,7 +333,8 @@ export class CommandService extends Disposable implements ICommandService {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@ILogService private readonly _logService: ILogService
@ILogService private readonly _logService: ILogService,
@IConfigService private readonly _configService: IConfigService
) {
super();
@@ -314,6 +347,7 @@ export class CommandService extends Disposable implements ICommandService {
this._commandExecutedListeners.length = 0;
this._beforeCommandExecutionListeners.length = 0;
this._collabMutationListeners.length = 0;
}
hasCommand(commandId: string): boolean {
@@ -359,6 +393,19 @@ export class CommandService extends Disposable implements ICommandService {
throw new Error('[CommandService]: could not add a listener twice.');
}
onMutationExecutedForCollab(listener: CommandListener): IDisposable {
if (this._collabMutationListeners.indexOf(listener) === -1) {
this._collabMutationListeners.push(listener);
return toDisposable(() => {
const index = this._collabMutationListeners.indexOf(listener);
this._collabMutationListeners.splice(index, 1);
});
}
throw new Error('[CommandService]: could not add a collab mutation listener twice.');
}
async executeCommand<P extends object = object, R = boolean>(
id: string,
params?: P,
@@ -379,7 +426,17 @@ export class CommandService extends Disposable implements ICommandService {
this._beforeCommandExecutionListeners.forEach((listener) => listener(commandInfo, _options));
const result = await this._execute<P, R>(command as ICommand<P, R>, params, _options);
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, _options));
// For syncOnly mutations, only call collab listeners, not regular listeners
if (_options.syncOnly) {
if (command.type === CommandType.MUTATION) {
this._collabMutationListeners.forEach((listener) => listener(commandInfo, _options));
}
} else {
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, _options));
if (command.type === CommandType.MUTATION) {
this._collabMutationListeners.forEach((listener) => listener(commandInfo, _options));
}
}
stackItemDisposable.dispose();
@@ -430,7 +487,17 @@ export class CommandService extends Disposable implements ICommandService {
this._beforeCommandExecutionListeners.forEach((listener) => listener(commandInfo, _options));
const result = this._syncExecute<P, R>(command as ICommand<P, R>, params, _options);
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, _options));
// For syncOnly mutations, only call collab listeners, not regular listeners
if (_options.syncOnly) {
if (command.type === CommandType.MUTATION) {
this._collabMutationListeners.forEach((listener) => listener(commandInfo, _options));
}
} else {
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, _options));
if (command.type === CommandType.MUTATION) {
this._collabMutationListeners.forEach((listener) => listener(commandInfo, _options));
}
}
stackItemDisposable.dispose();
@@ -487,10 +554,17 @@ export class CommandService extends Disposable implements ICommandService {
}
private async _execute<P extends object, R = boolean>(command: ICommand<P, R>, params?: P, options?: IExecutionOptions): Promise<R> {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(this._commandExecutingLevel, 0))}executing command "${command.id}"`
);
// If syncOnly is true, skip execution but return true to indicate success for sync purposes
if (options?.syncOnly) {
return true as R;
}
if (this._configService.getConfig<boolean>(COMMAND_LOG_EXECUTION_CONFIG_KEY) !== false) {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(this._commandExecutingLevel, 0))}executing command "${command.id}"`
);
}
this._commandExecutingLevel++;
let result: R | boolean;
@@ -507,10 +581,17 @@ export class CommandService extends Disposable implements ICommandService {
}
private _syncExecute<P extends object, R = boolean>(command: ICommand<P, R>, params?: P, options?: IExecutionOptions): R {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(0, this._commandExecutingLevel))}executing command "${command.id}".`
);
// If syncOnly is true, skip execution but return true to indicate success for sync purposes
if (options?.syncOnly) {
return true as R;
}
if (this._configService.getConfig<boolean>(COMMAND_LOG_EXECUTION_CONFIG_KEY) !== false) {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(0, this._commandExecutingLevel))}executing command "${command.id}".`
);
}
this._commandExecutingLevel++;
let result: R | boolean;
@@ -262,8 +262,11 @@ export class LocalUndoRedoService extends Disposable implements IUndoRedoService
const undoStack = this._getUndoStackForFocused();
const element = undoStack.pop();
if (element) {
const redoStack = this._getRedoStackForFocused();
redoStack.push(element);
// Only push to redo stack if redoMutations is not empty
if (element.redoMutations.length > 0) {
const redoStack = this._getRedoStackForFocused();
redoStack.push(element);
}
this._updateStatus();
}
}
@@ -0,0 +1,222 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IWorksheetData } from '../typedef';
import { describe, expect, it } from 'vitest';
import { Tools } from '../../shared/tools';
import { BooleanNumber } from '../../types/enum';
import { cloneWorksheetData } from '../clone';
function createTestWorksheetData(rowCount: number, colCount: number): IWorksheetData {
const cellData: IWorksheetData['cellData'] = {};
const rowData: IWorksheetData['rowData'] = {};
const columnData: IWorksheetData['columnData'] = {};
const mergeData: IWorksheetData['mergeData'] = [];
// Generate cell data
for (let r = 0; r < rowCount; r++) {
cellData[r] = {};
for (let c = 0; c < colCount; c++) {
cellData[r][c] = {
v: `Cell ${r},${c}`,
t: 1,
s: { ff: 'Arial', fs: 12 },
};
}
rowData[r] = { h: 20, hd: BooleanNumber.FALSE };
}
// Generate column data
for (let c = 0; c < colCount; c++) {
columnData[c] = { w: 100, hd: BooleanNumber.FALSE };
}
// Generate merge data
for (let i = 0; i < Math.min(rowCount, 10); i++) {
mergeData.push({
startRow: i * 5,
endRow: i * 5 + 1,
startColumn: i * 3,
endColumn: i * 3 + 2,
});
}
return {
id: 'test-sheet-id',
name: 'Test Sheet',
tabColor: '#FF0000',
hidden: BooleanNumber.FALSE,
freeze: {
xSplit: 0,
ySplit: 0,
startRow: -1,
startColumn: -1,
},
rowCount,
columnCount: colCount,
zoomRatio: 1,
scrollTop: 0,
scrollLeft: 0,
defaultColumnWidth: 88,
defaultRowHeight: 24,
mergeData,
cellData,
rowData,
columnData,
rowHeader: {
width: 46,
hidden: BooleanNumber.FALSE,
},
columnHeader: {
height: 20,
hidden: BooleanNumber.FALSE,
},
showGridlines: BooleanNumber.TRUE,
rightToLeft: BooleanNumber.FALSE,
};
}
describe('cloneWorksheetData', () => {
it('should correctly clone worksheet data', () => {
const original = createTestWorksheetData(10, 10);
const cloned = cloneWorksheetData(original);
// Verify basic properties
expect(cloned.id).toBe(original.id);
expect(cloned.name).toBe(original.name);
expect(cloned.tabColor).toBe(original.tabColor);
expect(cloned.rowCount).toBe(original.rowCount);
expect(cloned.columnCount).toBe(original.columnCount);
// Verify freeze is deeply cloned
expect(cloned.freeze).toEqual(original.freeze);
expect(cloned.freeze).not.toBe(original.freeze);
// Verify cellData is deeply cloned
expect(cloned.cellData[0][0]).toEqual(original.cellData[0][0]);
expect(cloned.cellData[0][0]).not.toBe(original.cellData[0][0]);
expect(cloned.cellData[0][0].s).not.toBe(original.cellData[0][0].s);
// Verify rowData is deeply cloned
expect(cloned.rowData[0]).toEqual(original.rowData[0]);
expect(cloned.rowData[0]).not.toBe(original.rowData[0]);
// Verify columnData is deeply cloned
expect(cloned.columnData[0]).toEqual(original.columnData[0]);
expect(cloned.columnData[0]).not.toBe(original.columnData[0]);
// Verify mergeData is deeply cloned
expect(cloned.mergeData).toEqual(original.mergeData);
expect(cloned.mergeData).not.toBe(original.mergeData);
expect(cloned.mergeData[0]).not.toBe(original.mergeData[0]);
// Modify cloned data and verify original is unchanged
cloned.cellData[0][0].v = 'Modified';
expect(original.cellData[0][0].v).toBe('Cell 0,0');
});
it('should be faster than Tools.deepClone for large worksheets', () => {
const testCases = [
{ rows: 100, cols: 50, label: '100x50 (5,000 cells)' },
{ rows: 500, cols: 100, label: '500x100 (50,000 cells)' },
];
for (const { rows, cols, label } of testCases) {
const original = createTestWorksheetData(rows, cols);
// Warm up
cloneWorksheetData(original);
Tools.deepClone(original);
// Benchmark cloneWorksheetData
const iterations = 5;
const startOptimized = performance.now();
for (let i = 0; i < iterations; i++) {
cloneWorksheetData(original);
}
const endOptimized = performance.now();
const optimizedTime = endOptimized - startOptimized;
// Benchmark Tools.deepClone
const startGeneric = performance.now();
for (let i = 0; i < iterations; i++) {
Tools.deepClone(original);
}
const endGeneric = performance.now();
const genericTime = endGeneric - startGeneric;
const speedup = genericTime / optimizedTime;
// The optimized version should be at least 2x faster
expect(speedup).toBeGreaterThan(1.5);
}
});
it('should handle empty cellData', () => {
const original = createTestWorksheetData(0, 0);
original.cellData = {};
const cloned = cloneWorksheetData(original);
expect(cloned.cellData).toEqual({});
});
it('should handle cells with rich text (p property)', () => {
const original = createTestWorksheetData(1, 1);
original.cellData[0][0] = {
p: {
id: 'doc-id',
documentStyle: {},
body: {
dataStream: 'Hello\\r\\n',
textRuns: [{ st: 0, ed: 5, ts: { ff: 'Arial' } }],
},
},
};
const cloned = cloneWorksheetData(original);
expect(cloned.cellData[0][0].p).toEqual(original.cellData[0][0].p);
expect(cloned.cellData[0][0].p).not.toBe(original.cellData[0][0].p);
expect(cloned.cellData[0][0].p!.body).not.toBe(original.cellData[0][0].p!.body);
});
it('should handle cells with formulas', () => {
const original = createTestWorksheetData(1, 1);
original.cellData[0][0] = {
f: '=SUM(A1:B10)',
v: 100,
si: 'formula-id',
};
const cloned = cloneWorksheetData(original);
expect(cloned.cellData[0][0]).toEqual(original.cellData[0][0]);
expect(cloned.cellData[0][0]).not.toBe(original.cellData[0][0]);
});
it('should handle custom data', () => {
const original = createTestWorksheetData(1, 1);
original.custom = { key1: 'value1', nested: { key2: 'value2' } };
original.cellData[0][0].custom = { cellKey: 'cellValue' };
const cloned = cloneWorksheetData(original);
expect(cloned.custom).toEqual(original.custom);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.cellData[0][0].custom).toEqual(original.cellData[0][0].custom);
expect(cloned.cellData[0][0].custom).not.toBe(original.cellData[0][0].custom);
});
});
+387
View File
@@ -0,0 +1,387 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// #region Optimized Clone Functions
import type { IObjectArrayPrimitiveType, IObjectMatrixPrimitiveType, Nullable } from '../shared';
import type { ICellData, ICellDataWithSpanAndDisplay, IColumnData, IRange, IRowData, IWorksheetData } from './typedef';
/**
* Fast clone for primitive values and simple objects.
* Avoids type checking overhead when we know the structure.
*/
export function cloneValue<T>(value: T): T {
if (value === null || value === undefined) {
return value;
}
const type = typeof value;
// Primitives are immutable, return directly
if (type !== 'object') {
return value;
}
// Handle arrays
if (Array.isArray(value)) {
const len = value.length;
const result = new Array(len);
for (let i = 0; i < len; i++) {
result[i] = cloneValue(value[i]);
}
return result as T;
}
// Handle plain objects
const result: Record<string, any> = {};
const keys = Object.keys(value as object);
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
result[key] = cloneValue((value as Record<string, any>)[key]);
}
return result as T;
}
/**
* Fast clone for ICellData. Optimized for the known structure.
* @param cell - The cell data to clone
* @returns A deep clone of the cell data
*/
export function cloneCellData(cell: Nullable<ICellData>): Nullable<ICellData> {
if (cell === null || cell === undefined) {
return cell;
}
const result: ICellData = {};
// p - IDocumentData (complex object, needs deep clone)
if (cell.p !== undefined) {
result.p = cell.p === null ? null : cloneValue(cell.p);
}
// s - style id (string) or IStyleData (object)
if (cell.s !== undefined) {
if (cell.s === null || typeof cell.s === 'string') {
result.s = cell.s;
} else {
result.s = cloneValue(cell.s);
}
}
// v - primitive value (string | number | boolean)
if (cell.v !== undefined) {
result.v = cell.v;
}
// t - CellValueType (number enum)
if (cell.t !== undefined) {
result.t = cell.t;
}
// f - formula string
if (cell.f !== undefined) {
result.f = cell.f;
}
// ref - formula array reference
if (cell.ref !== undefined) {
result.ref = cell.ref;
}
// xf - Excel formula prefix
if (cell.xf !== undefined) {
result.xf = cell.xf;
}
// si - formula id
if (cell.si !== undefined) {
result.si = cell.si;
}
// custom - user stored custom fields
if (cell.custom !== undefined) {
result.custom = cell.custom === null ? null : cloneValue(cell.custom);
}
return result;
}
/**
* Fast clone for ICellDataWithSpanAndDisplay. Optimized for the known structure.
* This extends cloneCellData with additional span and display properties.
* @param cell - The cell data with span and display info to clone
* @returns A deep clone of the cell data
*/
export function cloneCellDataWithSpanAndDisplay(cell: Nullable<ICellDataWithSpanAndDisplay>): Nullable<ICellDataWithSpanAndDisplay> {
if (cell === null || cell === undefined) {
return cell;
}
const result: ICellDataWithSpanAndDisplay = {};
// p - IDocumentData (complex object, needs deep clone)
if (cell.p !== undefined) {
result.p = cell.p === null ? null : cloneValue(cell.p);
}
// s - style id (string) or IStyleData (object)
if (cell.s !== undefined) {
if (cell.s === null || typeof cell.s === 'string') {
result.s = cell.s;
} else {
result.s = cloneValue(cell.s);
}
}
// v - primitive value (string | number | boolean)
if (cell.v !== undefined) {
result.v = cell.v;
}
// t - CellValueType (number enum)
if (cell.t !== undefined) {
result.t = cell.t;
}
// f - formula string
if (cell.f !== undefined) {
result.f = cell.f;
}
// ref - formula array reference
if (cell.ref !== undefined) {
result.ref = cell.ref;
}
// xf - Excel formula prefix
if (cell.xf !== undefined) {
result.xf = cell.xf;
}
// si - formula id
if (cell.si !== undefined) {
result.si = cell.si;
}
// custom - user stored custom fields
if (cell.custom !== undefined) {
result.custom = cell.custom === null ? null : cloneValue(cell.custom);
}
// rowSpan - span properties (primitives)
if (cell.rowSpan !== undefined) {
result.rowSpan = cell.rowSpan;
}
// colSpan - span properties (primitives)
if (cell.colSpan !== undefined) {
result.colSpan = cell.colSpan;
}
// displayV - display value (primitive string)
if (cell.displayV !== undefined) {
result.displayV = cell.displayV;
}
return result;
}
/**
* Fast clone for cell data matrix. Optimized for sparse matrix structure.
* @param cellData - The cell data matrix to clone
* @returns A deep clone of the cell data matrix
*/
export function cloneCellDataMatrix(
cellData: IObjectMatrixPrimitiveType<ICellData>
): IObjectMatrixPrimitiveType<ICellData> {
const result: IObjectMatrixPrimitiveType<ICellData> = {};
const rowKeys = Object.keys(cellData);
for (let i = 0, rowLen = rowKeys.length; i < rowLen; i++) {
const rowKey = rowKeys[i];
const rowNum = Number(rowKey);
const rowData = cellData[rowNum];
if (rowData === undefined) continue;
const clonedRow: Record<number, ICellData> = {};
const colKeys = Object.keys(rowData);
for (let j = 0, colLen = colKeys.length; j < colLen; j++) {
const colKey = colKeys[j];
const colNum = Number(colKey);
const cell = rowData[colNum];
if (cell !== undefined && cell !== null) {
clonedRow[colNum] = cloneCellData(cell) as ICellData;
}
}
result[rowNum] = clonedRow;
}
return result;
}
/**
* Fast clone for row/column data arrays (sparse arrays stored as objects).
* @param data - The row or column data to clone
* @returns A deep clone of the row or column data
*/
function cloneRowColumnData<T extends Partial<IRowData> | Partial<IColumnData>>(
data: IObjectArrayPrimitiveType<T>
): IObjectArrayPrimitiveType<T> {
const result: IObjectArrayPrimitiveType<T> = {};
const keys = Object.keys(data);
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
const idx = Number(key);
const item = data[idx];
if (item === undefined) continue;
const cloned: Record<string, any> = {};
// Handle common properties
if ('h' in item && item.h !== undefined) cloned.h = item.h;
if ('ia' in item && item.ia !== undefined) cloned.ia = item.ia;
if ('ah' in item && item.ah !== undefined) cloned.ah = item.ah;
if ('hd' in item && item.hd !== undefined) cloned.hd = item.hd;
if ('w' in item && item.w !== undefined) cloned.w = item.w;
// s - style (string or object)
if ('s' in item && item.s !== undefined) {
if (item.s === null || typeof item.s === 'string') {
cloned.s = item.s;
} else {
cloned.s = cloneValue(item.s);
}
}
// custom - user stored custom fields
if ('custom' in item && item.custom !== undefined) {
cloned.custom = item.custom === null ? null : cloneValue(item.custom);
}
result[idx] = cloned as T;
}
return result;
}
/**
* Fast clone for IRange array (merge data).
* @param ranges - The array of ranges to clone
* @returns A shallow clone of the ranges (IRange contains only primitive values)
*/
function cloneMergeData(ranges: IRange[]): IRange[] {
const len = ranges.length;
const result = new Array<IRange>(len);
for (let i = 0; i < len; i++) {
const range = ranges[i];
// IRange only contains primitive values, shallow copy is sufficient
result[i] = {
startRow: range.startRow,
startColumn: range.startColumn,
endRow: range.endRow,
endColumn: range.endColumn,
rangeType: range.rangeType,
startAbsoluteRefType: range.startAbsoluteRefType,
endAbsoluteRefType: range.endAbsoluteRefType,
};
}
return result;
}
/**
* Optimized deep clone specifically for IWorksheetData.
* This is significantly faster than generic deepClone because:
* 1. No recursive type checking - we know the structure
* 2. Direct property access instead of Object.keys iteration for known properties
* 3. Specialized handlers for cellData matrix (the largest data)
* 4. Primitive values copied directly without cloning
*
* @param worksheet - The worksheet data to clone
* @returns A deep clone of the worksheet data
*/
export function cloneWorksheetData(worksheet: IWorksheetData): IWorksheetData {
const result: IWorksheetData = {
// Primitive values - direct copy
id: worksheet.id,
name: worksheet.name,
tabColor: worksheet.tabColor,
hidden: worksheet.hidden,
rowCount: worksheet.rowCount,
columnCount: worksheet.columnCount,
zoomRatio: worksheet.zoomRatio,
scrollTop: worksheet.scrollTop,
scrollLeft: worksheet.scrollLeft,
defaultColumnWidth: worksheet.defaultColumnWidth,
defaultRowHeight: worksheet.defaultRowHeight,
showGridlines: worksheet.showGridlines,
rightToLeft: worksheet.rightToLeft,
// Freeze - simple object with primitive values
freeze: {
xSplit: worksheet.freeze.xSplit,
ySplit: worksheet.freeze.ySplit,
startRow: worksheet.freeze.startRow,
startColumn: worksheet.freeze.startColumn,
},
// Row/column headers - simple objects
rowHeader: {
width: worksheet.rowHeader.width,
hidden: worksheet.rowHeader.hidden,
},
columnHeader: {
height: worksheet.columnHeader.height,
hidden: worksheet.columnHeader.hidden,
},
// Merge data - array of IRange (primitives only)
mergeData: cloneMergeData(worksheet.mergeData),
// Cell data matrix - the largest data, use optimized clone
cellData: cloneCellDataMatrix(worksheet.cellData),
// Row/column data - sparse arrays
rowData: cloneRowColumnData<Partial<IRowData>>(worksheet.rowData),
columnData: cloneRowColumnData<Partial<IColumnData>>(worksheet.columnData),
};
// Optional properties
if (worksheet.gridlinesColor !== undefined) {
result.gridlinesColor = worksheet.gridlinesColor;
}
if (worksheet.defaultStyle !== undefined) {
if (worksheet.defaultStyle === null || typeof worksheet.defaultStyle === 'string') {
result.defaultStyle = worksheet.defaultStyle;
} else {
result.defaultStyle = cloneValue(worksheet.defaultStyle);
}
}
if (worksheet.custom !== undefined) {
result.custom = worksheet.custom === null ? null : cloneValue(worksheet.custom);
}
return result;
}
// #endregion
+2 -1
View File
@@ -36,6 +36,7 @@ import { SpanModel } from './span-model';
import { CellModeEnum } from './typedef';
import { addLinkToDocumentModel, createDocumentModelWithStyle, DEFAULT_PADDING_DATA, extractOtherStyle, getFontFormat, isNotNullOrUndefined } from './util';
import { SheetViewModel } from './view-model';
import { cloneWorksheetData } from './clone';
export interface IDocumentLayoutObject {
documentModel: Nullable<DocumentDataModel>;
@@ -430,7 +431,7 @@ export class Worksheet {
*/
clone(): Worksheet {
const { _snapshot: _config } = this;
const copy = Tools.deepClone(_config);
const copy = cloneWorksheetData(_config);
return new Worksheet(this.unitId, copy, this._styles);
}
+11 -2
View File
@@ -29,7 +29,7 @@ import { UniverInstanceType } from './common/unit';
import { DocumentDataModel } from './docs/data-model/document-data-model';
import { AuthzIoLocalService } from './services/authz-io/authz-io-local.service';
import { IAuthzIoService } from './services/authz-io/type';
import { CommandService, ICommandService } from './services/command/command.service';
import { COMMAND_LOG_EXECUTION_CONFIG_KEY, CommandService, ICommandService } from './services/command/command.service';
import { ConfigService, IConfigService } from './services/config/config.service';
import { ContextService, IContextService } from './services/context/context.service';
import { ErrorService } from './services/error/error.service';
@@ -82,6 +82,12 @@ export interface IUniverConfig {
*/
logLevel?: LogLevel;
/**
* Whether to enable logging for command execution.
* @default false
*/
logCommandExecution?: boolean;
/**
* The override dependencies of the Univer instance.
*/
@@ -113,12 +119,15 @@ export class Univer implements IDisposable {
constructor(config: Partial<IUniverConfig> = {}, parentInjector?: Injector) {
const injector = this._injector = createUniverInjector(parentInjector, config?.override);
const { theme, darkMode, locale, locales, logLevel } = config;
const { theme, darkMode, locale, locales, logLevel, logCommandExecution } = config;
if (theme) this._injector.get(ThemeService).setTheme(theme);
if (darkMode) this._injector.get(ThemeService).setDarkMode(darkMode);
if (locales) this._injector.get(LocaleService).load(locales);
if (locale) this._injector.get(LocaleService).setLocale(locale);
if (logLevel) this._injector.get(ILogService).setLogLevel(logLevel);
if (logCommandExecution !== undefined) {
this._injector.get(IConfigService).setConfig(COMMAND_LOG_EXECUTION_CONFIG_KEY, logCommandExecution);
}
this._init(injector);
}
@@ -20,7 +20,7 @@ import type { IWorkbookData, Workbook } from '@univerjs/core';
import { ICommandService, Inject, Injector, IUniverInstanceService, LocaleService, LocaleType, Plugin, RANGE_TYPE, UndoCommand, Univer, UniverInstanceType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import { CopySheetCommand, InsertColByRangeCommand, InsertColMutation, InsertRowByRangeCommand, InsertSheetMutation, MoveColsCommand, MoveColsMutation, MoveRangeCommand, MoveRangeMutation, MoveRowsCommand, MoveRowsMutation, RefRangeService, RemoveColByRangeCommand, RemoveColCommand, RemoveColMutation, RemoveRowByRangeCommand, RemoveRowCommand, RemoveRowMutation, SetRangeValuesMutation, SetSelectionsOperation, SheetInterceptorService, SheetRangeThemeModel, SheetsSelectionsService, ZebraCrossingCacheController } from '@univerjs/sheets';
import { CopySheetCommand, InsertColByRangeCommand, InsertColMutation, InsertRowByRangeCommand, InsertSheetMutation, MoveColsCommand, MoveColsMutation, MoveRangeCommand, MoveRangeMutation, MoveRowsCommand, MoveRowsMutation, RefRangeService, RemoveColByRangeCommand, RemoveColCommand, RemoveColMutation, RemoveRowByRangeCommand, RemoveRowCommand, RemoveRowMutation, SetRangeValuesMutation, SetSelectionsOperation, SheetInterceptorService, SheetLazyExecuteScheduleService, SheetRangeThemeModel, SheetsSelectionsService, ZebraCrossingCacheController } from '@univerjs/sheets';
import { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from '../../services/sheet-filter.service';
import { SheetsFilterController } from '../sheets-filter.controller';
import { SetSheetsFilterCriteriaMutation } from '../../commands/mutations/sheets-filter.mutation';
@@ -138,6 +138,7 @@ function createFilterControllerTestBed(workbookData?: IWorkbookData) {
this._injector.add([SheetsFilterController]);
this._injector.add([SheetsSelectionsService]);
this._injector.add([SheetInterceptorService]);
this._injector.add([SheetLazyExecuteScheduleService]);
}
override onReady(): void {
@@ -112,6 +112,7 @@ export class UpdateFormulaController extends Disposable {
if (
(options && options.onlyLocal === true) ||
(options && options.syncOnly === true) ||
params.trigger === SetStyleCommand.id ||
params.trigger === SetBorderCommand.id ||
params.trigger === ClearSelectionFormatCommand.id
@@ -15,8 +15,9 @@
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import { CommandType, ICommandService, LocaleService } from '@univerjs/core';
import { RemoveSheetCommand } from '@univerjs/sheets';
import type { IUniverSheetsConfig } from '@univerjs/sheets';
import { CommandType, ICommandService, IConfigService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { countCells, defaultLargeSheetOperationConfig, getSheetCommandTarget, RemoveSheetCommand, SHEETS_PLUGIN_CONFIG_KEY } from '@univerjs/sheets';
import { IConfirmService } from '@univerjs/ui';
interface IRemoveSheetConfirmCommandParams {
@@ -31,21 +32,38 @@ export const RemoveSheetConfirmCommand: ICommand = {
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const configService = accessor.get(IConfigService);
const univerInstanceService = accessor.get(IUniverInstanceService);
// Check if this is a large sheet that needs confirmation
const target = getSheetCommandTarget(univerInstanceService, { subUnitId });
if (!target) return false;
const { worksheet } = target;
const pluginConfig = configService.getConfig<IUniverSheetsConfig>(SHEETS_PLUGIN_CONFIG_KEY);
const largeSheetConfig = {
...defaultLargeSheetOperationConfig,
...pluginConfig?.largeSheetOperation,
};
const cellCount = countCells(worksheet.getCellMatrix());
const isLargeSheet = cellCount >= largeSheetConfig.largeSheetCellCountThreshold;
// Only show confirmation dialog for large sheets
const result = await confirmService.confirm({
id: 'sheet.confirm.remove-sheet',
title: {
title: localeService.t('sheetConfig.deleteSheet'),
},
children: { title: localeService.t('sheetConfig.deleteSheetContent') },
children: { title: isLargeSheet ? localeService.t('sheetConfig.deleteLargeSheetContent') : localeService.t('sheetConfig.deleteSheetContent') },
cancelText: localeService.t('button.cancel'),
confirmText: localeService.t('button.confirm'),
});
if (result) {
await commandService.executeCommand(RemoveSheetCommand.id, { subUnitId });
return true;
if (!result) {
return false;
}
return false;
await commandService.executeCommand(RemoveSheetCommand.id, { subUnitId });
return true;
},
};
@@ -29,6 +29,9 @@ import type { IDiscreteRange } from '../utils/range-tools';
import {
cellToRange,
CellValueType,
cloneCellData,
cloneCellDataMatrix,
cloneValue,
CustomRangeType,
DEFAULT_STYLES,
generateRandomId,
@@ -165,13 +168,13 @@ export function getMoveRangeMutations(
const toCellMatrix = toWorksheet.getCellMatrix();
Range.foreach(fromRange, (row, col) => {
fromCellValue.setValue(row, col, Tools.deepClone(fromCellMatrix.getValue(row, col)));
fromCellValue.setValue(row, col, cloneCellData(fromCellMatrix.getValue(row, col)));
newFromCellValue.setValue(row, col, null);
});
const toCellValue = new ObjectMatrix<Nullable<ICellData>>();
Range.foreach(toRange, (row, col) => {
toCellValue.setValue(row, col, Tools.deepClone(toCellMatrix.getValue(row, col)));
toCellValue.setValue(row, col, cloneCellData(toCellMatrix.getValue(row, col)));
});
const newToCellValue = new ObjectMatrix<Nullable<ICellData>>();
@@ -376,17 +379,17 @@ export function getSetCellValueMutations(
}
if (value.p?.body && isRichText(value.p.body)) {
const newValue = Tools.deepClone({ p: value.p, v: value.v });
const newValue = { p: cloneValue(value.p), v: value.v };
valueMatrix.setValue(realRow, realCol, newValue);
} else {
valueMatrix.setValue(realRow, realCol, Tools.deepClone(cellValue));
valueMatrix.setValue(realRow, realCol, cellValue && cloneCellData(cellValue)!);
}
});
// set cell value and style
const setValuesMutation: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: Tools.deepClone(valueMatrix.getMatrix()),
cellValue: cloneCellDataMatrix(valueMatrix.getMatrix()),
};
redoMutationsInfo.push({
@@ -492,7 +495,7 @@ export function getSetCellStyleMutations(
const setValuesMutation: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: Tools.deepClone(valueMatrix.getMatrix()),
cellValue: cloneCellDataMatrix(valueMatrix.getMatrix()),
};
redoMutationsInfo.push({
@@ -545,7 +548,7 @@ export function getClearCellStyleMutations(
const clearMutation: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: Tools.deepClone(clearStyleMatrix.getMatrix()),
cellValue: cloneCellDataMatrix(clearStyleMatrix.getMatrix()),
};
redoMutationsInfo.push({
id: SetRangeValuesMutation.id,
@@ -592,7 +595,7 @@ export function getClearCellValueMutations(
const clearMutation: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: Tools.deepClone(clearValueMatrix.getMatrix()),
cellValue: cloneCellDataMatrix(clearValueMatrix.getMatrix()),
};
redoMutationsInfo.push({
id: SetRangeValuesMutation.id,
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'El nom del full no pot estar buit.',
sheetNameAlreadyExistsError: 'El nom del full ja existeix. Si us plau, introduïu un altre nom.',
deleteSheet: 'Suprimir el full de càlcul',
deleteSheetContent:
deleteSheetContent: 'Confirmeu per suprimir aquest full de càlcul?',
deleteLargeSheetContent:
'Confirmeu per suprimir aquest full de càlcul. No es podrà recuperar després de la supressió. Esteu segur que el voleu suprimir?',
addProtectSheet: 'Protegir el full de càlcul',
removeProtectSheet: 'Desprotegir el full de càlcul',
+2 -1
View File
@@ -176,7 +176,8 @@ const locale = {
sheetNameCannotIsEmptyError: 'The sheet name cannot be empty.',
sheetNameAlreadyExistsError: 'The sheet name already exists. Please enter another name.',
deleteSheet: 'Delete worksheet',
deleteSheetContent:
deleteSheetContent: 'Confirm to delete this worksheet?',
deleteLargeSheetContent:
'Confirm to delete this worksheet. It will not be retrieved after deletion. Are you sure you want to delete it?',
addProtectSheet: 'Protect Worksheet',
removeProtectSheet: 'Unprotect Worksheet',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'El nombre de la hoja no puede estar vacío.',
sheetNameAlreadyExistsError: 'El nombre de la hoja ya existe. Por favor, introduce otro nombre.',
deleteSheet: 'Eliminar hoja de cálculo',
deleteSheetContent:
deleteSheetContent: '¿Confirmar para eliminar esta hoja de cálculo?',
deleteLargeSheetContent:
'Confirma para eliminar esta hoja de cálculo. No se podrá recuperar después de la eliminación. ¿Estás seguro de que quieres eliminarla?',
addProtectSheet: 'Proteger hoja de cálculo',
removeProtectSheet: 'Desproteger hoja de cálculo',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'نام برگه نمی‌تواند خالی باشد.',
sheetNameAlreadyExistsError: 'نام برگه قبلاً وجود دارد. لطفا نام دیگری وارد کنید.',
deleteSheet: 'حذف برگه',
deleteSheetContent:
deleteSheetContent: 'تایید حذف این برگه؟',
deleteLargeSheetContent:
'تایید حذف این برگه. پس از حذف، بازیابی نخواهد شد. آیا مطمئن هستید که می‌خواهید آن را حذف کنید؟',
addProtectSheet: 'محافظت از برگه',
removeProtectSheet: 'لغو محافظت از برگه',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'Le nom de la feuille ne peut pas être vide.',
sheetNameAlreadyExistsError: 'Le nom de la feuille existe déjà. Veuillez entrer un autre nom.',
deleteSheet: 'Supprimer la feuille de calcul',
deleteSheetContent:
deleteSheetContent: 'Confirmer la suppression de cette feuille de calcul ?',
deleteLargeSheetContent:
'Confirmer la suppression de cette feuille de calcul. Elle ne pourra pas être récupérée après suppression. Êtes-vous sûr de vouloir la supprimer ?',
addProtectSheet: 'Protéger la feuille de calcul',
removeProtectSheet: 'Déprotéger la feuille de calcul',
+2 -1
View File
@@ -177,7 +177,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'シート名は空にできません。',
sheetNameAlreadyExistsError: '同じシート名が既に存在します。別の名前を入力してください。',
deleteSheet: 'シートを削除',
deleteSheetContent: 'このシートを削除してもよいですか?削除後は復元できません。本当に削除しますか?',
deleteSheetContent: 'このシートを削除してもよいですか?',
deleteLargeSheetContent: 'このシートを削除してもよいですか?削除後は復元できません。本当に削除しますか?',
addProtectSheet: 'シート保護を追加',
removeProtectSheet: 'シート保護を解除',
changeSheetPermission: 'シート権限を変更',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: '시트 이름은 비어 있을 수 없습니다.',
sheetNameAlreadyExistsError: '시트 이름이 이미 존재합니다. 다른 이름을 입력해주세요.',
deleteSheet: '시트 삭제',
deleteSheetContent:
deleteSheetContent: '이 시트를 삭제하시겠습니까?',
deleteLargeSheetContent:
'이 시트를 삭제하시겠습니까? 삭제 후에는 복구할 수 없습니다. 삭제하시겠습니까?',
addProtectSheet: '시트 보호',
removeProtectSheet: '시트 보호 해제',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'Имя листа не может быть пустым.',
sheetNameAlreadyExistsError: 'Имя листа уже существует. Пожалуйста, введите другое имя.',
deleteSheet: 'Удалить лист',
deleteSheetContent:
deleteSheetContent: 'Подтвердите удаление этого листа?',
deleteLargeSheetContent:
'Подтвердите удаление этого листа. После удаления его нельзя будет восстановить. Вы уверены, что хотите удалить его?',
addProtectSheet: 'Защитить лист',
removeProtectSheet: 'Снять защиту листа',
+2 -1
View File
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: 'Tên không được để trống.',
sheetNameAlreadyExistsError: 'Trang bảng đã tồn tại, vui lòng nhập tên khác.',
deleteSheet: 'Xóa trang bảng',
deleteSheetContent: 'Xác nhận xóa trang bảng này, sau khi xóa sẽ không thể khôi phục, bạn có chắc chắn muốn xóa không?',
deleteSheetContent: 'Xác nhận xóa trang bảng này?',
deleteLargeSheetContent: 'Xác nhận xóa trang bảng này, sau khi xóa sẽ không thể khôi phục, bạn có chắc chắn muốn xóa không?',
addProtectSheet: 'Bảo vệ trang bảng',
removeProtectSheet: 'Bỏ bảo vệ trang bảng',
changeSheetPermission: 'Thay đổi quyền hạn trang bảng',
+2 -1
View File
@@ -179,7 +179,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: '名称不能为空。',
sheetNameAlreadyExistsError: '工作表已存在,请输入其它名称。',
deleteSheet: '删除工作表',
deleteSheetContent: '确认删除此工作表,删除后将不可找回,确定要删除吗',
deleteSheetContent: '确认删除此工作表?',
deleteLargeSheetContent: '确认删除此工作表,删除后将不可找回,确定要删除吗?',
addProtectSheet: '保护工作表',
removeProtectSheet: '取消保护工作表',
changeSheetPermission: '更改工作表权限',
+2 -1
View File
@@ -179,7 +179,8 @@ const locale: typeof enUS = {
sheetNameCannotIsEmptyError: '名稱不能為空。 ',
sheetNameAlreadyExistsError: '工作表已存在,請輸入其它名稱。 ',
deleteSheet: '刪除工作表',
deleteSheetContent: '確認刪除此工作表,刪除後將不可找回,確定要刪除嗎? ',
deleteSheetContent: '確認刪除此工作表',
deleteLargeSheetContent: '確認刪除此工作表,刪除後將不可找回,確定要刪除嗎?',
addProtectSheet: '保護工作表',
removeProtectSheet: '取消保護工作表',
changeSheetPermission: '更改工作表權限',
@@ -263,6 +263,8 @@ describe('test "spilitLargeSetRangeValuesMutations"', () => {
expect(chunk.id).toBe(SetRangeValuesMutation.id);
expect(chunk.params.unitId).toBe('1');
expect(chunk.params.subUnitId).toBe('1');
// Verify __splitChunk__ flag is set for split chunks
expect(chunk.params.__splitChunk__).toBe(true);
});
});
@@ -40,6 +40,7 @@ import type {
} from './type';
import {
CellModeEnum,
cloneCellDataWithSpanAndDisplay,
createIdentifier,
Disposable,
ErrorService,
@@ -56,7 +57,6 @@ import {
sequenceExecute,
ThemeService,
toDisposable,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService, withCurrentTypeOfRenderer } from '@univerjs/engine-render';
@@ -77,6 +77,7 @@ import { IMarkSelectionService } from '../mark-selection/mark-selection.service'
import { SheetSkeletonManagerService } from '../sheet-skeleton-manager.service';
import { createCopyPasteSelectionStyle } from '../utils/selection-util';
import { cloneCellDataWithSpanInfo } from './clone';
import { CopyContentCache, extractId, genId } from './copy-content-cache';
import { HtmlToUSMService } from './html-to-usm/converter';
import { LarkPastePlugin } from './html-to-usm/paste-plugins/plugin-lark';
@@ -439,7 +440,7 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
for (let c = startColumn; c <= endColumn; c++) {
const cellData = matrix.getValue(r, c);
if (cellData) {
const newCellData = Tools.deepClone(cellData);
const newCellData = cloneCellDataWithSpanAndDisplay(cellData)!;
plainMatrix.setValue(rowIndex - startRow, c - startColumn, {
...getEmptyCell(),
...newCellData,
@@ -626,27 +627,24 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
return res;
}
// eslint-disable-next-line max-lines-per-function, complexity
// eslint-disable-next-line max-lines-per-function
private async _pasteInternal(copyId: string, pasteType: IPasteHookValueType): Promise<boolean> {
// const target = this._getPastingTarget();
// const { selection, unitId, subUnitId } = target;
const cachedData = Tools.deepClone(this._copyContentCache.get(copyId));
const { range, matrix: cellMatrix, unitId: copyUnitId, subUnitId: copySubUnitId } = cachedData || {};
if (!cellMatrix || !cachedData || !range || !copyUnitId || !copySubUnitId) {
return false;
}
if (!cellMatrix || !cachedData) {
const cachedData = this._copyContentCache.get(copyId);
const { range, matrix: cachedMatrix, unitId: copyUnitId, subUnitId: copySubUnitId } = cachedData || {};
if (!cachedMatrix || !cachedData || !range || !copyUnitId || !copySubUnitId) {
return false;
}
const { mapFunc } = virtualizeDiscreteRanges([range]);
const worksheet = this._univerInstanceService.getUniverSheetInstance(copyUnitId)?.getSheetBySheetId(copySubUnitId);
cellMatrix.forValue((row, col, value) => {
const cellMatrix = new ObjectMatrix<ICellDataWithSpanInfo>();
cachedMatrix.forValue((row, col, value) => {
const { row: actualRow, col: actualColumn } = mapFunc(row, col);
const style = worksheet?.getComposedCellStyle(actualRow, actualColumn);
const newValue = Tools.deepClone(value);
const newValue = cloneCellDataWithSpanInfo(value)!;
newValue.s = style;
cellMatrix.setValue(row, col, newValue);
@@ -0,0 +1,99 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import { cloneValue } from '@univerjs/core';
import type { ICellDataWithSpanInfo } from './type';
/**
* Fast clone for ICellDataWithSpanInfo. Optimized for the known structure.
* This extends cloneCellData with additional span and plain properties.
* @param cell - The cell data with span info to clone
* @returns A deep clone of the cell data
*/
export function cloneCellDataWithSpanInfo(cell: Nullable<ICellDataWithSpanInfo>): Nullable<ICellDataWithSpanInfo> {
if (cell === null || cell === undefined) {
return cell;
}
const result: ICellDataWithSpanInfo = {};
// p - IDocumentData (complex object, needs deep clone)
if (cell.p !== undefined) {
result.p = cell.p === null ? null : cloneValue(cell.p);
}
// s - style id (string) or IStyleData (object)
if (cell.s !== undefined) {
if (cell.s === null || typeof cell.s === 'string') {
result.s = cell.s;
} else {
result.s = cloneValue(cell.s);
}
}
// v - primitive value (string | number | boolean)
if (cell.v !== undefined) {
result.v = cell.v;
}
// t - CellValueType (number enum)
if (cell.t !== undefined) {
result.t = cell.t;
}
// f - formula string
if (cell.f !== undefined) {
result.f = cell.f;
}
// ref - formula array reference
if (cell.ref !== undefined) {
result.ref = cell.ref;
}
// xf - Excel formula prefix
if (cell.xf !== undefined) {
result.xf = cell.xf;
}
// si - formula id
if (cell.si !== undefined) {
result.si = cell.si;
}
// custom - user stored custom fields
if (cell.custom !== undefined) {
result.custom = cell.custom === null ? null : cloneValue(cell.custom);
}
// rowSpan - span properties (primitives)
if (cell.rowSpan !== undefined) {
result.rowSpan = cell.rowSpan;
}
// colSpan - span properties (primitives)
if (cell.colSpan !== undefined) {
result.colSpan = cell.colSpan;
}
// plain - plain text value (primitive string)
if (cell.plain !== undefined) {
result.plain = cell.plain;
}
return result;
}
@@ -256,6 +256,7 @@ export function spilitLargeSetRangeValuesMutations(
params: {
...mutation.params,
cellValue: chunkMatrix.getMatrix(),
__splitChunk__: true,
},
});
}
@@ -21,6 +21,7 @@ import enUS from '../../../locale/en-US';
import zhCN from '../../../locale/zh-CN';
import { InsertSheetMutation } from '../../mutations/insert-sheet.mutation';
import { RemoveSheetMutation } from '../../mutations/remove-sheet.mutation';
import { SetRangeValuesMutation } from '../../mutations/set-range-values.mutation';
import { SetWorksheetActiveOperation } from '../../operations/set-worksheet-active.operation';
import { CopySheetCommand, getCopyUniqueSheetName } from '../copy-worksheet.command';
import { RemoveSheetCommand } from '../remove-sheet.command';
@@ -40,6 +41,7 @@ describe('Test copy worksheet commands', () => {
commandService = get(ICommandService);
commandService.registerCommand(CopySheetCommand);
commandService.registerCommand(InsertSheetMutation);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(SetWorksheetActiveOperation);
commandService.registerCommand(SetWorksheetActivateCommand);
commandService.registerCommand(RemoveSheetCommand);
@@ -59,10 +61,22 @@ describe('Test copy worksheet commands', () => {
if (!workbook) throw new Error('This is an error');
function getSheetCopyPart(sheet: Worksheet) {
const config = sheet.getConfig();
const { id, name, ...rest } = config;
const { id, name, cellData, ...rest } = config;
return rest;
}
function getCellDataWithoutType(sheet: Worksheet) {
const config = sheet.getConfig();
const result: Record<number, Record<number, { v: unknown }>> = {};
for (const [rowKey, row] of Object.entries(config.cellData)) {
result[Number(rowKey)] = {};
for (const [colKey, cell] of Object.entries(row as Record<string, { v?: unknown }>)) {
result[Number(rowKey)][Number(colKey)] = { v: cell?.v };
}
}
return result;
}
expect(
await commandService.executeCommand(SetWorksheetActivateCommand.id, { subUnitId: 'sheet1' })
).toBeTruthy();
@@ -76,6 +90,7 @@ describe('Test copy worksheet commands', () => {
const [oldSheet, newSheet] = workbook.getSheets();
expect(getSheetCopyPart(newSheet)).toEqual(getSheetCopyPart(oldSheet));
expect(getCellDataWithoutType(newSheet)).toEqual(getCellDataWithoutType(oldSheet));
// undo;
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
@@ -86,6 +101,7 @@ describe('Test copy worksheet commands', () => {
const [oldSheet2, newSheet2] = workbook.getSheets();
expect(getSheetCopyPart(newSheet2)).toEqual(getSheetCopyPart(oldSheet2));
expect(getCellDataWithoutType(newSheet2)).toEqual(getCellDataWithoutType(oldSheet2));
});
it('Function getCopyUniqueSheetName', async () => {
@@ -32,6 +32,7 @@ import {
import enUS from '../../../locale/en-US';
import { RangeProtectionRuleModel } from '../../../model/range-protection-rule.model';
import { BorderStyleManagerService } from '../../../services/border-style-manager.service';
import { SheetLazyExecuteScheduleService } from '../../../services/lazy-execute-schedule.service';
import { WorkbookPermissionService } from '../../../services/permission/workbook-permission/workbook-permission.service';
import { WorksheetProtectionPointModel, WorksheetProtectionRuleModel } from '../../../services/permission/worksheet-permission';
import { WorksheetPermissionService } from '../../../services/permission/worksheet-permission/worksheet-permission.service';
@@ -106,6 +107,7 @@ export function createCommandTestBed(workbookData?: IWorkbookData, dependencies?
injector.add([BorderStyleManagerService]);
injector.add([SheetInterceptorService]);
injector.add([SheetSkeletonService]);
injector.add([SheetLazyExecuteScheduleService]);
dependencies?.forEach((d) => injector.add(d));
@@ -14,37 +14,237 @@
* limitations under the License.
*/
import type { IAccessor, ICommand, IMutationInfo, Workbook } from '@univerjs/core';
import type { IAccessor, ICellData, ICommand, IMutationInfo, IObjectMatrixPrimitiveType, Nullable, Workbook } from '@univerjs/core';
import type { IInsertSheetMutationParams, IRemoveSheetMutationParams } from '../../basics/interfaces/mutation-interface';
import type { IUniverSheetsConfig } from '../../controllers/config.schema';
import type { ISetRangeValuesMutationParams } from '../mutations/set-range-values.mutation';
import {
cloneWorksheetData,
CommandType,
generateRandomId,
ICommandService,
IConfigService,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
sequenceExecute,
Tools,
} from '@univerjs/core';
import { defaultLargeSheetOperationConfig, SHEETS_PLUGIN_CONFIG_KEY } from '../../controllers/config.schema';
import { SheetLazyExecuteScheduleService } from '../../services/lazy-execute-schedule.service';
import { SheetInterceptorService } from '../../services/sheet-interceptor/sheet-interceptor.service';
import { CopyWorksheetEndMutation } from '../mutations/copy-worksheet-end.mutation';
import { InsertSheetMutation, InsertSheetUndoMutationFactory } from '../mutations/insert-sheet.mutation';
import { RemoveSheetMutation } from '../mutations/remove-sheet.mutation';
import { SetRangeValuesMutation } from '../mutations/set-range-values.mutation';
import { getSheetCommandTarget } from './utils/target-util';
/**
* Count the total number of cells in cellData
*/
function countCells(cellData: IObjectMatrixPrimitiveType<Nullable<ICellData>>): number {
let count = 0;
for (const rowKey of Object.keys(cellData)) {
const rowData = cellData[Number(rowKey)];
if (rowData) {
count += Object.keys(rowData).length;
}
}
return count;
}
/**
* Split cellData into batches for SetRangeValuesMutation
* Returns the first chunk separately (to be included in InsertSheetMutation)
* and remaining chunks (to be scheduled for idle execution)
* @param unitId - The unit ID
* @param subUnitId - The sub unit ID (sheet ID)
* @param cellData - The cell data to split
* @param batchSize - The maximum number of cells per batch
* @returns Object containing firstChunkCellData and remainingMutations
*/
function splitCellDataIntoBatches(
unitId: string,
subUnitId: string,
cellData: IObjectMatrixPrimitiveType<Nullable<ICellData>>,
batchSize: number
): {
firstChunkCellData: IObjectMatrixPrimitiveType<Nullable<ICellData>>;
remainingMutations: IMutationInfo<ISetRangeValuesMutationParams>[];
} {
const batches: IObjectMatrixPrimitiveType<Nullable<ICellData>>[] = [];
let currentBatch: IObjectMatrixPrimitiveType<Nullable<ICellData>> = {};
let cellCount = 0;
for (const rowKey in cellData) {
const row = Number(rowKey);
const rowData = cellData[row];
if (!rowData) continue;
const rowCellCount = Object.keys(rowData).length;
// If adding this row would exceed the batch size, push current batch first
if (cellCount > 0 && cellCount + rowCellCount > batchSize) {
batches.push(currentBatch);
currentBatch = {};
cellCount = 0;
}
// Add entire row at once
currentBatch[row] = rowData;
cellCount += rowCellCount;
// If batch is full, push it
if (cellCount >= batchSize) {
batches.push(currentBatch);
currentBatch = {};
cellCount = 0;
}
}
// Push remaining cells
if (cellCount > 0) {
batches.push(currentBatch);
}
// First chunk goes into InsertSheetMutation
const firstChunkCellData = batches.length > 0 ? batches[0] : {};
// Remaining chunks become SetRangeValuesMutation (for idle scheduling)
const remainingMutations: IMutationInfo<ISetRangeValuesMutationParams>[] = batches.slice(1).map((batch) => ({
id: SetRangeValuesMutation.id,
params: {
unitId,
subUnitId,
cellValue: batch,
__splitChunk__: true,
},
}));
return { firstChunkCellData, remainingMutations };
}
export interface ICopySheetCommandParams {
unitId?: string;
subUnitId?: string;
}
const COPY_SHEET_COMMAND_ID = 'sheet.command.copy-sheet';
interface IBuildCopySheetResult {
/** Remaining mutations to be scheduled for idle execution (local only, not serialized) */
scheduledMutations: IMutationInfo<ISetRangeValuesMutationParams>[];
redos: IMutationInfo[];
undos: IMutationInfo[];
unitId: string;
/** New sheet ID for scheduling remaining mutations */
newSheetId: string;
/** Whether the sheet was split into chunks */
isSplit: boolean;
}
// eslint-disable-next-line max-lines-per-function
function buildCopySheetMutations(
accessor: IAccessor,
workbook: Workbook,
worksheet: ReturnType<Workbook['getActiveSheet']>,
unitId: string,
subUnitId: string,
localeService: LocaleService,
sheetInterceptorService: SheetInterceptorService
): IBuildCopySheetResult {
const configService = accessor.get(IConfigService);
const pluginConfig = configService.getConfig<IUniverSheetsConfig>(SHEETS_PLUGIN_CONFIG_KEY);
const largeSheetConfig = {
...defaultLargeSheetOperationConfig,
...pluginConfig?.largeSheetOperation,
};
const config = cloneWorksheetData(worksheet!.getConfig());
config.name = getCopyUniqueSheetName(workbook, localeService, config.name);
const newSheetId = generateRandomId();
config.id = newSheetId;
const sheetIndex = workbook.getSheetIndex(worksheet!);
const { cellData } = config;
const cellCount = countCells(cellData);
// Only split if the cell count exceeds the threshold
const shouldSplit = cellCount >= largeSheetConfig.largeSheetCellCountThreshold;
let insertSheetMutationParams: IInsertSheetMutationParams;
let scheduledMutations: IMutationInfo<ISetRangeValuesMutationParams>[] = [];
if (shouldSplit) {
// Split mode: include first chunk in InsertSheetMutation, schedule the rest
const { firstChunkCellData, remainingMutations } = splitCellDataIntoBatches(
unitId,
newSheetId,
cellData,
largeSheetConfig.batchSize
);
// Insert sheet with first chunk of cell data
const sheetConfigWithFirstChunk = { ...config, cellData: firstChunkCellData as IObjectMatrixPrimitiveType<ICellData> };
insertSheetMutationParams = {
index: sheetIndex + 1,
sheet: sheetConfigWithFirstChunk,
unitId,
};
// Remaining mutations will be scheduled for idle execution
scheduledMutations = remainingMutations;
} else {
// No split: insert sheet with all cell data at once
insertSheetMutationParams = {
index: sheetIndex + 1,
sheet: config,
unitId,
};
}
const removeSheetMutationParams: IRemoveSheetMutationParams = InsertSheetUndoMutationFactory(
accessor,
insertSheetMutationParams
);
const intercepted = sheetInterceptorService.onCommandExecute({
id: COPY_SHEET_COMMAND_ID,
params: { unitId, subUnitId, targetSubUnitId: config.id },
});
// Redos only include InsertSheetMutation (with first chunk), remaining mutations are scheduled
const redos: IMutationInfo[] = [
...(intercepted.preRedos ?? []),
{ id: InsertSheetMutation.id, params: insertSheetMutationParams },
...intercepted.redos,
];
// Undo just removes the sheet - all scheduled mutations become irrelevant
const undos: IMutationInfo[] = [
...(intercepted.preUndos ?? []),
{ id: RemoveSheetMutation.id, params: removeSheetMutationParams },
...intercepted.undos,
];
return {
redos,
undos,
unitId,
newSheetId,
isSplit: shouldSplit,
scheduledMutations,
};
}
export const CopySheetCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.copy-sheet',
id: COPY_SHEET_COMMAND_ID,
handler: (accessor: IAccessor, params?: ICopySheetCommandParams) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const localeService = accessor.get(LocaleService);
const sheetLazyExecuteScheduleService = accessor.get(SheetLazyExecuteScheduleService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) {
@@ -52,47 +252,51 @@ export const CopySheetCommand: ICommand = {
}
const { workbook, worksheet, unitId, subUnitId } = target;
const config = Tools.deepClone(worksheet.getConfig());
config.name = getCopyUniqueSheetName(workbook, localeService, config.name);
config.id = generateRandomId();
const sheetIndex = workbook.getSheetIndex(worksheet);
const insertSheetMutationParams: IInsertSheetMutationParams = {
index: sheetIndex + 1,
sheet: config,
unitId,
};
const removeSheetMutationParams: IRemoveSheetMutationParams = InsertSheetUndoMutationFactory(
const { redos, undos, newSheetId, isSplit, scheduledMutations } = buildCopySheetMutations(
accessor,
insertSheetMutationParams
workbook,
worksheet,
unitId,
subUnitId,
localeService,
sheetInterceptorService
);
const intercepted = sheetInterceptorService.onCommandExecute({
id: CopySheetCommand.id,
params: { unitId, subUnitId, targetSubUnitId: config.id },
});
const redos: IMutationInfo[] = [
...(intercepted.preRedos ?? []),
{ id: InsertSheetMutation.id, params: insertSheetMutationParams },
...intercepted.redos,
];
const undos: IMutationInfo[] = [
...(intercepted.preUndos ?? []),
{ id: RemoveSheetMutation.id, params: removeSheetMutationParams },
...intercepted.undos,
];
const insertResult = sequenceExecute(redos, commandService).result;
if (insertResult) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: redos,
});
// For split case:
// - Undo: just remove the sheet (scheduled mutations become irrelevant)
// - Redo: empty (don't support redo for large sheets to avoid performance issues)
if (isSplit) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: [], // No redo for split case
});
// Schedule remaining mutations for idle execution
if (scheduledMutations.length > 0) {
// Immediately sync all mutations to changeset (syncOnly: true means sync but don't execute)
// The actual execution will happen in SheetLazyExecuteScheduleService with onlyLocal
for (const mutation of scheduledMutations) {
commandService.syncExecuteCommand(mutation.id, mutation.params, { syncOnly: true });
}
// Sync the end mutation to mark the copy worksheet operation is complete
// This will trigger a snapshot save on the server
commandService.syncExecuteCommand(CopyWorksheetEndMutation.id, { unitId, subUnitId: newSheetId }, { syncOnly: true });
// Schedule local execution during idle time
sheetLazyExecuteScheduleService.scheduleMutations(unitId, newSheetId, scheduledMutations);
}
} else {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: redos,
});
}
return true;
}
return false;
@@ -19,17 +19,21 @@ import type {
IInsertSheetMutationParams,
IRemoveSheetMutationParams,
} from '../../basics/interfaces/mutation-interface';
import type { IUniverSheetsConfig } from '../../controllers/config.schema';
import {
CommandType,
ICommandService,
IConfigService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
} from '@univerjs/core';
import { defaultLargeSheetOperationConfig, SHEETS_PLUGIN_CONFIG_KEY } from '../../controllers/config.schema';
import { SheetInterceptorService } from '../../services/sheet-interceptor/sheet-interceptor.service';
import { InsertSheetMutation } from '../mutations/insert-sheet.mutation';
import { RemoveSheetMutation, RemoveSheetUndoMutationFactory } from '../mutations/remove-sheet.mutation';
import { countCells } from './util';
import { getSheetCommandTarget } from './utils/target-util';
export interface IRemoveSheetCommandParams {
@@ -48,6 +52,7 @@ export const RemoveSheetCommand: ICommand = {
const undoRedoService = accessor.get(IUndoRedoService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const configService = accessor.get(IConfigService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
@@ -56,30 +61,46 @@ export const RemoveSheetCommand: ICommand = {
if (workbook.getSheets().length <= 1) return false;
// Check if this is a large sheet that shouldn't support undo/redo
const pluginConfig = configService.getConfig<IUniverSheetsConfig>(SHEETS_PLUGIN_CONFIG_KEY);
const largeSheetConfig = {
...defaultLargeSheetOperationConfig,
...pluginConfig?.largeSheetOperation,
};
const cellCount = countCells(worksheet.getCellMatrix());
const isLargeSheet = cellCount >= largeSheetConfig.largeSheetCellCountThreshold;
// prepare do mutations
const RemoveSheetMutationParams: IRemoveSheetMutationParams = {
subUnitId,
unitId,
subUnitName: worksheet.getName(),
};
const InsertSheetMutationParams: IInsertSheetMutationParams = RemoveSheetUndoMutationFactory(
accessor,
RemoveSheetMutationParams
);
// For large sheets, we don't need to prepare undo mutation since undo/redo won't be supported
const InsertSheetMutationParams: IInsertSheetMutationParams | null = isLargeSheet
? null
: RemoveSheetUndoMutationFactory(accessor, RemoveSheetMutationParams);
const intercepted = sheetInterceptorService.onCommandExecute({
id: RemoveSheetCommand.id,
params: { unitId, subUnitId },
});
const redos = [...(intercepted.preRedos ?? []), { id: RemoveSheetMutation.id, params: RemoveSheetMutationParams }, ...intercepted.redos];
const undos = [...(intercepted.preUndos ?? []), { id: InsertSheetMutation.id, params: InsertSheetMutationParams }, ...intercepted.undos];
const undos = isLargeSheet
? []
: [...(intercepted.preUndos ?? []), { id: InsertSheetMutation.id, params: InsertSheetMutationParams! }, ...intercepted.undos];
const result = sequenceExecute(redos, commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: redos,
});
if (isLargeSheet) {
// For large sheets, clear undo/redo to disable undo/redo functionality
undoRedoService.clearUndoRedo(unitId);
} else {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: redos,
});
}
return true;
}
@@ -106,3 +106,11 @@ export function getSuitableRangesInView(ranges: IRange[], skeleton: Nullable<She
return { suitableRanges, remainingRanges };
}
export function countCells(cellMatrix: ObjectMatrix<unknown>): number {
let count = 0;
cellMatrix.forEach(() => {
count++;
});
return count;
}
@@ -0,0 +1,36 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export interface ICopyWorksheetEndMutationParams {
unitId: string;
subUnitId: string;
}
/**
* This mutation is used to mark the end of a copy worksheet operation that was split into chunks.
* When this mutation is applied on the server, it should trigger a snapshot save.
*/
export const CopyWorksheetEndMutation: IMutation<ICopyWorksheetEndMutationParams, boolean> = {
id: 'sheet.mutation.copy-worksheet-end',
type: CommandType.MUTATION,
handler: () => {
// This is a marker mutation, it doesn't need to do anything
return true;
},
};
@@ -118,6 +118,7 @@ import { AddRangeThemeMutation } from '../commands/mutations/add-range-theme.mut
import { AddWorksheetMergeMutation } from '../commands/mutations/add-worksheet-merge.mutation';
import { AddWorksheetProtectionMutation } from '../commands/mutations/add-worksheet-protection.mutation';
import { SetWorksheetRangeThemeStyleMutation } from '../commands/mutations/add-worksheet-range-theme.mutation';
import { CopyWorksheetEndMutation } from '../commands/mutations/copy-worksheet-end.mutation';
import { DeleteRangeProtectionMutation } from '../commands/mutations/delete-range-protection.mutation';
import { DeleteWorksheetProtectionMutation } from '../commands/mutations/delete-worksheet-protection.mutation';
import { DeleteWorksheetRangeThemeStyleMutation } from '../commands/mutations/delete-worksheet-range-theme.mutation';
@@ -208,6 +209,7 @@ export class BasicWorksheetController extends Disposable implements IDisposable
MarkDirtyRowAutoHeightMutation,
CancelMarkDirtyRowAutoHeightMutation,
CopyWorksheetEndMutation,
] as IMutation<object>[]).forEach((mutation) => {
this._commandService.registerCommand(mutation);
this._dataSyncPrimaryController?.registerSyncingMutations(mutation);
@@ -249,7 +251,6 @@ export class BasicWorksheetController extends Disposable implements IDisposable
RemoveRowCommand,
RemoveSheetCommand,
ReorderRangeCommand,
RemoveWorksheetMergeCommand,
ResetBackgroundColorCommand,
ResetTextColorCommand,
@@ -20,6 +20,23 @@ export const SHEETS_PLUGIN_CONFIG_KEY = 'sheets.config';
export const configSymbol = Symbol(SHEETS_PLUGIN_CONFIG_KEY);
export interface ILargeSheetOperationConfig {
/**
* The minimum number of cells that defines a "large sheet".
* When a sheet has more cells than this threshold:
* - Copy sheet: the mutation will be split into multiple batches
* - Remove sheet: undo/redo will not be supported
* @default 6000
*/
largeSheetCellCountThreshold?: number;
/**
* The maximum number of cells per batch when splitting mutations for large sheets.
* @default 3000
*/
batchSize?: number;
}
export interface IUniverSheetsConfig {
notExecuteFormula?: boolean;
override?: DependencyOverride;
@@ -43,6 +60,19 @@ export interface IUniverSheetsConfig {
* @default true
*/
freezeSync?: boolean;
/**
* Configuration for large sheet operations.
* When a sheet has more cells than the threshold:
* - Copy sheet: the mutation will be split into multiple batches
* - Remove sheet: undo/redo will not be supported
*/
largeSheetOperation?: ILargeSheetOperationConfig;
}
export const defaultLargeSheetOperationConfig: Required<ILargeSheetOperationConfig> = {
largeSheetCellCountThreshold: 6_000,
batchSize: 3_000,
};
export const defaultPluginConfig: IUniverSheetsConfig = {};
@@ -38,6 +38,7 @@ import {
RangeProtectionRuleModel,
RefRangeService,
SheetInterceptorService,
SheetLazyExecuteScheduleService,
SheetPermissionInitController,
SheetSkeletonService,
SheetsSelectionsService,
@@ -113,6 +114,7 @@ class RenderManagerServiceTestBed extends RenderManagerService {
}
}
// eslint-disable-next-line max-lines-per-function
export function createFacadeTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
@@ -148,6 +150,7 @@ export function createFacadeTestBed(workbookData?: IWorkbookData, dependencies?:
injector.add([WorksheetProtectionRuleModel]);
injector.add([SheetPermissionInitController]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
injector.add([SheetLazyExecuteScheduleService]);
dependencies?.forEach((d) => injector.add(d));
+5 -2
View File
@@ -195,6 +195,7 @@ export { type IToggleCellCheckboxCommandParams, ToggleCellCheckboxCommand } from
export { type IToggleGridlinesCommandParams, ToggleGridlinesCommand } from './commands/commands/toggle-gridlines.command';
export { UnregisterWorksheetRangeThemeStyleCommand } from './commands/commands/unregister-range-theme.command';
export type { IUnregisterWorksheetRangeThemeStyleCommandParams } from './commands/commands/unregister-range-theme.command';
export { countCells } from './commands/commands/util';
export { alignToMergedCellsBorders, getCellAtRowCol, isSingleCellSelection, setEndForRange } from './commands/commands/utils/selection-utils';
export { followSelectionOperation, getPrimaryForRange } from './commands/commands/utils/selection-utils';
export { copyRangeStyles } from './commands/commands/utils/selection-utils';
@@ -205,6 +206,7 @@ export type { IAddRangeThemeMutationParams } from './commands/mutations/add-rang
export { AddMergeUndoMutationFactory, AddWorksheetMergeMutation } from './commands/mutations/add-worksheet-merge.mutation';
export { AddWorksheetProtectionMutation, type IAddWorksheetProtectionParams } from './commands/mutations/add-worksheet-protection.mutation';
export { SetWorksheetRangeThemeStyleMutation, SetWorksheetRangeThemeStyleMutationFactory } from './commands/mutations/add-worksheet-range-theme.mutation';
export { CopyWorksheetEndMutation, type ICopyWorksheetEndMutationParams } from './commands/mutations/copy-worksheet-end.mutation';
export { DeleteRangeProtectionMutation, FactoryDeleteRangeProtectionMutation, type IDeleteRangeProtectionMutationParams } from './commands/mutations/delete-range-protection.mutation';
export { DeleteWorksheetProtectionMutation } from './commands/mutations/delete-worksheet-protection.mutation';
export type { IDeleteWorksheetProtectionParams } from './commands/mutations/delete-worksheet-protection.mutation';
@@ -279,8 +281,8 @@ export {
SetRowVisibleMutation,
} from './commands/mutations/set-row-visible.mutation';
export { type ISetTabColorMutationParams, SetTabColorMutation } from './commands/mutations/set-tab-color.mutation';
export { type ISetWorkbookNameMutationParams, SetWorkbookNameMutation } from './commands/mutations/set-workbook-name.mutation';
export { type ISetWorkbookNameMutationParams, SetWorkbookNameMutation } from './commands/mutations/set-workbook-name.mutation';
export {
type ISetWorksheetColWidthMutationParams,
SetWorksheetColWidthMutation,
@@ -318,7 +320,7 @@ export { getInsertRangeMutations, getRemoveRangeMutations } from './commands/uti
export { handleInsertRangeMutation } from './commands/utils/handle-range-mutation';
export { type ISheetCommandSharedParams } from './commands/utils/interface';
export { getSelectionsService } from './commands/utils/selection-command-util';
export { type IUniverSheetsConfig } from './controllers/config.schema';
export { defaultLargeSheetOperationConfig, type ILargeSheetOperationConfig, type IUniverSheetsConfig, SHEETS_PLUGIN_CONFIG_KEY } from './controllers/config.schema';
export { MAX_CELL_PER_SHEET_KEY } from './controllers/config/config';
export { DefinedNameDataController } from './controllers/defined-name-data.controller';
export { SCOPE_WORKBOOK_VALUE_DEFINED_NAME } from './controllers/defined-name-data.controller';
@@ -338,6 +340,7 @@ export type { IRangeThemeStyleItem } from './model/range-theme-util';
export { UniverSheetsPlugin } from './plugin';
export { BorderStyleManagerService, type IBorderInfo } from './services/border-style-manager.service';
export { ExclusiveRangeService, IExclusiveRangeService } from './services/exclusive-range/exclusive-range-service';
export { SheetLazyExecuteScheduleService } from './services/lazy-execute-schedule.service';
export { NumfmtService } from './services/numfmt/numfmt.service';
export type { INumfmtItem, INumfmtItemWithCache } from './services/numfmt/type';
export { INumfmtService } from './services/numfmt/type';
+2
View File
@@ -36,6 +36,7 @@ import { RangeProtectionCache } from './model/range-protection.cache';
import { SheetRangeThemeModel } from './model/range-theme-model';
import { BorderStyleManagerService } from './services/border-style-manager.service';
import { ExclusiveRangeService, IExclusiveRangeService } from './services/exclusive-range/exclusive-range-service';
import { SheetLazyExecuteScheduleService } from './services/lazy-execute-schedule.service';
import { NumfmtService } from './services/numfmt/numfmt.service';
import { INumfmtService } from './services/numfmt/type';
import { RangeProtectionRefRangeService } from './services/permission/range-permission/range-protection.ref-range';
@@ -90,6 +91,7 @@ export class UniverSheetsPlugin extends Plugin {
const dependencies: Dependency[] = [
// services
[BorderStyleManagerService],
[SheetLazyExecuteScheduleService],
[SheetsSelectionsService],
[RefRangeService],
[WorkbookPermissionService],
@@ -0,0 +1,225 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutationInfo, Workbook } from '@univerjs/core';
import type { ISetRangeValuesMutationParams } from '../commands/mutations/set-range-values.mutation';
import { Disposable, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
interface IScheduledTask {
unitId: string;
subUnitId: string;
mutations: IMutationInfo<ISetRangeValuesMutationParams>[];
currentIndex: number;
}
/**
* Service to schedule and execute remaining SetRangeValuesMutation tasks
* during browser idle time after a sheet copy operation.
*
* This improves user experience by:
* 1. Immediately showing the copied sheet with first chunk of data
* 2. Filling remaining data in background during idle time
* 3. Automatically canceling tasks if the sheet is deleted
* 4. Warning user if they try to close while tasks are pending
*/
export class SheetLazyExecuteScheduleService extends Disposable {
private _tasks: Map<string, IScheduledTask> = new Map();
private _idleCallbackId: number | null = null;
private _beforeUnloadHandler: ((e: BeforeUnloadEvent) => void) | null = null;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._setupBeforeUnloadListener();
this.disposeWithMe(() => {
this._cancelAllTasks();
this._removeBeforeUnloadListener();
});
}
/**
* Check if there are any pending tasks
*/
hasPendingTasks(): boolean {
return this._tasks.size > 0;
}
/**
* Get the count of pending mutations across all tasks
*/
getPendingMutationsCount(): number {
let count = 0;
for (const task of this._tasks.values()) {
count += task.mutations.length - task.currentIndex;
}
return count;
}
/**
* Schedule mutations to be executed during idle time
* @param unitId - The workbook unit ID
* @param subUnitId - The sheet ID (newly created sheet)
* @param mutations - Remaining SetRangeValuesMutation to execute
*/
scheduleMutations(
unitId: string,
subUnitId: string,
mutations: IMutationInfo<ISetRangeValuesMutationParams>[]
): void {
if (mutations.length === 0) {
return;
}
const taskKey = `${unitId}_${subUnitId}`;
// Cancel existing task for the same sheet if any
this._cancelTask(taskKey);
this._tasks.set(taskKey, {
unitId,
subUnitId,
mutations,
currentIndex: 0,
});
this._scheduleNextIdle();
}
/**
* Cancel scheduled mutations for a specific sheet
* Called when the sheet is deleted
*/
cancelScheduledMutations(unitId: string, subUnitId: string): void {
const taskKey = `${unitId}_${subUnitId}`;
this._cancelTask(taskKey);
}
private _cancelTask(taskKey: string): void {
this._tasks.delete(taskKey);
// If no more tasks, cancel the idle callback
if (this._tasks.size === 0 && this._idleCallbackId !== null) {
if (typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(this._idleCallbackId);
}
this._idleCallbackId = null;
}
}
private _cancelAllTasks(): void {
this._tasks.clear();
if (this._idleCallbackId !== null) {
if (typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(this._idleCallbackId);
}
this._idleCallbackId = null;
}
}
private _scheduleNextIdle(): void {
if (this._idleCallbackId !== null) {
return; // Already scheduled
}
if (typeof requestIdleCallback !== 'undefined') {
this._idleCallbackId = requestIdleCallback(
(deadline) => this._processIdleTasks(deadline),
{ timeout: 1000 * 60 }
);
} else {
// Fallback for environments without requestIdleCallback
this._idleCallbackId = setTimeout(() => {
this._processIdleTasks({ didTimeout: false, timeRemaining: () => 16 });
}, 16) as unknown as number;
}
}
private _processIdleTasks(deadline: IdleDeadline | { didTimeout: boolean; timeRemaining: () => number }): void {
this._idleCallbackId = null;
// Process tasks while we have time
for (const [taskKey, task] of this._tasks) {
// Check if the sheet still exists
if (!this._isSheetExist(task.unitId, task.subUnitId)) {
// Sheet was deleted, cancel the task
this._tasks.delete(taskKey);
continue;
}
const startIndex = task.currentIndex;
// Process mutations while we have time remaining
while (task.currentIndex < task.mutations.length) {
if (deadline.timeRemaining() <= 0 && !deadline.didTimeout) {
// No more time, schedule next idle
this._scheduleNextIdle();
return;
}
const mutation = task.mutations[task.currentIndex];
// Use onlyLocal since the mutation was already synced to changeset via syncOnly
this._commandService.syncExecuteCommand(mutation.id, mutation.params, { onlyLocal: true });
task.currentIndex++;
}
// Task completed, remove it
this._tasks.delete(taskKey);
}
// If there are still tasks remaining, schedule next idle
if (this._tasks.size > 0) {
this._scheduleNextIdle();
}
}
private _isSheetExist(unitId: string, subUnitId: string): boolean {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return false;
}
return workbook.getSheetBySheetId(subUnitId) !== null;
}
private _setupBeforeUnloadListener(): void {
if (typeof window === 'undefined') {
return;
}
this._beforeUnloadHandler = (e: BeforeUnloadEvent) => {
if (this.hasPendingTasks()) {
// Standard way to show browser's default confirmation dialog
e.preventDefault();
// For older browsers
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', this._beforeUnloadHandler);
}
private _removeBeforeUnloadListener(): void {
if (typeof window === 'undefined' || !this._beforeUnloadHandler) {
return;
}
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
this._beforeUnloadHandler = null;
}
}