snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# @univerjs/action-recorder
[![npm version](https://img.shields.io/npm/v/@univerjs/action-recorder?style=flat-square)](https://npmjs.com/package/@univerjs/action-recorder)
[![license](https://img.shields.io/npm/l/@univerjs/action-recorder?style=flat-square)](https://npmjs.com/package/@univerjs/action-recorder)
[![downloads](https://img.shields.io/npm/dm/@univerjs/action-recorder?style=flat-square)](https://npmjs.com/package/@univerjs/action-recorder)
`@univerjs/action-recorder` records user actions in Univer and can replay them for debugging, demos, or automated workflow reproduction.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/action-recorder` | `UniverActionRecorder` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/action-recorder
# or
npm install @univerjs/action-recorder
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/action-recorder/lib/index.css';
import EnUS from '@univerjs/action-recorder/locale/en-US';
import { UniverActionRecorderPlugin } from '@univerjs/action-recorder';
univer.registerPlugin(UniverActionRecorderPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/action-recorder)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,93 @@
{
"name": "@univerjs/action-recorder",
"version": "1.0.0-rc.0",
"private": false,
"description": "Record and replay user actions in Univer.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"action-recorder",
"replay",
"automation",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-filter": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,305 @@
/**
* 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 { IDisposable } from '@univerjs/core';
import type { IMessageProps } from '@univerjs/design';
import {
CommandService,
CommandType,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
LogLevel,
toDisposable,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
import { beforeEach, describe, expect, it } from 'vitest';
import { ActionRecorderService } from '../../../services/action-recorder.service';
import { ActionReplayService } from '../../../services/replay.service';
import {
CompleteRecordingActionCommand,
StartRecordingActionCommand,
StopRecordingActionCommand,
} from '../record.command';
import {
ReplayLocalRecordCommand,
ReplayLocalRecordOnActiveCommand,
ReplayLocalRecordOnNamesakeCommand,
} from '../replay.command';
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.command-test.apply-cell-value';
interface ITestFile {
name: string;
text(): Promise<string>;
}
interface IAppliedCommandParams {
unitId?: string;
subUnitId?: string;
value?: string;
}
class TestState {
static files: ITestFile[] = [];
static downloads: Array<{ data: Blob; fileName: string }> = [];
static messages: IMessageProps[] = [];
static appliedParams: IAppliedCommandParams[] = [];
static reset() {
this.files = [];
this.downloads = [];
this.messages = [];
this.appliedParams = [];
}
}
class TestLocalFileService implements ILocalFileService {
openFile(): Promise<File[]> {
return Promise.resolve(TestState.files as File[]);
}
downloadFile(data: Blob, fileName: string): void {
TestState.downloads.push({ data, fileName });
}
}
class TestMessageService implements IMessageService {
show(options: IMessageProps): IDisposable {
TestState.messages.push(options);
return toDisposable(() => {});
}
remove(): void {
// no container in command tests
}
removeAll(): void {
TestState.messages = [];
}
}
class TestWorkbook {
getUnitId() {
return 'focused-workbook';
}
getSheetBySheetName(sheetName: string) {
if (sheetName !== 'Recorded Sheet') {
return null;
}
return { getSheetId: () => 'actual-sheet-id' };
}
getActiveSheet() {
return { getSheetId: () => 'active-sheet-id' };
}
getSheetBySheetId(sheetId: string) {
if (sheetId !== 'sheet-1') {
return null;
}
return { getName: () => 'Recorded Sheet' };
}
}
class TestUniverInstanceService {
private readonly _workbook = new TestWorkbook();
getFocusedUnit() {
return this._workbook;
}
getUnit() {
return this._workbook;
}
}
function createCommandTestBed() {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
injector.add([IMessageService, { useClass: TestMessageService }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ActionRecorderService]);
injector.add([ActionReplayService]);
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
commandService.registerCommand(StartRecordingActionCommand);
commandService.registerCommand(CompleteRecordingActionCommand);
commandService.registerCommand(StopRecordingActionCommand);
commandService.registerCommand(ReplayLocalRecordCommand);
commandService.registerCommand(ReplayLocalRecordOnNamesakeCommand);
commandService.registerCommand(ReplayLocalRecordOnActiveCommand);
commandService.registerCommand({
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
handler: (_accessor, params?: IAppliedCommandParams) => {
TestState.appliedParams.push({ ...params });
return true;
},
});
return {
commandService,
recorderService: injector.get(ActionRecorderService),
};
}
describe('action-recorder commands', () => {
beforeEach(() => {
TestState.reset();
});
it('records whitelisted user commands and exports them through the complete command', async () => {
const { commandService, recorderService } = createCommandTestBed();
const recordingStates: boolean[] = [];
const commandIdsVisibleInPanel: string[][] = [];
recorderService.recording$.subscribe((state) => recordingStates.push(state));
recorderService.recordedCommands$.subscribe((commands) => {
const ids: string[] = [];
for (const command of commands) {
ids.push(command.id);
}
commandIdsVisibleInPanel.push(ids);
});
recorderService.registerRecordedCommand({
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
handler: () => true,
});
await commandService.executeCommand(StartRecordingActionCommand.id, { replaceId: true });
await commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
unitId: 'focused-workbook',
subUnitId: 'sheet-1',
value: '42',
});
await commandService.executeCommand(CompleteRecordingActionCommand.id);
expect(recordingStates.at(-1)).toBe(false);
expect(commandIdsVisibleInPanel.at(-1)).toEqual([]);
expect(TestState.downloads).toHaveLength(1);
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
const exportedCommands = JSON.parse(await TestState.downloads[0].data.text());
expect(exportedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
params: {
unitId: 'focused-workbook',
subUnitId: 'Recorded Sheet',
value: '42',
},
},
]);
});
it('uses the same finalized export flow when the user stops recording', async () => {
const { commandService, recorderService } = createCommandTestBed();
await commandService.executeCommand(StartRecordingActionCommand.id);
expect(recorderService.recording).toBe(true);
await commandService.executeCommand(StopRecordingActionCommand.id);
expect(recorderService.recording).toBe(false);
expect(TestState.downloads).toHaveLength(1);
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
});
it('replays local records against the matching sheet name', async () => {
const { commandService } = createCommandTestBed();
TestState.files = [{
name: 'recorded-commands.json',
text: () => Promise.resolve(JSON.stringify([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'old-workbook',
subUnitId: 'Recorded Sheet',
value: 'from-file',
},
},
])),
}];
const result = await commandService.executeCommand(ReplayLocalRecordOnNamesakeCommand.id);
expect(result).toBe(true);
expect(TestState.appliedParams).toEqual([{
unitId: 'focused-workbook',
subUnitId: 'actual-sheet-id',
value: 'from-file',
}]);
expect(TestState.messages).toEqual([{
type: MessageType.Success,
content: 'Successfully replayed local records',
}]);
});
it('replays local records against the active sheet when requested', async () => {
const { commandService } = createCommandTestBed();
TestState.files = [{
name: 'recorded-commands.json',
text: () => Promise.resolve(JSON.stringify([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'old-workbook',
subUnitId: 'any-recorded-sheet',
value: 'active-sheet-value',
},
},
])),
}];
const result = await commandService.executeCommand(ReplayLocalRecordOnActiveCommand.id);
expect(result).toBe(true);
expect(TestState.appliedParams).toEqual([{
unitId: 'focused-workbook',
subUnitId: 'active-sheet-id',
value: 'active-sheet-value',
}]);
});
it('does not announce replay success when the user cancels local file selection', async () => {
const { commandService } = createCommandTestBed();
const result = await commandService.executeCommand(ReplayLocalRecordCommand.id);
expect(result).toBe(false);
expect(TestState.appliedParams).toEqual([]);
expect(TestState.messages).toEqual([]);
});
});

View File

@@ -0,0 +1,53 @@
/**
* 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 { ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ActionRecorderService } from '../../services/action-recorder.service';
interface IStartRecordingActionCommandParams {
replaceId?: boolean;
}
export const StartRecordingActionCommand: ICommand<IStartRecordingActionCommandParams> = {
id: 'action-recorder.command.start-recording',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const actionRecorderService = accessor.get(ActionRecorderService);
actionRecorderService.startRecording(!!params?.replaceId);
return true;
},
};
export const CompleteRecordingActionCommand: ICommand = {
id: 'action-recorder.command.complete-recording',
type: CommandType.COMMAND,
handler: (accessor) => {
const actionRecorderService = accessor.get(ActionRecorderService);
actionRecorderService.completeRecording();
return true;
},
};
export const StopRecordingActionCommand: ICommand = {
id: 'action-recorder.command.stop-recording',
type: CommandType.COMMAND,
handler: (accessor) => {
const actionRecorderService = accessor.get(ActionRecorderService);
actionRecorderService.completeRecording();
return true;
},
};

View File

@@ -0,0 +1,78 @@
/**
* 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 { ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IMessageService } from '@univerjs/ui';
import { ActionReplayService, ReplayMode } from '../../services/replay.service';
export const ReplayLocalRecordCommand: ICommand = {
id: 'action-recorder.command.replay-local-records',
type: CommandType.COMMAND,
handler: async (accessor) => {
const replayService = accessor.get(ActionReplayService);
const result = await replayService.replayLocalJSON();
if (result) {
const messageService = accessor.get(IMessageService);
messageService.show({
type: MessageType.Success,
content: 'Successfully replayed local records',
});
}
return result;
},
};
export const ReplayLocalRecordOnNamesakeCommand: ICommand = {
id: 'action-recorder.command.replay-local-records-name',
type: CommandType.COMMAND,
handler: async (accessor) => {
const replayService = accessor.get(ActionReplayService);
const result = await replayService.replayLocalJSON(ReplayMode.NAME);
if (result) {
const messageService = accessor.get(IMessageService);
messageService.show({
type: MessageType.Success,
content: 'Successfully replayed local records',
});
}
return result;
},
};
export const ReplayLocalRecordOnActiveCommand: ICommand = {
id: 'action-recorder.command.replay-local-records-active',
type: CommandType.COMMAND,
handler: async (accessor) => {
const replayService = accessor.get(ActionReplayService);
const result = await replayService.replayLocalJSON(ReplayMode.ACTIVE);
if (result) {
const messageService = accessor.get(IMessageService);
messageService.show({
type: MessageType.Success,
content: 'Successfully replayed local records',
});
}
return result;
},
};

View File

@@ -0,0 +1,105 @@
/**
* 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 {
CommandService,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
LogLevel,
} from '@univerjs/core';
import { ILocalFileService } from '@univerjs/ui';
import { beforeEach, describe, expect, it } from 'vitest';
import { ActionRecorderService } from '../../../services/action-recorder.service';
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../operation';
class TestLocalFileService implements ILocalFileService {
static downloads = 0;
static reset() {
this.downloads = 0;
}
openFile(): Promise<File[]> {
return Promise.resolve([]);
}
downloadFile(): void {
TestLocalFileService.downloads += 1;
}
}
class TestFocusedUnit {
getUnitId() {
return 'focused-workbook';
}
}
class TestUniverInstanceService {
private readonly _focusedUnit = new TestFocusedUnit();
getFocusedUnit() {
return this._focusedUnit;
}
}
function createOperationTestBed() {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ActionRecorderService]);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
commandService.registerCommand(OpenRecordPanelOperation);
commandService.registerCommand(CloseRecordPanelOperation);
return {
commandService,
recorderService: injector.get(ActionRecorderService),
};
}
describe('action-recorder panel operations', () => {
beforeEach(() => {
TestLocalFileService.reset();
});
it('opens the record panel and closes an active recording session without exporting', async () => {
const { commandService, recorderService } = createOperationTestBed();
const panelStates: boolean[] = [];
recorderService.panelOpened$.subscribe((state) => panelStates.push(state));
await commandService.executeCommand(OpenRecordPanelOperation.id);
recorderService.startRecording();
await commandService.executeCommand(CloseRecordPanelOperation.id);
expect(panelStates).toEqual([false, true, false]);
expect(recorderService.recording).toBe(false);
expect(TestLocalFileService.downloads).toBe(0);
});
});

View File

@@ -0,0 +1,39 @@
/**
* 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 { IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ActionRecorderService } from '../../services/action-recorder.service';
export const OpenRecordPanelOperation: IOperation = {
id: 'action-recorder.operation.open-panel',
type: CommandType.OPERATION,
handler(accessor) {
const s = accessor.get(ActionRecorderService);
s.togglePanel(true);
return true;
},
};
export const CloseRecordPanelOperation: IOperation = {
id: 'action-recorder.operation.close-panel',
type: CommandType.OPERATION,
handler(accessor) {
const s = accessor.get(ActionRecorderService);
s.togglePanel(false);
return true;
},
};

View File

@@ -0,0 +1,28 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const ACTION_RECORDER_PLUGIN_CONFIG_KEY = 'action-recorder.config';
export const configSymbol = Symbol(ACTION_RECORDER_PLUGIN_CONFIG_KEY);
export interface IUniverActionRecorderConfig {
menu?: MenuConfig;
replayOnly?: boolean;
}
export const defaultPluginConfig: IUniverActionRecorderConfig = {};

View File

@@ -0,0 +1,199 @@
/**
* 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 { Disposable, ICommandService, Inject, Injector } from '@univerjs/core';
import {
AddWorksheetMergeAllCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeHorizontalCommand,
AddWorksheetMergeVerticalCommand,
AutoFillCommand,
CancelFrozenCommand,
CopySheetCommand,
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
DeltaColumnWidthCommand,
DeltaRowHeightCommand,
InsertColAfterCommand,
InsertColBeforeCommand,
InsertRowAfterCommand,
InsertRowBeforeCommand,
InsertSheetCommand,
RefillCommand,
RemoveSheetCommand,
SetFrozenCommand,
SetHorizontalTextAlignCommand,
SetOverlineCommand,
SetRangeValuesCommand,
SetSelectionsOperation,
SetStrikeThroughCommand,
SetStyleCommand,
SetTextColorCommand,
SetTextRotationCommand,
SetTextWrapCommand,
SetVerticalTextAlignCommand,
SetWorksheetActivateCommand,
SetWorksheetActiveOperation,
} from '@univerjs/sheets';
import { RemoveSheetFilterCommand, SetSheetFilterRangeCommand, SetSheetsFilterCriteriaCommand } from '@univerjs/sheets-filter';
import {
SetRangeBoldCommand,
SetRangeFontFamilyCommand,
SetRangeFontSizeCommand,
SetRangeItalicCommand,
SetRangeStrickThroughCommand,
SetRangeSubscriptCommand,
SetRangeSuperscriptCommand,
SetRangeTextColorCommand,
SetRangeUnderlineCommand,
SheetCopyCommand,
SheetCutCommand,
SheetPasteBesidesBorderCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteFormatCommand,
SheetPasteShortKeyCommand,
SheetPasteValueCommand,
} from '@univerjs/sheets-ui';
import {
BuiltInUIPart,
connectInjector,
IMenuManagerService,
IUIPartsService,
} from '@univerjs/ui';
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../commands/commands/record.command';
import { ReplayLocalRecordCommand, ReplayLocalRecordOnActiveCommand, ReplayLocalRecordOnNamesakeCommand } from '../commands/commands/replay.command';
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../commands/operations/operation';
import { menuSchema } from '../menu/action-recorder.menu';
import { ActionRecorderService } from '../services/action-recorder.service';
import { RecorderPanel } from '../views/components/RecorderPanel';
export class ActionRecorderController extends Disposable {
constructor(
@ICommandService private readonly _commandSrv: ICommandService,
@IUIPartsService private readonly _uiPartsSrv: IUIPartsService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@Inject(ActionRecorderService) private readonly _actionRecorderService: ActionRecorderService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initCommands();
this._initUI();
this._initSheetsCommands();
this._initDocsCommands();
}
private _initCommands(): void {
([
StartRecordingActionCommand,
StopRecordingActionCommand,
CompleteRecordingActionCommand,
OpenRecordPanelOperation,
CloseRecordPanelOperation,
ReplayLocalRecordCommand,
ReplayLocalRecordOnNamesakeCommand,
ReplayLocalRecordOnActiveCommand,
]).forEach((command) => this._commandSrv.registerCommand(command));
}
private _initUI(): void {
this._uiPartsSrv.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(RecorderPanel, this._injector));
this._menuManagerService.mergeMenu(menuSchema);
}
private _initSheetsCommands(): void {
([
// InsertColCommand,
// InsertRowCommand,
// #region basic commands
CopySheetCommand,
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
DeltaColumnWidthCommand,
DeltaRowHeightCommand,
InsertSheetCommand,
InsertColAfterCommand,
InsertColBeforeCommand,
InsertRowAfterCommand,
InsertRowBeforeCommand,
RemoveSheetCommand,
SetStyleCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeAllCommand,
AddWorksheetMergeVerticalCommand,
AddWorksheetMergeHorizontalCommand,
// ResetBackgroundColorCommand,
// ResetTextColorCommand,
// SetBackgroundColorCommand,
// SetBoldCommand,
// SetFontFamilyCommand,
// SetFontSizeCommand,
SetFrozenCommand,
CancelFrozenCommand,
SetHorizontalTextAlignCommand,
// SetItalicCommand,
SetOverlineCommand,
SetRangeBoldCommand,
SetRangeFontFamilyCommand,
SetRangeFontSizeCommand,
SetRangeItalicCommand,
SetRangeStrickThroughCommand,
SetRangeSubscriptCommand,
SetRangeSuperscriptCommand,
SetRangeTextColorCommand,
SetRangeUnderlineCommand,
SetRangeValuesCommand,
SetStrikeThroughCommand,
SetTextColorCommand,
SetTextRotationCommand,
SetTextWrapCommand,
// SetUnderlineCommand,
SetVerticalTextAlignCommand,
SheetCopyCommand,
SheetCutCommand,
SheetPasteBesidesBorderCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteFormatCommand,
SheetPasteShortKeyCommand,
SheetPasteValueCommand,
AutoFillCommand,
RefillCommand,
SetWorksheetActivateCommand,
SetWorksheetActiveOperation,
SetSelectionsOperation,
// #endregion
// #region data validation command
// #endregion
// #region conditional formatting command
// #endregion
// #region filter command
SetSheetFilterRangeCommand,
SetSheetsFilterCriteriaCommand,
RemoveSheetFilterCommand,
// #endregion
]).forEach((command) => this._actionRecorderService.registerRecordedCommand(command));
}
private _initDocsCommands(): void {
// TODO
}
}

View File

@@ -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 { Disposable, Inject } from '@univerjs/core';
import { RecordIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
RecordIcon,
}));
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,23 @@
/**
* 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 './global.css';
export type { IUniverActionRecorderConfig } from './config/config';
export { menuSchema as ActionRecorderMenuSchema } from './menu/action-recorder.menu';
export { UniverActionRecorderPlugin } from './plugin';
export { ActionRecorderService } from './services/action-recorder.service';
export { ActionReplayService } from './services/replay.service';

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'جارٍ التسجيل...',
startRecording: 'بدء التسجيل',
cancel: 'إلغاء',
close: 'إغلاق',
save: 'حفظ',
start: 'بدء',
startWithId: 'بدء (N)',
},
menu: {
title: 'تسجيل الإجراءات',
record: 'تسجيل الإجراءات...',
'replay-local': 'استبدال السجل المحلي...',
'replay-local-name': 'استبدال السجل المحلي بوحدة فرعية...',
'replay-local-active': 'استبدال السجل المحلي بالوحدة الفرعية الحالية...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'S\'està enregistrant...',
startRecording: 'Inicia l’enregistrament',
cancel: 'Cancel·la',
close: 'Tanca',
save: 'Desa',
start: 'Inicia',
startWithId: 'Inicia (N)',
},
menu: {
title: 'Grava accions',
record: 'Grava accions...',
'replay-local': 'Reemplaça gravació local...',
'replay-local-name': 'Reemplaça gravació local per subunitat...',
'replay-local-active': 'Reemplaça gravació local per subunitat actual...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Aufnahme läuft...',
startRecording: 'Aufnahme starten',
cancel: 'Abbrechen',
close: 'Schließen',
save: 'Speichern',
start: 'Starten',
startWithId: 'Starten (N)',
},
menu: {
title: 'Aktionen aufzeichnen',
record: 'Aktionen aufzeichnen...',
'replay-local': 'Lokale Aufzeichnung ersetzen...',
'replay-local-name': 'Lokale Aufzeichnung nach Untereinheit ersetzen...',
'replay-local-active': 'Lokale Aufzeichnung nach aktueller Untereinheit ersetzen...',
},
},
};
export default locale;

View File

@@ -0,0 +1,38 @@
/**
* 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.
*/
const locale = {
'action-recorder': {
panel: {
recording: 'Recording...',
startRecording: 'Start Recording',
cancel: 'Cancel',
close: 'Close',
save: 'Save',
start: 'Start',
startWithId: 'Start(N)',
},
menu: {
title: 'Record Actions',
record: 'Record Actions...',
'replay-local': 'Replace Local Record...',
'replay-local-name': 'Replace Local Record by Subunit...',
'replay-local-active': 'Replace Local Record by Current Subunit...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Grabando...',
startRecording: 'Iniciar grabación',
cancel: 'Cancelar',
close: 'Cerrar',
save: 'Guardar',
start: 'Iniciar',
startWithId: 'Iniciar (N)',
},
menu: {
title: 'Grabar acciones',
record: 'Grabar acciones...',
'replay-local': 'Reemplazar grabación local...',
'replay-local-name': 'Reemplazar grabación local por subunidad...',
'replay-local-active': 'Reemplazar grabación local por subunidad actual...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'در حال ضبط...',
startRecording: 'شروع ضبط',
cancel: 'لغو',
close: 'بستن',
save: 'ذخیره',
start: 'شروع',
startWithId: 'شروع (N)',
},
menu: {
title: 'ثبت اقدامات',
record: 'ثبت اقدامات...',
'replay-local': 'بازپخش محلی ثبت...',
'replay-local-name': 'بازپخش محلی ثبت بر اساس نام زیر واحد...',
'replay-local-active': 'بازپخش محلی ثبت بر اساس زیر واحد فعلی...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Enregistrement...',
startRecording: 'Démarrer l’enregistrement',
cancel: 'Annuler',
close: 'Fermer',
save: 'Enregistrer',
start: 'Démarrer',
startWithId: 'Démarrer (N)',
},
menu: {
title: 'Enregistrer les actions',
record: 'Enregistrer les actions...',
'replay-local': 'Lecture de l\'enregistrement local...',
'replay-local-name': 'Remplacer l\'enregistrement local par le sous-unité...',
'replay-local-active': 'Remplacer l\'enregistrement local par le sous-unité actuel...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Merekam...',
startRecording: 'Mulai merekam',
cancel: 'Batal',
close: 'Tutup',
save: 'Simpan',
start: 'Mulai',
startWithId: 'Mulai (N)',
},
menu: {
title: 'Rekam Aksi',
record: 'Rekam Aksi...',
'replay-local': 'Putar Ulang Rekaman Lokal...',
'replay-local-name': 'Putar Ulang Rekaman Lokal berdasarkan Subunit...',
'replay-local-active': 'Putar Ulang Rekaman Lokal berdasarkan Subunit Saat Ini...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Registrazione...',
startRecording: 'Avvia registrazione',
cancel: 'Annulla',
close: 'Chiudi',
save: 'Salva',
start: 'Avvia',
startWithId: 'Avvia (N)',
},
menu: {
title: 'Registra Azioni',
record: 'Registra Azioni...',
'replay-local': 'Riproduci Registrazione Locale...',
'replay-local-name': 'Riproduci Registrazione Locale per Sottoelemento...',
'replay-local-active': 'Riproduci Registrazione Locale per Sottoelemento Corrente...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: '記録中...',
startRecording: '記録を開始',
cancel: 'キャンセル',
close: '閉じる',
save: '保存',
start: '開始',
startWithId: '開始 (N)',
},
menu: {
title: '操作記録',
record: '記録を開始...',
'replay-local': 'ローカルの記録で置き換え...',
'replay-local-name': 'サブ単位ごとにローカルの記録で置き換え...',
'replay-local-active': '現在のサブ単位をローカルの記録で置き換え...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: '기록 중...',
startRecording: '기록 시작',
cancel: '취소',
close: '닫기',
save: '저장',
start: '시작',
startWithId: '시작 (N)',
},
menu: {
title: '동작 기록',
record: '동작 기록 시작...',
'replay-local': '로컬 기록 대체...',
'replay-local-name': '하위 단위로 로컬 기록 대체...',
'replay-local-active': '현재 하위 단위로 로컬 기록 대체...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Nagrywanie...',
startRecording: 'Rozpocznij nagrywanie',
cancel: 'Anuluj',
close: 'Zamknij',
save: 'Zapisz',
start: 'Rozpocznij',
startWithId: 'Rozpocznij (N)',
},
menu: {
title: 'Nagraj działania',
record: 'Nagraj działania...',
'replay-local': 'Odtwórz nagranie lokalne...',
'replay-local-name': 'Odtwórz nagranie lokalne według jednostki podrzędnej...',
'replay-local-active': 'Odtwórz nagranie lokalne według bieżącej jednostki podrzędnej...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Gravando...',
startRecording: 'Iniciar gravação',
cancel: 'Cancelar',
close: 'Fechar',
save: 'Salvar',
start: 'Iniciar',
startWithId: 'Iniciar (N)',
},
menu: {
title: 'Gravar Ações',
record: 'Gravar Ações...',
'replay-local': 'Substituir Gravação Local...',
'replay-local-name': 'Substituir Gravação Local por Subunidade...',
'replay-local-active': 'Substituir Gravação Local pela Subunidade Atual...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Запись...',
startRecording: 'Начать запись',
cancel: 'Отмена',
close: 'Закрыть',
save: 'Сохранить',
start: 'Начать',
startWithId: 'Начать (N)',
},
menu: {
title: 'Запись действий',
record: 'Записать действия...',
'replay-local': 'Заменить локальную запись...',
'replay-local-name': 'Заменить локальную запись по подразделению...',
'replay-local-active': 'Заменить локальную запись по текущему подразделению...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Nahrávanie...',
startRecording: 'Spustiť nahrávanie',
cancel: 'Zrušiť',
close: 'Zavrieť',
save: 'Uložiť',
start: 'Spustiť',
startWithId: 'Spustiť (N)',
},
menu: {
title: 'Zaznamenať akcie',
record: 'Zaznamenať akcie...',
'replay-local': 'Nahradiť miestny záznam...',
'replay-local-name': 'Nahradiť miestny záznam podľa podjednotky...',
'replay-local-active': 'Nahradiť miestny záznam podľa aktuálnej podjednotky...',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: 'Đang ghi...',
startRecording: 'Bắt đầu ghi',
cancel: 'Hủy',
close: 'Đóng',
save: 'Lưu',
start: 'Bắt đầu',
startWithId: 'Bắt đầu (N)',
},
menu: {
title: 'Ghi hành động',
record: 'Ghi hành động...',
'replay-local': 'Thay bản ghi cục bộ...',
'replay-local-name': 'Thay bản ghi cục bộ theo bộ phận...',
'replay-local-active': 'Thay bản ghi cục bộ theo bộ phận hiện tại...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: '录制中...',
startRecording: '开始录制',
cancel: '取消',
close: '关闭',
save: '保存',
start: '开始',
startWithId: '开始 (N)',
},
menu: {
title: '操作录制',
record: '录制操作...',
'replay-local': '回放本地录制...',
'replay-local-name': '根据子表名回放本地录制...',
'replay-local-active': '根据当前子表回放本地录制...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: '錄製中...',
startRecording: '開始錄製',
cancel: '取消',
close: '關閉',
save: '儲存',
start: '開始',
startWithId: '開始 (N)',
},
menu: {
title: '操作錄製',
record: '錄製操作...',
'replay-local': '回放本地錄製...',
'replay-local-name': '根據子表名回放本地錄製...',
'replay-local-active': '根據當前子表回放本地錄製...',
},
},
};
export default locale;

View File

@@ -0,0 +1,40 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'action-recorder': {
panel: {
recording: '錄製中...',
startRecording: '開始錄製',
cancel: '取消',
close: '關閉',
save: '儲存',
start: '開始',
startWithId: '開始 (N)',
},
menu: {
title: '操作錄製',
record: '錄製操作...',
'replay-local': '回放本地錄製...',
'replay-local-name': '根據子表名回放本地錄製...',
'replay-local-active': '根據當前子表回放本地錄製...',
},
},
};
export default locale;

View File

@@ -0,0 +1,98 @@
/**
* 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 { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem, MenuSchemaType } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { MenuItemType, RibbonOthersGroup } from '@univerjs/ui';
import {
ReplayLocalRecordCommand,
ReplayLocalRecordOnActiveCommand,
ReplayLocalRecordOnNamesakeCommand,
} from '../commands/commands/replay.command';
import { OpenRecordPanelOperation } from '../commands/operations/operation';
import { ActionRecorderService } from '../services/action-recorder.service';
export const RECORD_MENU_ITEM_ID = 'RECORD_MENU_ITEM';
export function RecordMenuItemFactory(): IMenuSelectorItem<LocaleKey> {
return {
id: RECORD_MENU_ITEM_ID,
type: MenuItemType.SUBITEMS,
icon: 'RecordIcon',
tooltip: 'action-recorder.menu.title',
};
}
export function OpenRecorderMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
const actionRecorderService = accessor.get(ActionRecorderService);
return {
id: OpenRecordPanelOperation.id,
title: 'action-recorder.menu.record',
type: MenuItemType.BUTTON,
disabled$: actionRecorderService.panelOpened$,
};
}
export function ReplayLocalRecordMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: ReplayLocalRecordCommand.id,
title: 'action-recorder.menu.replay-local',
type: MenuItemType.BUTTON,
};
}
export function ReplayLocalRecordOnNamesakeMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: ReplayLocalRecordOnNamesakeCommand.id,
title: 'action-recorder.menu.replay-local-name',
type: MenuItemType.BUTTON,
};
}
export function ReplayLocalRecordOnActiveMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: ReplayLocalRecordOnActiveCommand.id,
title: 'action-recorder.menu.replay-local-active',
type: MenuItemType.BUTTON,
};
}
export const menuSchema: MenuSchemaType = {
[RibbonOthersGroup.OTHERS]: {
[RECORD_MENU_ITEM_ID]: {
order: 1,
menuItemFactory: RecordMenuItemFactory,
[OpenRecordPanelOperation.id]: {
order: 1,
menuItemFactory: OpenRecorderMenuItemFactory,
},
[ReplayLocalRecordCommand.id]: {
order: 2,
menuItemFactory: ReplayLocalRecordMenuItemFactory,
},
[ReplayLocalRecordOnNamesakeCommand.id]: {
order: 3,
menuItemFactory: ReplayLocalRecordOnNamesakeMenuItemFactory,
},
[ReplayLocalRecordOnActiveCommand.id]: {
order: 4,
menuItemFactory: ReplayLocalRecordOnActiveMenuItemFactory,
},
},
},
};

View File

@@ -0,0 +1,75 @@
/**
* 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 { Dependency } from '@univerjs/core';
import type { IUniverActionRecorderConfig } from './config/config';
import { IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
import pkg from '../package.json';
import { ACTION_RECORDER_PLUGIN_CONFIG_KEY, defaultPluginConfig } from './config/config';
import { ActionRecorderController } from './controllers/action-recorder.controller';
import { ComponentsController } from './controllers/components.controller';
import { ActionRecorderService } from './services/action-recorder.service';
import { ActionReplayService } from './services/replay.service';
/**
* This plugin provides a recorder for user's interactions with Univer,
* it only records commands (and some special operations) so that it can be replayed later.
*/
export class UniverActionRecorderPlugin extends Plugin {
static override pluginName = 'UNIVER_ACTION_RECORDER_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverActionRecorderConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(ACTION_RECORDER_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
const dependency = this._config.replayOnly
? [[ActionReplayService]]
: [
[ActionRecorderService],
[ActionReplayService],
[ActionRecorderController],
];
(dependency as Dependency[]).forEach((d) => this._injector.add(d));
}
override onSteady(): void {
if (this._config.replayOnly) {
return;
}
this._injector.get(ActionRecorderController);
}
}

View File

@@ -0,0 +1,140 @@
/**
* 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 {
CommandService,
CommandType,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
} from '@univerjs/core';
import { SetSelectionsOperation } from '@univerjs/sheets';
import { DesktopLocalFileService, ILocalFileService } from '@univerjs/ui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ActionRecorderService } from '../action-recorder.service';
class TestUniverInstanceService {
getFocusedUnit() {
return { getUnitId: () => 'unit-1' };
}
getUnit() {
return {
getSheetBySheetId: () => ({ getName: () => 'Sheet-A' }),
};
}
}
describe('ActionRecorderService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('should record commands, replace selection entries and complete/stop', () => {
const link = { click: vi.fn(), download: '', href: '' };
vi.stubGlobal('document', { createElement: () => link });
vi.stubGlobal('window', { URL: { createObjectURL: () => 'blob:recording' } });
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILocalFileService, { useClass: DesktopLocalFileService }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ActionRecorderService]);
const service = injector.get(ActionRecorderService);
const commandService = injector.get(ICommandService);
const logService = injector.get(ILogService);
const logError = vi.spyOn(logService, 'error');
expect(() =>
service.registerRecordedCommand({
id: 'mutation-id',
type: CommandType.MUTATION,
} as never)
).toThrow('[CommandRecorderService] Cannot record mutation commands.');
service.registerRecordedCommand({ id: 'cmd-1', type: CommandType.COMMAND } as never);
service.registerRecordedCommand({ id: SetSelectionsOperation.id, type: CommandType.OPERATION } as never);
commandService.registerCommand({
id: 'cmd-1',
type: CommandType.COMMAND,
handler: () => true,
});
commandService.registerCommand({
id: SetSelectionsOperation.id,
type: CommandType.OPERATION,
handler: () => true,
});
commandService.registerCommand({
id: 'ignored',
type: CommandType.COMMAND,
handler: () => true,
});
const panelStates: boolean[] = [];
const recordingStates: boolean[] = [];
const commandStates: string[][] = [];
service.panelOpened$.subscribe((v) => panelStates.push(v));
service.recording$.subscribe((v) => recordingStates.push(v));
service.recordedCommands$.subscribe((v) => commandStates.push(v.map((cmd) => cmd.id)));
service.togglePanel(true);
service.startRecording(true);
expect(service.recording).toBe(true);
expect(recordingStates[recordingStates.length - 1]).toBe(true);
commandService.syncExecuteCommand('cmd-1', {
unitId: 'unit-1',
subUnitId: 'sheet-1',
});
commandService.syncExecuteCommand(SetSelectionsOperation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
mark: 1,
});
commandService.syncExecuteCommand(SetSelectionsOperation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
mark: 2,
});
commandService.syncExecuteCommand('ignored', {});
expect(commandStates[commandStates.length - 1]).toEqual(['cmd-1']);
service.completeRecording();
expect(link.download).toBe('recorded-commands.json');
expect(link.href).toBe('blob:recording');
expect(link.click).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalled();
expect(recordingStates[recordingStates.length - 1]).toBe(false);
service.startRecording();
commandService.syncExecuteCommand('cmd-1', {
params: { unitId: 'unit-1', subUnitId: 'sheet-1' },
});
service.togglePanel(false);
expect(panelStates[panelStates.length - 1]).toBe(false);
expect(recordingStates[recordingStates.length - 1]).toBe(false);
});
});

View File

@@ -0,0 +1,394 @@
/**
* 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 { ICommandInfo } from '@univerjs/core';
import {
CommandService,
CommandType,
ConfigService,
DesktopLogService,
ICommandService,
IConfigService,
ILogService,
Injector,
IUniverInstanceService,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ActionReplayService, ReplayMode } from '../replay.service';
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.test.apply-cell-value';
const STOP_REPLAY_COMMAND_ID = 'action-recorder.test.stop-replay';
interface IAppliedCommand {
id: string;
params?: {
unitId?: string;
subUnitId?: string;
value?: string;
};
}
interface ITestFile {
name: string;
text(): Promise<string>;
}
class TestReplayState {
static appliedCommands: IAppliedCommand[] = [];
static reset() {
this.appliedCommands = [];
}
}
class TestLocalFileService {
static files: ITestFile[] = [];
static reset() {
this.files = [];
}
openFile() {
return Promise.resolve(TestLocalFileService.files);
}
}
class TestMessageService {
static messages: Array<{ type: MessageType; content: string }> = [];
static reset() {
this.messages = [];
}
show(message: { type: MessageType; content: string }) {
TestMessageService.messages.push(message);
}
}
class TestWorkbook {
getUnitId() {
return 'focused-workbook';
}
getSheetBySheetName(sheetName: string) {
if (sheetName !== 'Recorded Sheet') {
return null;
}
return { getSheetId: () => 'actual-sheet-id' };
}
getActiveSheet() {
return { getSheetId: () => 'active-sheet-id' };
}
}
class TestUniverInstanceService {
private readonly _workbook = new TestWorkbook();
getFocusedUnit() {
return this._workbook;
}
}
class TestUniverInstanceServiceWithoutFocusedUnit {
getFocusedUnit() {
return null;
}
}
function cloneCommandParams(params?: IAppliedCommand['params']) {
if (!params) {
return undefined;
}
return { ...params };
}
function createJsonFile(name: string, commands: ICommandInfo[]): ITestFile {
return {
name,
text: () => Promise.resolve(JSON.stringify(commands)),
};
}
function createInvalidJsonFile(name: string): ITestFile {
return {
name,
text: () => Promise.resolve('{ invalid json'),
};
}
function createReplayTestBed(instanceService: typeof TestUniverInstanceService | typeof TestUniverInstanceServiceWithoutFocusedUnit = TestUniverInstanceService) {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IMessageService, { useClass: TestMessageService as never }]);
injector.add([ILocalFileService, { useClass: TestLocalFileService as never }]);
injector.add([IUniverInstanceService, { useClass: instanceService as never }]);
injector.add([ActionReplayService]);
const commandService = injector.get(ICommandService);
commandService.registerCommand({
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
handler: (_accessor, params?: IAppliedCommand['params']) => {
TestReplayState.appliedCommands.push({
id: APPLY_CELL_VALUE_COMMAND_ID,
params: cloneCommandParams(params),
});
return true;
},
});
commandService.registerCommand({
id: STOP_REPLAY_COMMAND_ID,
type: CommandType.COMMAND,
handler: (_accessor, params?: IAppliedCommand['params']) => {
TestReplayState.appliedCommands.push({
id: STOP_REPLAY_COMMAND_ID,
params: cloneCommandParams(params),
});
return false;
},
});
return {
commandService,
replayService: injector.get(ActionReplayService),
};
}
describe('ActionReplayService', () => {
beforeEach(() => {
TestReplayState.reset();
TestLocalFileService.reset();
TestMessageService.reset();
});
afterEach(() => {
vi.useRealTimers();
});
it('replays recorded sheet actions into the focused workbook and resolves recorded sheet names', async () => {
const { replayService } = createReplayTestBed();
const result = await replayService.replayCommands(
[
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'Recorded Sheet',
value: 'replayed by sheet name',
},
},
],
{ mode: ReplayMode.NAME }
);
expect(result).toBe(true);
expect(TestReplayState.appliedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'focused-workbook',
subUnitId: 'actual-sheet-id',
value: 'replayed by sheet name',
},
},
]);
});
it('can replay recorded actions into the active sheet instead of the recorded sheet', async () => {
const { replayService } = createReplayTestBed();
const result = await replayService.replayCommands(
[
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'recorded-sheet-id',
value: 'replayed into active sheet',
},
},
],
{ mode: ReplayMode.ACTIVE }
);
expect(result).toBe(true);
expect(TestReplayState.appliedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'focused-workbook',
subUnitId: 'active-sheet-id',
value: 'replayed into active sheet',
},
},
]);
});
it('stops replaying after a command reports failure', async () => {
const { replayService } = createReplayTestBed();
const result = await replayService.replayCommands([
{
id: STOP_REPLAY_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'recorded-sheet-id',
value: 'cannot apply',
},
},
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'recorded-sheet-id',
value: 'must not be applied',
},
},
]);
expect(result).toBe(false);
expect(TestReplayState.appliedCommands).toEqual([
{
id: STOP_REPLAY_COMMAND_ID,
params: {
unitId: 'focused-workbook',
subUnitId: 'recorded-sheet-id',
value: 'cannot apply',
},
},
]);
});
it('loads a local JSON recording and replays it against the focused workbook', async () => {
const { replayService } = createReplayTestBed();
TestLocalFileService.files = [
createJsonFile('recording.json', [
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'Recorded Sheet',
value: 'loaded from json',
},
},
]),
];
const result = await replayService.replayLocalJSON(ReplayMode.NAME);
expect(result).toBe(true);
expect(TestReplayState.appliedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'focused-workbook',
subUnitId: 'actual-sheet-id',
value: 'loaded from json',
},
},
]);
});
it('does not replay anything when the local recording cannot be parsed', async () => {
const { replayService } = createReplayTestBed();
TestLocalFileService.files = [createInvalidJsonFile('broken-recording.json')];
const result = await replayService.replayLocalJSON();
expect(result).toBe(false);
expect(TestReplayState.appliedCommands).toEqual([]);
expect(TestMessageService.messages).toEqual([
{
type: MessageType.Error,
content: 'Failed to replay commands from local file broken-recording.json.',
},
]);
});
it('does not replay anything when the user cancels local recording selection', async () => {
const { replayService } = createReplayTestBed();
TestLocalFileService.files = [];
const result = await replayService.replayLocalJSON();
expect(result).toBe(false);
expect(TestReplayState.appliedCommands).toEqual([]);
expect(TestMessageService.messages).toEqual([]);
});
it('delays command execution while still targeting the focused workbook', async () => {
vi.useFakeTimers();
const { replayService } = createReplayTestBed();
const result = replayService.replayCommandsWithDelay([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'recorded-workbook',
subUnitId: 'recorded-sheet-id',
value: 'delayed replay',
},
},
]);
expect(TestReplayState.appliedCommands).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe(true);
expect(TestReplayState.appliedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
unitId: 'focused-workbook',
subUnitId: 'recorded-sheet-id',
value: 'delayed replay',
},
},
]);
});
it('keeps replaying commands without workbook params when no workbook is focused', async () => {
const { replayService } = createReplayTestBed(TestUniverInstanceServiceWithoutFocusedUnit);
const result = await replayService.replayCommands([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
value: 'global action',
},
},
]);
expect(result).toBe(true);
expect(TestReplayState.appliedCommands).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
params: {
value: 'global action',
},
},
]);
});
});

View File

@@ -0,0 +1,126 @@
/**
* 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 { ICommand, ICommandInfo, IDisposable, Nullable, Workbook } from '@univerjs/core';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import { CommandType, Disposable, ICommandService, ILogService, IUniverInstanceService } from '@univerjs/core';
import { SetSelectionsOperation } from '@univerjs/sheets';
import { ILocalFileService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
/**
* This service is for recording commands. What commands should be recorded can be configured by other
* plugins.
*/
export class ActionRecorderService extends Disposable {
private readonly _shouldRecordCommands = new Set<string>();
private readonly _panelOpened$ = new BehaviorSubject<boolean>(false);
readonly panelOpened$ = this._panelOpened$.asObservable();
private _recorder: Nullable<IDisposable> = null;
private readonly _recording$ = new BehaviorSubject<boolean>(false);
readonly recording$ = this._recording$.asObservable();
get recording(): boolean { return this._recording$.getValue(); }
private _recorded$ = new BehaviorSubject<ICommandInfo[]>([]);
private get _recorded(): ICommandInfo[] { return this._recorded$.getValue(); }
private _recordedCommands$ = new BehaviorSubject<ICommandInfo[]>([]);
readonly recordedCommands$ = this._recordedCommands$.asObservable();
private get _recordedCommands(): ICommandInfo[] { return this._recordedCommands$.getValue(); }
constructor(
@ICommandService private readonly _commandSrv: ICommandService,
@ILogService private readonly _logService: ILogService,
@ILocalFileService private readonly _localFileService: ILocalFileService,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService
) {
super();
}
registerRecordedCommand(command: ICommand): void {
if (command.type === CommandType.MUTATION) throw new Error('[CommandRecorderService] Cannot record mutation commands.');
this._shouldRecordCommands.add(command.id);
}
togglePanel(visible: boolean): void {
this._panelOpened$.next(visible);
if (visible === false) this.stopRecording();
}
startRecording(replaceId = false): void {
this._recorder = this._commandSrv.onCommandExecuted((rawCommandInfo) => {
if (this._shouldRecordCommands.has(rawCommandInfo.id)) {
const recorded = this._recorded;
const commands = this._recordedCommands;
let commandInfo = { ...rawCommandInfo };
const focusUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
const { unitId = focusUnitId, subUnitId } = commandInfo?.params as ISheetCommandSharedParams;
if (replaceId && unitId && subUnitId) {
const subUnitName = (this._instanceService.getUnit(unitId) as Workbook).getSheetBySheetId(subUnitId)?.getName();
commandInfo = {
...commandInfo,
params: {
...commandInfo.params,
subUnitId: subUnitName,
},
};
}
if (
commandInfo.id === SetSelectionsOperation.id &&
recorded.length > 0 &&
recorded[recorded.length - 1].id === SetSelectionsOperation.id
) {
recorded[recorded.length - 1] = commandInfo;
} else {
recorded.push(commandInfo);
this._recorded$.next(recorded);
if (commandInfo.type === CommandType.COMMAND) {
commands.push(commandInfo);
this._recordedCommands$.next(commands);
}
}
}
});
this._recording$.next(true);
}
stopRecording(): void {
this._recorder?.dispose();
this._recorder = null;
this._recorded$.next([]);
this._recordedCommands$.next([]);
this._recording$.next(false);
}
completeRecording(): void {
const commands = this._recorded.slice();
this._localFileService.downloadFile(new Blob([JSON.stringify(commands, null, 2)]), 'recorded-commands.json');
this._logService.error('Recorded commands:', commands);
this.stopRecording();
}
}

View File

@@ -0,0 +1,150 @@
/**
* 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 { ICommandInfo, Workbook } from '@univerjs/core';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import { awaitTime, Disposable, ICommandService, ILogService, IUniverInstanceService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
export enum ReplayMode {
DEFAULT = 'default',
NAME = 'name',
ACTIVE = 'active',
}
/**
* This service is for replaying user actions.
*/
export class ActionReplayService extends Disposable {
constructor(
@IMessageService private readonly _messageService: IMessageService,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@ILocalFileService private readonly _localFileService: ILocalFileService,
@ILogService private readonly _logService: ILogService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
}
/**
* Read a local file and try to replay commands in this JSON.
*/
async replayLocalJSON(mode: ReplayMode = ReplayMode.DEFAULT): Promise<boolean> {
const files = await this._localFileService.openFile({ multiple: false, accept: '.json' });
if (files.length !== 1) return false;
const file = files[0];
try {
return this.replayCommands(JSON.parse(await file.text()), { mode });
} catch {
this._messageService.show({
type: MessageType.Error,
content: `Failed to replay commands from local file ${file.name}.`,
});
return false;
}
}
/**
* Replay a list of commands. Note that `unitId` of these commands would be changed to the focused unit.
* @param commands - The commands to replay.
* @returns If the replay is successful.
*/
async replayCommands(commands: ICommandInfo[], options?: { mode: ReplayMode }): Promise<boolean> {
const focusedUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
if (!focusedUnitId) {
this._logService.error('[ReplayService]', 'no focused unit to replay commands');
}
const { mode } = options || {};
for (const command of commands) {
const { id, params } = command;
const commandParams = params as ISheetCommandSharedParams;
if (commandParams) {
if (typeof commandParams.unitId !== 'undefined') {
commandParams.unitId = focusedUnitId!;
}
if (mode === ReplayMode.NAME && commandParams.subUnitId !== 'undefined') {
const realSubUnitId = (this._instanceService.getFocusedUnit() as Workbook).getSheetBySheetName(commandParams.subUnitId)?.getSheetId();
if (realSubUnitId) {
commandParams.subUnitId = realSubUnitId;
} else {
this._logService.error('[ReplayService]', `failed to find subunit by subUnitName = ${commandParams.subUnitId}`);
}
}
if (mode === ReplayMode.ACTIVE && commandParams.subUnitId !== 'undefined') {
const realSubUnitId = (this._instanceService.getFocusedUnit() as Workbook).getActiveSheet()?.getSheetId();
if (realSubUnitId) {
commandParams.subUnitId = realSubUnitId;
} else {
this._logService.error('[ReplayService]', 'failed to find active subunit');
}
}
const result = await this._commandService.executeCommand(id, params);
if (!result) return false;
} else {
const result = await this._commandService.executeCommand(id);
if (!result) return false;
}
}
return true;
}
/**
* Replay a list of commands with a random delay between each command.
* @param commands - The commands to replay.
*/
async replayCommandsWithDelay(commands: ICommandInfo[]): Promise<boolean> {
const focusedUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
if (!focusedUnitId) {
this._logService.error('[ReplayService]', 'no focused unit to replay commands');
}
for (const command of commands) {
await awaitTime(randomBetween200And1k());
const { id, params } = command;
if (params) {
if (typeof (params as ISharedCommandParams).unitId !== 'undefined') {
(params as ISharedCommandParams).unitId = focusedUnitId;
}
const result = await this._commandService.executeCommand(id, params);
if (!result) return false;
} else {
const result = await this._commandService.executeCommand(id);
if (!result) return false;
}
}
return true;
}
}
interface ISharedCommandParams {
unitId?: string;
}
function randomBetween200And1k() {
return Math.floor(Math.random() * 800) + 200;
}

View File

@@ -0,0 +1,101 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { Button } from '@univerjs/design';
import { RecordIcon } from '@univerjs/icons';
import { useDependency, useObservable } from '@univerjs/ui';
import { useCallback } from 'react';
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../../commands/commands/record.command';
import { CloseRecordPanelOperation } from '../../commands/operations/operation';
import { ActionRecorderService } from '../../services/action-recorder.service';
export function RecorderPanel() {
const s = useDependency(ActionRecorderService);
const opened = useObservable(s.panelOpened$);
if (!opened) return null;
return <RecordPanelImpl />;
}
function RecordPanelImpl() {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const actionRecorderService = useDependency(ActionRecorderService);
const recording = useObservable(actionRecorderService.recording$);
const recordedCommands = useObservable(actionRecorderService.recordedCommands$);
const len = recordedCommands?.length ?? 0;
const closePanel = useCallback(() => {
if (!recording) commandService.executeCommand(CloseRecordPanelOperation.id);
}, [commandService, recording]);
const startRecording = useCallback((replaceId?: boolean) => {
if (!recording) commandService.executeCommand(StartRecordingActionCommand.id, { replaceId });
}, [commandService, recording]);
const completeRecording = useCallback(() => {
if (recording) commandService.executeCommand(CompleteRecordingActionCommand.id);
}, [commandService, recording]);
const stopRecording = useCallback(() => {
if (recording) commandService.executeCommand(StopRecordingActionCommand.id);
}, [commandService, recording]);
const titleText = recording
? len === 0 ? localeService.t<LocaleKey>('action-recorder.panel.recording') : (`${len}: ${recordedCommands![len - 1].id}`)
: localeService.t<LocaleKey>('action-recorder.panel.startRecording');
return (
<div
className={`
univer-fixed univer-bottom-20 univer-left-1/2 univer-z-[1000] univer-flex univer-h-16 univer-w-[512px]
-univer-translate-x-1/2 univer-items-center univer-rounded-lg univer-bg-gray-0 univer-px-5
univer-shadow-lg
`}
>
<div className="univer-mr-2 univer-size-5 univer-shrink-0 univer-grow-0 univer-text-xl">
<RecordIcon />
</div>
<div className="univer-flex-1 univer-text-sm">{titleText}</div>
<div className="univer-flex univer-w-64 univer-shrink-0 univer-grow-0 univer-justify-between">
<Button
className="univer-w-20"
onClick={recording ? stopRecording : closePanel}
>
{localeService.t<LocaleKey>(recording ? 'action-recorder.panel.cancel' : 'action-recorder.panel.close')}
</Button>
<Button
className="univer-w-20"
variant="primary"
onClick={recording ? completeRecording : () => startRecording()}
>
{localeService.t<LocaleKey>(recording ? 'action-recorder.panel.save' : 'action-recorder.panel.start')}
</Button>
{!recording && (
<Button
variant="primary"
onClick={() => startRecording(true)}
>
{localeService.t<LocaleKey>('action-recorder.panel.startWithId')}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,328 @@
/**
* 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 { IAccessor } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import {
CommandService,
CommandType,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
} from '@univerjs/core';
import { ILocalFileService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CompleteRecordingActionCommand,
StartRecordingActionCommand,
StopRecordingActionCommand,
} from '../../../commands/commands/record.command';
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../../../commands/operations/operation';
import enUS from '../../../locale/en-US';
import { ActionRecorderService } from '../../../services/action-recorder.service';
import { RecorderPanel } from '../RecorderPanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.panel-test.apply-cell-value';
interface IAppliedCommandParams {
unitId?: string;
subUnitId?: string;
value?: string;
}
class TestState {
static downloads: Array<{ data: Blob; fileName: string }> = [];
static appliedParams: IAppliedCommandParams[] = [];
static reset() {
this.downloads = [];
this.appliedParams = [];
}
}
class TestLocalFileService implements ILocalFileService {
openFile(): Promise<File[]> {
return Promise.resolve([]);
}
downloadFile(data: Blob, fileName: string): void {
TestState.downloads.push({ data, fileName });
}
}
class TestWorkbook {
getUnitId() {
return 'focused-workbook';
}
getSheetBySheetId(sheetId: string) {
if (sheetId !== 'sheet-1') {
return null;
}
return { getName: () => 'Recorded Sheet' };
}
}
class TestUniverInstanceService {
private readonly _workbook = new TestWorkbook();
getFocusedUnit() {
return this._workbook;
}
getUnit() {
return this._workbook;
}
}
function createRecorderPanelTestBed() {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([LocaleService]);
injector.add([ActionRecorderService]);
injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS });
injector.get(LocaleService).setLocale(LocaleType.EN_US);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
[
OpenRecordPanelOperation,
CloseRecordPanelOperation,
StartRecordingActionCommand,
CompleteRecordingActionCommand,
StopRecordingActionCommand,
{
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
handler: (_accessor: IAccessor, params?: IAppliedCommandParams) => {
TestState.appliedParams.push({ ...params });
return true;
},
},
].forEach((command) => commandService.registerCommand(command));
const recorderService = injector.get(ActionRecorderService);
recorderService.registerRecordedCommand({
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
handler: () => true,
});
return {
injector,
commandService,
recorderService,
};
}
async function renderRecorderPanel(root: Root, injector: Injector) {
await act(async () => {
root.render(
<RediContext.Provider value={{ injector }}>
<RecorderPanel />
</RediContext.Provider>
);
});
}
function getButton(container: HTMLElement, text: string) {
const button = Array.from(container.querySelectorAll('[data-u-comp="button"]'))
.find((item) => item.textContent === text);
expect(button).toBeTruthy();
return button as HTMLElement;
}
describe('RecorderPanel', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createRecorderPanelTestBed> | undefined;
beforeEach(() => {
TestState.reset();
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed = undefined;
root = undefined;
container = undefined;
});
it('opens the panel and starts recording from the Start action', async () => {
currentTestBed = createRecorderPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderRecorderPanel(root, currentTestBed.injector);
expect(container.textContent).toBe('');
await act(async () => {
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
});
expect(container.textContent).toContain('Start Recording');
await act(async () => {
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(currentTestBed.recorderService.recording).toBe(true);
expect(container.textContent).toContain('Recording...');
expect(container.textContent).toContain('Cancel');
expect(container.textContent).toContain('Save');
});
it('records a command with sheet names from Start(N) and exports it on Save', async () => {
currentTestBed = createRecorderPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderRecorderPanel(root, currentTestBed.injector);
await act(async () => {
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
});
await act(async () => {
getButton(container!, 'Start(N)').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await act(async () => {
await currentTestBed!.commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
unitId: 'focused-workbook',
subUnitId: 'sheet-1',
value: '42',
});
});
expect(container.textContent).toContain(`1: ${APPLY_CELL_VALUE_COMMAND_ID}`);
await act(async () => {
getButton(container!, 'Save').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(currentTestBed.recorderService.recording).toBe(false);
expect(TestState.downloads).toHaveLength(1);
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
expect(JSON.parse(await TestState.downloads[0].data.text())).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
params: {
unitId: 'focused-workbook',
subUnitId: 'Recorded Sheet',
value: '42',
},
},
]);
});
it('stops recording without exporting when the panel is closed', async () => {
currentTestBed = createRecorderPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderRecorderPanel(root, currentTestBed.injector);
await act(async () => {
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
});
await act(async () => {
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await act(async () => {
await currentTestBed!.commandService.executeCommand(CloseRecordPanelOperation.id);
});
expect(currentTestBed.recorderService.recording).toBe(false);
expect(container.textContent).toBe('');
expect(TestState.downloads).toHaveLength(0);
});
it('completes an active recording from the panel cancel action using the stop command', async () => {
currentTestBed = createRecorderPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderRecorderPanel(root, currentTestBed.injector);
await act(async () => {
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
});
await act(async () => {
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await act(async () => {
await currentTestBed!.commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
unitId: 'focused-workbook',
subUnitId: 'sheet-1',
value: 'cancelled',
});
});
expect(container.textContent).toContain(`1: ${APPLY_CELL_VALUE_COMMAND_ID}`);
await act(async () => {
getButton(container!, 'Cancel').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(currentTestBed.recorderService.recording).toBe(false);
expect(container.textContent).toContain('Start Recording');
expect(TestState.downloads).toHaveLength(1);
expect(JSON.parse(await TestState.downloads[0].data.text())).toEqual([
{
id: APPLY_CELL_VALUE_COMMAND_ID,
type: CommandType.COMMAND,
params: {
unitId: 'focused-workbook',
subUnitId: 'sheet-1',
value: 'cancelled',
},
},
]);
});
});

View File

@@ -0,0 +1,27 @@
/**
* 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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();