mirror of
https://github.com/dream-num/univer.git
synced 2026-08-28 23:01:30 +08:00
feat(sheets): split insert sheet command on large data (#6214)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
|
||||
sheetNameCannotIsEmptyError: 'نام برگه نمیتواند خالی باشد.',
|
||||
sheetNameAlreadyExistsError: 'نام برگه قبلاً وجود دارد. لطفا نام دیگری وارد کنید.',
|
||||
deleteSheet: 'حذف برگه',
|
||||
deleteSheetContent:
|
||||
deleteSheetContent: 'تایید حذف این برگه؟',
|
||||
deleteLargeSheetContent:
|
||||
'تایید حذف این برگه. پس از حذف، بازیابی نخواهد شد. آیا مطمئن هستید که میخواهید آن را حذف کنید؟',
|
||||
addProtectSheet: 'محافظت از برگه',
|
||||
removeProtectSheet: 'لغو محافظت از برگه',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -177,7 +177,8 @@ const locale: typeof enUS = {
|
||||
sheetNameCannotIsEmptyError: 'シート名は空にできません。',
|
||||
sheetNameAlreadyExistsError: '同じシート名が既に存在します。別の名前を入力してください。',
|
||||
deleteSheet: 'シートを削除',
|
||||
deleteSheetContent: 'このシートを削除してもよいですか?削除後は復元できません。本当に削除しますか?',
|
||||
deleteSheetContent: 'このシートを削除してもよいですか?',
|
||||
deleteLargeSheetContent: 'このシートを削除してもよいですか?削除後は復元できません。本当に削除しますか?',
|
||||
addProtectSheet: 'シート保護を追加',
|
||||
removeProtectSheet: 'シート保護を解除',
|
||||
changeSheetPermission: 'シート権限を変更',
|
||||
|
||||
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
|
||||
sheetNameCannotIsEmptyError: '시트 이름은 비어 있을 수 없습니다.',
|
||||
sheetNameAlreadyExistsError: '시트 이름이 이미 존재합니다. 다른 이름을 입력해주세요.',
|
||||
deleteSheet: '시트 삭제',
|
||||
deleteSheetContent:
|
||||
deleteSheetContent: '이 시트를 삭제하시겠습니까?',
|
||||
deleteLargeSheetContent:
|
||||
'이 시트를 삭제하시겠습니까? 삭제 후에는 복구할 수 없습니다. 삭제하시겠습니까?',
|
||||
addProtectSheet: '시트 보호',
|
||||
removeProtectSheet: '시트 보호 해제',
|
||||
|
||||
@@ -178,7 +178,8 @@ const locale: typeof enUS = {
|
||||
sheetNameCannotIsEmptyError: 'Имя листа не может быть пустым.',
|
||||
sheetNameAlreadyExistsError: 'Имя листа уже существует. Пожалуйста, введите другое имя.',
|
||||
deleteSheet: 'Удалить лист',
|
||||
deleteSheetContent:
|
||||
deleteSheetContent: 'Подтвердите удаление этого листа?',
|
||||
deleteLargeSheetContent:
|
||||
'Подтвердите удаление этого листа. После удаления его нельзя будет восстановить. Вы уверены, что хотите удалить его?',
|
||||
addProtectSheet: 'Защитить лист',
|
||||
removeProtectSheet: 'Снять защиту листа',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -179,7 +179,8 @@ const locale: typeof enUS = {
|
||||
sheetNameCannotIsEmptyError: '名称不能为空。',
|
||||
sheetNameAlreadyExistsError: '工作表已存在,请输入其它名称。',
|
||||
deleteSheet: '删除工作表',
|
||||
deleteSheetContent: '确认删除此工作表,删除后将不可找回,确定要删除吗?',
|
||||
deleteSheetContent: '确认删除此工作表?',
|
||||
deleteLargeSheetContent: '确认删除此工作表,删除后将不可找回,确定要删除吗?',
|
||||
addProtectSheet: '保护工作表',
|
||||
removeProtectSheet: '取消保护工作表',
|
||||
changeSheetPermission: '更改工作表权限',
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user