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,46 @@
# @univerjs/sheets-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-ui)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-ui)
`@univerjs/sheets-ui` provides the main spreadsheet UI layer for Univer Sheets, including selection, menus, clipboard, formula bar integration, and rendering interaction services.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-ui` | `UniverSheetsUi` | Yes | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-ui
# or
npm install @univerjs/sheets-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/sheets-ui/lib/index.css';
import EnUS from '@univerjs/sheets-ui/locale/en-US';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
univer.registerPlugin(UniverSheetsUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
Exported plugin classes:
- `UniverSheetsUIPlugin`
- `UniverSheetsMobileUIPlugin`
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,110 @@
{
"name": "@univerjs/sheets-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Spreadsheet UI layer for Univer Sheets.",
"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",
"sheets",
"spreadsheet",
"editor",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts",
"./facade": "./src/facade/index.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"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.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/docs": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/protocol": "workspace:*",
"@univerjs/sheets": "workspace:*",
"@univerjs/telemetry": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/themes": "workspace:*",
"postcss": "^8.5.28",
"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,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 { UniverInstanceType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getEmbedSheetsTabCustomData } from '../embed-tab-anchor';
describe('getEmbedSheetsTabCustomData', () => {
it('reads the embedded product type used by the Sheet tab marker', () => {
expect(getEmbedSheetsTabCustomData({
custom: {
UNIVER_EMBED_SHEETS_TAB: {
version: 1,
embedId: 'embed-1',
hostAnchorId: 'sheet-1',
childType: UniverInstanceType.UNIVER_BOARD,
},
},
})).toEqual({
version: 1,
embedId: 'embed-1',
hostAnchorId: 'sheet-1',
childType: UniverInstanceType.UNIVER_BOARD,
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* 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 { IRenderManagerService } from '@univerjs/engine-render';
import { DOCS_VIEW_KEY } from '@univerjs/docs-ui';
import { describe, expect, it } from 'vitest';
import { getEditorObject } from '../get-editor-object';
describe('getEditorObject', () => {
it('returns undefined when unitId is null or render is missing', () => {
const renderManagerService = {
getRenderUnitById: () => null,
} as unknown as IRenderManagerService;
expect(getEditorObject(null, renderManagerService)).toBeUndefined();
expect(getEditorObject('u-1', renderManagerService)).toBeUndefined();
});
it('returns editor render parts from render manager', () => {
const document = { id: 'doc' };
const docBackground = { id: 'bg' };
const scene = { id: 'scene' };
const engine = { id: 'engine' };
const renderManagerService = {
getRenderUnitById: () => ({
mainComponent: document,
scene,
engine,
components: new Map([[DOCS_VIEW_KEY.BACKGROUND, docBackground]]),
}),
} as unknown as IRenderManagerService;
const res = getEditorObject('u-1', renderManagerService);
expect(res).toEqual({
document,
docBackground,
scene,
engine,
});
});
});

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 { Nullable } from '@univerjs/core';
import type { DocBackground, Documents, Engine, IRenderManagerService, Scene } from '@univerjs/engine-render';
import { DOCS_VIEW_KEY } from '@univerjs/docs-ui';
export interface IDocObjectParam {
document: Documents;
docBackground: DocBackground;
scene: Scene;
engine: Engine;
}
export function getEditorObject(
unitId: Nullable<string>,
renderManagerService: IRenderManagerService
): Nullable<IDocObjectParam> {
if (unitId == null) {
return;
}
const currentRender = renderManagerService.getRenderUnitById(unitId);
if (currentRender == null) {
return;
}
const { mainComponent, scene, engine, components } = currentRender;
const document = mainComponent as Documents;
const docBackground = components.get(DOCS_VIEW_KEY.BACKGROUND) as DocBackground;
return {
document,
docBackground,
scene,
engine,
};
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { Editor, IRichTextEditorProps } from '@univerjs/docs-ui';
import type { RefObject } from 'react';
export interface IRangeSelectorInstance {
editor: Nullable<Editor>;
blur: () => void;
focus: () => void;
changePopupVisible: (visible: boolean) => void;
verify: () => boolean;
}
export interface IRangeSelectorProps extends IRichTextEditorProps {
unitId: string;
subUnitId: string;
maxRangeCount?: number;
supportAcrossSheet?: boolean;
selectorRef?: RefObject<IRangeSelectorInstance>;
onVerify?: (res: boolean, rangeText: string) => void;
onRangeSelectorDialogVisibleChange?: (visible: boolean) => void;
};

View File

@@ -0,0 +1,677 @@
/**
* 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 { Injector, IRange, Univer, Workbook } from '@univerjs/core';
import {
ICommandService,
IConfirmService,
IUniverInstanceService,
LocaleService,
RANGE_TYPE,
RedoCommand,
TestConfirmService,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import {
AddWorksheetMergeAllCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeHorizontalCommand,
AddWorksheetMergeMutation,
AddWorksheetMergeVerticalCommand,
getClearContentMutationParamForRange,
InsertColAfterCommand,
InsertColBeforeCommand,
InsertColByRangeCommand,
InsertColCommand,
InsertColMutation,
InsertRowAfterCommand,
InsertRowBeforeCommand,
InsertRowByRangeCommand,
InsertRowCommand,
InsertRowMutation,
MergeCellController,
MoveColsCommand,
MoveColsMutation,
MoveRangeMutation,
RefRangeService,
RemoveColByRangeCommand,
RemoveColCommand,
RemoveColMutation,
RemoveRowByRangeCommand,
RemoveRowCommand,
RemoveRowMutation,
RemoveWorksheetMergeCommand,
RemoveWorksheetMergeMutation,
SetRangeValuesMutation,
SetSelectionsOperation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createCommandTestBed } from './create-command-test-bed';
describe('Test add worksheet merge commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let selectionManager: SheetsSelectionsService;
beforeEach(() => {
const testBed = createCommandTestBed(undefined, [
[IConfirmService, { useClass: TestConfirmService }],
[RefRangeService],
[MergeCellController],
]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(AddWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeAllCommand);
commandService.registerCommand(AddWorksheetMergeVerticalCommand);
commandService.registerCommand(AddWorksheetMergeHorizontalCommand);
commandService.registerCommand(RemoveWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeMutation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
[
RemoveColByRangeCommand,
RemoveRowByRangeCommand,
InsertRowByRangeCommand,
InsertColByRangeCommand,
].forEach((command) => {
commandService.registerCommand(command);
});
[
InsertRowCommand,
InsertRowBeforeCommand,
InsertRowAfterCommand,
InsertColAfterCommand,
InsertColBeforeCommand,
InsertColCommand,
RemoveRowCommand,
RemoveColCommand,
MoveColsCommand,
InsertColMutation,
InsertRowMutation,
RemoveRowMutation,
RemoveColMutation,
MoveRangeMutation,
MoveColsMutation,
SetSelectionsOperation,
].forEach((c) => commandService.registerCommand(c));
get(LocaleService).load({});
get(MergeCellController);
selectionManager = get(SheetsSelectionsService);
});
afterEach(() => {
// univer.dispose();
});
describe('add merge all', () => {
describe('correct situations', () => {
it('will merge all cells of the selected range when there is a selected range', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 10, startColumn: 10, endColumn: 10, endRow: 10, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
function getMerge(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getConfig()
.mergeData;
}
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeAllCommand.id)).toBeTruthy();
expect(getMerge()).toStrictEqual([
{ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5, rangeType: RANGE_TYPE.NORMAL },
]);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getMerge()).toStrictEqual([
{ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5, rangeType: RANGE_TYPE.NORMAL },
]);
});
});
describe('fault situations', () => {
it('will not apply when there is no selected ranges', async () => {
const result = await commandService.executeCommand(AddWorksheetMergeAllCommand.id);
expect(result).toBeFalsy();
});
});
});
describe('add merge vertical', () => {
describe('correct situations', () => {
it('will merge all vertical cells of the selected range when there is a selected range', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 10, startColumn: 10, endColumn: 15, endRow: 10, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
function getMerge(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getConfig()
.mergeData;
}
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeVerticalCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(6);
expect(getMerge()?.[0]).toStrictEqual({
startRow: 0,
startColumn: 0,
endColumn: 0,
endRow: 5,
});
});
});
describe('fault situations', () => {
it('will not apply when there is no selected ranges', async () => {
const result = await commandService.executeCommand(AddWorksheetMergeVerticalCommand.id);
expect(result).toBeFalsy();
});
});
});
describe('add merge horizontal', () => {
describe('correct situations', () => {
it('will merge all horizontal cells of the selected range when there is a selected range', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 10, startColumn: 10, endColumn: 10, endRow: 15, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
function getMerge(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getConfig()
.mergeData;
}
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeHorizontalCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(6);
expect(getMerge()?.[0]).toStrictEqual({ startRow: 0, startColumn: 0, endColumn: 5, endRow: 0 });
});
});
describe('fault situations', () => {
it('will not apply when there is no selected ranges', async () => {
const result = await commandService.executeCommand(AddWorksheetMergeHorizontalCommand.id);
expect(result).toBeFalsy();
});
});
describe('horizontal with other merge cell', () => {
it('test only the upper left conner has a value', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 10, startColumn: 2, endColumn: 2, endRow: 12, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
function getMerge(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getConfig()
.mergeData;
}
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeAllCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(1);
commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
10: {
2: {
v: 1234,
},
},
},
});
selectionManager.clear();
selectionManager.addSelections([
{
range: { startRow: 10, startColumn: 1, endColumn: 2, endRow: 12, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
expect(await commandService.executeCommand(AddWorksheetMergeHorizontalCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(3);
const worksheet = get(IUniverInstanceService).getUnit<Workbook>('test')?.getSheetBySheetId('sheet1');
if (worksheet) {
expect(worksheet.getCellMatrix().getValue(10, 1)?.v).toBe(1234);
expect(worksheet.getCellMatrix().getValue(10, 2)?.v).toBeUndefined();
}
});
});
});
describe('cancel merge', () => {
describe('correct situations', () => {
it('will cancel the merge of the selected range when there is a selected range', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
function getMerge(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getConfig()
.mergeData;
}
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeHorizontalCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(6);
expect(getMerge()?.[0]).toStrictEqual({ startRow: 0, startColumn: 0, endColumn: 5, endRow: 0 });
expect(await commandService.executeCommand(RemoveWorksheetMergeCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(0);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(6);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getMerge()?.length).toBe(0);
});
});
describe('fault situations', () => {
it('will not apply when there is no selected ranges', async () => {
const result = await commandService.executeCommand(RemoveWorksheetMergeCommand.id);
expect(result).toBeFalsy();
});
});
});
describe('merge cell with set cell data', () => {
it('test clear util', () => {
const commandService = get(ICommandService);
const univerInstanceService = get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
if (!worksheet) throw new Error('No active sheet found');
commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
cellValue: {
2: {
2: {
p: {
id: '__INTERNAL_EDITOR__DOCS_NORMAL',
documentStyle: {
pageSize: {
width: 82.20805358886719,
height: null,
},
marginTop: 0,
marginBottom: 1,
marginRight: 2,
marginLeft: 2,
renderConfig: {
horizontalAlign: 0,
verticalAlign: 0,
centerAngle: 0,
vertexAngle: 0,
wrapStrategy: 0,
},
},
body: {
dataStream: 'hahaha\r\n',
textRuns: [
{
st: 3,
ed: 5,
ts: {
fs: 28,
},
},
],
paragraphs: [
{ paragraphId: 'para_sheets_ui_fixture_1', startIndex: 6, paragraphStyle: {
horizontalAlign: 0,
} },
],
sectionBreaks: [
{
sectionId: 'section_fixture_1106',
startIndex: 7,
},
],
customRanges: [],
customDecorations: [],
},
drawings: {},
drawingsOrder: [],
settings: {
zoomRatio: 1,
},
resources: [
{
name: 'SHEET_THREAD_COMMENT_PLUGIN',
data: '{}',
},
{
name: 'SHEET_AuthzIoMockService_PLUGIN',
data: '{}',
},
],
},
v: null,
f: null,
si: null,
custom: {},
},
},
},
});
const res = getClearContentMutationParamForRange(worksheet, { startRow: 0, startColumn: 2, endRow: 2, endColumn: 2 });
expect(res.getValue(0, 2)?.p).toBeTruthy();
expect(res.getValue(2, 2)).toBeNull();
});
it('test merge range first cell is blank, second cell is percent number format', async () => {
const univerInstanceService = get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
if (!worksheet) throw new Error('No active sheet found');
expect(worksheet.getCellRaw(20, 0)).toBeUndefined();
expect(worksheet.getCellRaw(20, 1)).toStrictEqual({
v: 2,
t: 2,
s: {
n: {
pattern: '0%',
},
},
});
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: {
startRow: 20,
startColumn: 0,
endRow: 21,
endColumn: 1,
rangeType: RANGE_TYPE.NORMAL,
},
primary: null,
style: null,
},
]);
const commandService = get(ICommandService);
await commandService.executeCommand(AddWorksheetMergeAllCommand.id);
expect(worksheet.getCellRaw(20, 0)?.v).toStrictEqual(2);
expect(worksheet.getCellStyle(20, 0)).toStrictEqual({
n: {
pattern: '0%',
},
});
expect(worksheet.getCellStyle(20, 1)).toStrictEqual({
n: {
pattern: '0%',
},
});
});
});
describe('with insert or remove col', () => {
it('will adjust the merge data when insert col and remove col', async () => {
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 10, startColumn: 10, endColumn: 15, endRow: 15, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
const commandService = get(ICommandService);
const univerInstanceService = get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
if (!worksheet) throw new Error('No active sheet found');
const mergeData = worksheet.getConfig().mergeData;
expect(mergeData.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeAllCommand.id)).toBeTruthy();
expect(mergeData.length).toBe(2);
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: {
startRow: 0,
startColumn: 7,
endColumn: 7,
endRow: worksheet.getRowCount() - 1,
rangeType: RANGE_TYPE.COLUMN,
},
primary: null,
style: null,
},
]);
expect(await commandService.executeCommand(InsertColAfterCommand.id)).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 11, endColumn: 16, endRow: 15, rangeType: 0 });
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: {
startRow: 0,
startColumn: 12,
endColumn: 13,
endRow: worksheet.getRowCount() - 1,
rangeType: RANGE_TYPE.COLUMN,
},
primary: null,
style: null,
},
]);
expect(await commandService.executeCommand(InsertColAfterCommand.id)).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 11, endColumn: 18, endRow: 15, rangeType: 0 });
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: {
startRow: 0,
startColumn: 7,
endColumn: 7,
endRow: worksheet.getRowCount() - 1,
rangeType: RANGE_TYPE.COLUMN,
},
primary: null,
style: null,
},
]);
expect(await commandService.executeCommand(RemoveColCommand.id)).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 10, endColumn: 17, endRow: 15, rangeType: 0 });
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: {
startRow: 0,
startColumn: 12,
endColumn: 13,
endRow: worksheet.getRowCount() - 1,
rangeType: RANGE_TYPE.COLUMN,
},
primary: null,
style: null,
},
]);
expect(await commandService.executeCommand(RemoveColCommand.id)).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 10, endColumn: 15, endRow: 15, rangeType: 0 });
});
});
describe('with move col', () => {
it('will adjust the merge data when move col', async () => {
selectionManager.clearCurrentSelections();
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 5, endRow: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 10, startColumn: 10, endColumn: 15, endRow: 15, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
const commandService = get(ICommandService);
const univerInstanceService = get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
if (!worksheet) throw new Error('No active sheet found');
const mergeData = worksheet.getConfig().mergeData;
expect(mergeData.length).toBe(0);
expect(await commandService.executeCommand(AddWorksheetMergeAllCommand.id)).toBeTruthy();
expect(mergeData.length).toBe(2);
selectionManager.clearCurrentSelections();
selectionManager.addSelections([{
primary: null,
style: null,
range: {
startRow: 0,
startColumn: 18,
endRow: worksheet.getRowCount() - 1,
endColumn: 18,
rangeType: 2,
},
}]);
expect(await commandService.executeCommand(MoveColsCommand.id, {
fromRange: {
startRow: 0,
startColumn: 18,
endRow: worksheet.getRowCount() - 1,
endColumn: 18,
rangeType: 2,
},
toRange: {
startRow: 0,
startColumn: 7,
endRow: worksheet.getRowCount() - 1,
endColumn: 7,
rangeType: 2,
},
})).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 11, endColumn: 16, endRow: 15, rangeType: 0 });
selectionManager.clearCurrentSelections();
selectionManager.addSelections([{
primary: null,
style: null,
range: {
startRow: 0,
startColumn: 6,
endRow: worksheet.getRowCount() - 1,
endColumn: 8,
rangeType: 2,
},
}]);
expect(await commandService.executeCommand(MoveColsCommand.id, {
fromRange: {
startRow: 0,
startColumn: 6,
endRow: worksheet.getRowCount() - 1,
endColumn: 8,
rangeType: 2,
},
toRange: {
startRow: 0,
startColumn: 17,
endRow: worksheet.getRowCount() - 1,
endColumn: 19,
rangeType: 2,
},
})).toBeTruthy();
expect(worksheet.getMergeData().length).toBe(2);
expect(worksheet.getMergeData()[1]).toStrictEqual({ startRow: 10, startColumn: 8, endColumn: 13, endRow: 15, rangeType: 0 });
});
});
});

View File

@@ -0,0 +1,960 @@
/**
* 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 { ICellData, Injector, IRange, IStyleData, Nullable, Workbook } from '@univerjs/core';
import {
CellValueType,
ICommandService,
IUniverInstanceService,
LocaleType,
RedoCommand,
set,
ThemeService,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { EditorService, IEditorService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
AddWorksheetMergeMutation,
AUTO_FILL_APPLY_TYPE,
AutoClearContentCommand,
AutoFillCommand,
AutoFillController,
AutoFillService,
IAutoFillService,
RefillCommand,
RemoveWorksheetMergeMutation,
SetRangeValuesMutation,
SetSelectionsOperation,
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { IPlatformService, IShortcutService, PlatformService, ShortcutService } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AutoFillRenderController, AutoFillUIController, detectAutoFillRange } from '../../../controllers/auto-fill-ui.controller';
import { createRenderTestBed } from '../../../controllers/render-controllers/__tests__/render-test-bed';
import { EditorBridgeService, IEditorBridgeService } from '../../../services/editor-bridge.service';
import { ISheetSelectionRenderService } from '../../../services/selection/base-selection-render.service';
import { SheetSelectionRenderService } from '../../../services/selection/selection-render.service';
import { SheetSkeletonManagerService } from '../../../services/sheet-skeleton-manager.service';
import { SheetsRenderService } from '../../../services/sheets-render.service';
import { createCommandTestBed } from './create-command-test-bed';
class mockSheetsRenderService {
registerSkeletonChangingMutations(_id: string) {
}
}
describe('AutoFillRenderController', () => {
it('routes fill events to the workbook owned by the render context', () => {
const selectionFilled$ = new Subject<IRange | null>();
const getSelectionControls = vi.fn(() => [{
model: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 2,
},
selectionFilled$,
fillControl: {
onDblclick$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
onPointerDown$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
},
}]);
const testBed = createRenderTestBed({
dependencies: [
[ISheetSelectionRenderService, { useValue: { getSelectionControls } }],
[IEditorBridgeService, {
useValue: {
isVisible: () => ({ visible: false }),
},
}],
],
});
const executeCommand = vi.spyOn(testBed.commandService, 'executeCommand').mockResolvedValue(true);
const controller = testBed.injector.createInstance(AutoFillRenderController, testBed.context);
selectionFilled$.next({
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 5,
});
expect(executeCommand).toHaveBeenCalledWith(AutoFillCommand.id, {
sourceRange: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 2,
},
targetRange: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 5,
},
unitId: testBed.context.unitId,
subUnitId: testBed.sheet.getActiveSheet().getSheetId(),
});
controller.dispose();
testBed.univer.dispose();
});
});
const TEST_WORKBOOK_DATA = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
// Single Number
0: {
0: {
v: 1,
t: CellValueType.NUMBER,
},
1: {
v: 2,
t: CellValueType.NUMBER,
},
},
// Single Number. descending
1: {
0: {
v: 2,
t: CellValueType.NUMBER,
},
1: {
v: 1,
t: CellValueType.NUMBER,
},
},
// Extend Number
2: {
0: {
v: '第 1',
t: CellValueType.STRING,
},
1: {
v: '第 2',
t: CellValueType.STRING,
},
},
// Chinese Number
3: {
0: {
v: '一',
t: CellValueType.STRING,
},
1: {
v: '三',
t: CellValueType.STRING,
},
},
// Chinese Week
4: {
0: {
v: '星期一',
t: CellValueType.STRING,
},
1: {
v: '星期三',
t: CellValueType.STRING,
},
},
// Loop Series
5: {
0: {
v: '甲',
t: CellValueType.STRING,
},
1: {
v: '乙',
t: CellValueType.STRING,
},
},
// Other String
6: {
0: {
v: 'copy only',
t: CellValueType.STRING,
},
1: {
v: 'no series',
t: CellValueType.STRING,
},
},
// Mixed Mode
7: {
0: {
v: 1,
t: CellValueType.NUMBER,
},
1: {
v: 2,
t: CellValueType.NUMBER,
},
2: {
v: '第 1',
t: CellValueType.STRING,
},
3: {
v: '第 2',
t: CellValueType.STRING,
},
},
// db click to fill
10: {
0: {
v: 1,
t: CellValueType.NUMBER,
},
1: {
v: 2,
t: CellValueType.NUMBER,
},
2: {
v: 3,
t: CellValueType.NUMBER,
},
3: {
v: 4,
t: CellValueType.NUMBER,
},
},
11: {
0: {
v: 2,
t: CellValueType.NUMBER,
},
2: {
v: 3,
t: CellValueType.NUMBER,
},
},
12: {
0: {
v: 3,
t: CellValueType.NUMBER,
},
},
13: {
0: {
v: 4,
t: CellValueType.NUMBER,
},
},
// left direction & equal ratio & styles
14: {
2: {
v: 2,
t: CellValueType.NUMBER,
s: '_aRLOe',
},
3: {
v: 4,
t: CellValueType.NUMBER,
},
4: {
v: 8,
t: CellValueType.NUMBER,
s: '3UpAbI',
},
},
16: {
2: {
v: 2,
t: CellValueType.NUMBER,
s: '_aRLOe',
},
},
},
mergeData: [
{
startRow: 0,
endRow: 2,
startColumn: 5,
endColumn: 7,
},
],
},
},
createdTime: '',
creator: '',
lastModifiedBy: '',
locale: LocaleType.ZH_CN,
modifiedTime: '',
name: '',
sheetOrder: [],
styles: {
_aRLOe: {
bg: {
rgb: '#ccc',
},
ht: 2,
vt: 2,
},
'3UpAbI': {
bg: {
rgb: '#eee',
},
},
},
timeZone: '',
};
describe('Test auto fill rules in controller', () => {
let get: Injector['get'];
let commandService: ICommandService;
let themeService: ThemeService;
let getValues: (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
) => Array<Array<Nullable<ICellData>>> | undefined;
let getStyles: (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
) => Array<Array<Nullable<IStyleData>>> | undefined;
beforeEach(() => {
const testBed = createCommandTestBed(TEST_WORKBOOK_DATA, [
[DocSelectionManagerService],
[ISheetSelectionRenderService, { useClass: SheetSelectionRenderService }],
[SheetSkeletonService],
[IAutoFillService, { useClass: AutoFillService }],
[IShortcutService, { useClass: ShortcutService }],
[IPlatformService, { useClass: PlatformService }],
[IEditorBridgeService, { useClass: EditorBridgeService }],
[IEditorService, { useClass: EditorService }],
[IRenderManagerService, { useClass: RenderManagerService }],
[SheetsRenderService, { useClass: mockSheetsRenderService }],
[SheetSkeletonManagerService],
[AutoFillController],
[AutoFillUIController],
]);
get = testBed.get;
commandService = get(ICommandService);
themeService = get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
get(AutoFillUIController);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(SetSelectionsOperation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
commandService.registerCommand(AddWorksheetMergeMutation);
commandService.registerCommand(RefillCommand);
commandService.registerCommand(AutoClearContentCommand);
commandService.registerCommand(AutoFillCommand);
getValues = (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
): Array<Array<Nullable<ICellData>>> | undefined =>
get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getRange(startRow, startColumn, endRow, endColumn)
.getValues();
getStyles = (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
): Array<Array<Nullable<IStyleData>>> | undefined => {
const values = getValues(startRow, startColumn, endRow, endColumn);
const styles = get(IUniverInstanceService).getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)?.getStyles();
if (values && styles) {
return values.map((row) => row.map((cell) => styles.getStyleByCell(cell)));
}
};
});
describe('auto fill', () => {
describe('auto fill the numbers', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test number
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 1,
},
targetRange: {
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 3,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(0, 2)?.v).toBe(3);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(0, 3)?.v).toBe(4);
// test number. descending
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 1,
startColumn: 0,
endRow: 1,
endColumn: 1,
},
targetRange: {
startRow: 1,
startColumn: 0,
endRow: 1,
endColumn: 3,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(1, 2)?.v).toBe(0);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(1, 3)?.v).toBe(-1);
});
});
describe('auto fill the extend numbers', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test extend number
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 2,
startColumn: 0,
endRow: 2,
endColumn: 1,
},
targetRange: {
startRow: 2,
startColumn: 0,
endRow: 2,
endColumn: 3,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(2, 2)?.v).toBe('第 3');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(2, 3)?.v).toBe('第 4');
});
});
describe('auto fill the chinese numbers', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test chinese number
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 3,
startColumn: 0,
endRow: 3,
endColumn: 2,
},
targetRange: {
startRow: 3,
startColumn: 0,
endRow: 3,
endColumn: 4,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(3, 3)?.v).toBe('五');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(3, 4)?.v).toBe('日');
});
});
describe('auto fill the chinese week', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test chinese week
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 4,
startColumn: 0,
endRow: 4,
endColumn: 2,
},
targetRange: {
startRow: 4,
startColumn: 0,
endRow: 4,
endColumn: 4,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(4, 3)?.v).toBe('星期五');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(4, 4)?.v).toBe('星期日');
});
});
describe('auto fill the loop series', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test loop series
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 5,
startColumn: 0,
endRow: 5,
endColumn: 1,
},
targetRange: {
startRow: 5,
startColumn: 0,
endRow: 5,
endColumn: 3,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(5, 2)?.v).toBe('丙');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(5, 3)?.v).toBe('丁');
});
});
describe('auto fill the other string', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test other string
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 6,
startColumn: 0,
endRow: 6,
endColumn: 1,
},
targetRange: {
startRow: 6,
startColumn: 0,
endRow: 6,
endColumn: 3,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(6, 2)?.v).toBe('copy only');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(6, 3)?.v).toBe('no series');
});
});
describe('auto fill the mixed mode', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test mixed mode
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 7,
startColumn: 0,
endRow: 7,
endColumn: 3,
},
targetRange: {
startRow: 7,
startColumn: 0,
endRow: 7,
endColumn: 7,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 4)?.v).toBe(3);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 5)?.v).toBe(4);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 6)?.v).toBe('第 3');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 7)?.v).toBe('第 4');
// undo redo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 4)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 5)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 6)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 7)?.v).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 4)?.v).toBe(3);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 5)?.v).toBe(4);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 6)?.v).toBe('第 3');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(7, 7)?.v).toBe('第 4');
});
});
describe('auto fill the merged cell', async () => {
it('test primary', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
const selectionManagerService = get(SheetsSelectionsService);
selectionManagerService.addSelections([{
style: null,
range: {
startRow: 0,
startColumn: 5,
endRow: 2,
endColumn: 7,
},
primary: {
startRow: 0,
startColumn: 5,
endRow: 2,
endColumn: 7,
actualColumn: 5,
actualRow: 0,
isMerged: true,
isMergedMainCell: true,
},
}]);
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 0,
startColumn: 5,
endRow: 2,
endColumn: 7,
},
targetRange: {
startRow: 0,
startColumn: 5,
endRow: 5,
endColumn: 7,
},
});
expect(workbook.getActiveSheet()?.getMergeData().length).toBe(2);
expect(selectionManagerService.getCurrentLastSelection()?.primary).toEqual(expect.objectContaining({
startRow: 0,
startColumn: 5,
endRow: 2,
endColumn: 7,
}));
});
});
describe('auto fill clear', async () => {
it('test primary will move to upper left corner', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
const selectionManagerService = get(SheetsSelectionsService);
selectionManagerService.addSelections([{
style: null,
range: {
startRow: 0,
startColumn: 0,
endRow: 5,
endColumn: 2,
},
// primary not in the upper left corner, but after fill clear will move to upper left corner
primary: {
startRow: 0,
startColumn: 0,
endRow: 1,
endColumn: 0,
actualColumn: 0,
actualRow: 1,
isMerged: true,
isMergedMainCell: true,
},
}]);
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 0,
startColumn: 0,
endRow: 5,
endColumn: 2,
},
targetRange: {
startRow: 0,
startColumn: 0,
endRow: 3,
endColumn: 2,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(4, 0)?.v).toBeFalsy();
expect(selectionManagerService.getCurrentLastSelection()?.primary).toEqual(expect.objectContaining({
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 0,
}));
});
});
});
describe('auto fill range is auto detected', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test other string
const sourceRange: IRange = {
startRow: 10,
startColumn: 1,
endRow: 10,
endColumn: 1,
};
const worksheet = workbook.getSheetBySheetId('sheet1');
if (!worksheet) throw new Error('Worksheet sheet1 does not exist');
await commandService.executeCommand(AutoFillCommand.id, {
sourceRange,
targetRange: detectAutoFillRange(sourceRange, worksheet),
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(11, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(12, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(13, 1)?.v).toBe(2);
// undo redo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(11, 1)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(12, 1)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(13, 1)?.v).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(11, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(12, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(13, 1)?.v).toBe(2);
});
});
describe('auto fill in left direction', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test other string
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 14,
startColumn: 2,
endRow: 14,
endColumn: 4,
},
targetRange: {
startRow: 14,
startColumn: 0,
endRow: 14,
endColumn: 4,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 0)?.v).toBe(0.5);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 1)?.v).toBe(1);
// undo redo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 0)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 1)?.v).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 0)?.v).toBe(0.5);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 1)?.v).toBe(1);
});
});
describe('auto fill with equal ratio & style', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// equal ratio
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 14,
startColumn: 2,
endRow: 14,
endColumn: 4,
},
targetRange: {
startRow: 14,
startColumn: 2,
endRow: 14,
endColumn: 7,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 5)?.v).toBe(16);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 6)?.v).toBe(32);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 7)?.v).toBe(64);
const styles = getStyles(14, 5, 14, 7);
expect(styles && styles[0][0]).toStrictEqual({
bg: {
rgb: '#ccc',
},
ht: 2,
vt: 2,
});
expect(styles && styles[0][1]).toStrictEqual(undefined);
expect(styles && styles[0][2]).toStrictEqual({
bg: {
rgb: '#eee',
},
});
// undo redo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 5)?.v).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 6)?.v).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 5)?.v).toBe(16);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 6)?.v).toBe(32);
});
});
describe('auto fill without format', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// equal ratio
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 14,
startColumn: 2,
endRow: 14,
endColumn: 3,
},
targetRange: {
startRow: 14,
startColumn: 2,
endRow: 14,
endColumn: 4,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 4)?.v).toBe(6);
const styles = getStyles(14, 4, 14, 4);
expect(styles && styles[0][0]).toStrictEqual({
bg: {
rgb: '#ccc',
},
ht: 2,
vt: 2,
});
commandService.executeCommand(RefillCommand.id, { type: AUTO_FILL_APPLY_TYPE.NO_FORMAT });
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 4)?.v).toBe(6);
const styles_refill = getStyles(14, 4, 14, 4);
expect(styles_refill && styles_refill[0][0]).toStrictEqual({
bg: {
rgb: '#eee',
},
});
// undo redo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 4)?.v).toBe(8);
const styles_undo = getStyles(14, 4, 14, 4);
expect(styles_undo && styles_undo[0][0]).toStrictEqual({
bg: {
rgb: '#eee',
},
});
// redo
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(14, 4)?.v).toBe(6);
const styles_redo = getStyles(14, 4, 14, 4);
expect(styles_redo && styles_redo[0][0]).toStrictEqual({
bg: {
rgb: '#eee',
},
});
});
});
describe('auto fill from single cell', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test right
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 16,
startColumn: 2,
endRow: 16,
endColumn: 2,
},
targetRange: {
startRow: 16,
startColumn: 2,
endRow: 16,
endColumn: 4,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(16, 3)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(16, 4)?.v).toBe(2);
// test left
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 16,
startColumn: 2,
endRow: 16,
endColumn: 2,
},
targetRange: {
startRow: 16,
startColumn: 0,
endRow: 16,
endColumn: 2,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(16, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(16, 0)?.v).toBe(2);
// test up
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 16,
startColumn: 2,
endRow: 16,
endColumn: 2,
},
targetRange: {
startRow: 15,
startColumn: 2,
endRow: 16,
endColumn: 2,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(15, 2)?.v).toBe(2);
// test down
commandService.executeCommand(AutoFillCommand.id, {
sourceRange: {
startRow: 16,
startColumn: 2,
endRow: 16,
endColumn: 2,
},
targetRange: {
startRow: 16,
startColumn: 2,
endRow: 17,
endColumn: 2,
},
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(17, 2)?.v).toBe(2);
});
});
});

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 { IWorkbookData, Workbook } from '@univerjs/core';
import type { IClipboardInterfaceService } from '@univerjs/ui';
import { ICommandService, Injector, IUniverInstanceService, Tools, UniverInstanceType } from '@univerjs/core';
import {
SetRangeValuesMutation,
SetSelectionsOperation,
SetWorksheetActiveOperation,
SetWorksheetRowAutoHeightMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { expect, it, vi } from 'vitest';
import { clipboardTestBed } from '../../../services/clipboard/__tests__/clipboard-test-bed';
import { PREDEFINED_HOOK_NAME_PASTE } from '../../../services/clipboard/clipboard.service';
import { SheetPasteCommand } from '../clipboard.command';
it.each([false, true])('silently cancels a delayed paste after target disposal (replacement: %s)', async (replace) => {
let release = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const getType = vi.fn(async () => new Blob(['stale paste'], { type: 'text/plain' }));
const boundary: IClipboardInterfaceService = {
supportClipboard: true,
write: async () => {},
writeText: async () => {},
readText: async () => '',
read: vi.fn(async () => {
await gate;
return [{ presentationStyle: 'unspecified' as const, types: ['text/plain'], getType }];
}),
};
const { univer, get, sheet } = clipboardTestBed(undefined, undefined, boundary);
try {
const commands = get(ICommandService);
for (const command of [SetRangeValuesMutation, SetSelectionsOperation, SetWorksheetActiveOperation, SetWorksheetRowAutoHeightMutation]) {
commands.registerCommand(command);
}
get(SheetsSelectionsService).setSelections('test', 'sheet1', [{
range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
primary: null,
style: null,
}]);
const snapshot = Tools.deepClone(sheet.getSnapshot());
const pending = SheetPasteCommand.handler(get(Injector), { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE });
expect(boundary.read).toHaveBeenCalledOnce();
expect(get(IUniverInstanceService).disposeUnit(sheet.getUnitId())).toBe(true);
const replacement = replace
? univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, snapshot)
: null;
const before = replacement ? JSON.stringify(replacement.getSnapshot()) : null;
release();
expect(await pending).toBe(false);
expect(getType).not.toHaveBeenCalled();
expect(replacement ? JSON.stringify(replacement.getSnapshot()) : null).toEqual(before);
} finally {
release();
univer.dispose();
}
});

View File

@@ -0,0 +1,470 @@
/**
* 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, IRange, Workbook, Worksheet } from '@univerjs/core';
import type { IClipboardInterfaceService } from '@univerjs/ui';
import {
ICommandService,
IConfirmService,
Injector,
IPermissionService,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
RedoCommand,
UndoCommand,
} from '@univerjs/core';
import {
AddWorksheetMergeMutation,
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
InsertRangeMoveDownCommand,
InsertRangeMoveRightCommand,
MoveRangeMutation,
RemoveWorksheetMergeMutation,
SetRangeValuesMutation,
SetSelectionsOperation,
SetWorksheetActiveOperation,
SetWorksheetColWidthMutation,
SetWorksheetRowAutoHeightMutation,
SetWorksheetRowHeightMutation,
SheetsSelectionsService,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorksheetSetCellStylePermission,
WorksheetSetCellValuePermission,
} from '@univerjs/sheets';
import * as sheets from '@univerjs/sheets';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { clipboardTestBed } from '../../../services/clipboard/__tests__/clipboard-test-bed';
import { ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE } from '../../../services/clipboard/clipboard.service';
import {
SheetCopyCommand,
SheetCutCommand,
SheetOptionalPasteCommand,
SheetPasteBesidesBorderCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteFormatCommand,
SheetPasteShortKeyCommand,
SheetPasteValueCommand,
} from '../clipboard.command';
import { DeleteRangeMoveLeftConfirmCommand } from '../delete-range-move-left-confirm.command';
import { DeleteRangeMoveUpConfirmCommand } from '../delete-range-move-up-confirm.command';
import { InsertRangeMoveDownConfirmCommand } from '../insert-range-move-down-confirm.command';
import { InsertRangeMoveRightConfirmCommand } from '../insert-range-move-right-confirm.command';
function createAccessor(pairs: Array<[unknown, unknown]>): IAccessor {
const map = new Map<unknown, unknown>(pairs);
return {
get(token: unknown) {
if (!map.has(token)) {
throw new Error(`Unknown token: ${String(token)}`);
}
return map.get(token);
},
} as unknown as IAccessor;
}
function createWorksheet(options?: {
rowCount?: number;
colCount?: number;
filteredRows?: number[];
merges?: IRange[];
}): Worksheet {
const filtered = new Set(options?.filteredRows ?? []);
return {
getRowCount: () => options?.rowCount ?? 10,
getColumnCount: () => options?.colCount ?? 8,
getRowFiltered: (row: number) => filtered.has(row),
getMergeData: () => options?.merges ?? [],
} as unknown as Worksheet;
}
function mockSheetCommandTarget(worksheet: Worksheet): void {
vi.spyOn(sheets, 'getSheetCommandTarget').mockReturnValue({
workbook: {} as unknown as Workbook,
worksheet,
unitId: 'unit-1',
subUnitId: 'sheet-1',
});
}
function createSelectionService(range?: IRange | null) {
return {
getCurrentSelections: () => (range ? [{ range }] : range === null ? null : [{ range: undefined }]),
};
}
describe('insert/delete range confirm commands', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('InsertRangeMoveDownConfirm handles filtered rows, merge confirm and direct execute', async () => {
const executeCommand = vi.fn(async () => true);
const confirm = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const locale = { t: (key: string) => key };
const rowSelection = { startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 } as IRange;
const filteredWorksheet = createWorksheet({ filteredRows: [2] });
mockSheetCommandTarget(filteredWorksheet);
const accessorFiltered = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(rowSelection)],
[IUniverInstanceService, {}],
]);
expect(await InsertRangeMoveDownConfirmCommand.handler(accessorFiltered)).toBe(false);
expect(executeCommand).not.toHaveBeenCalled();
const mergeWorksheet = createWorksheet({
merges: [{ startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 } as IRange],
});
mockSheetCommandTarget(mergeWorksheet);
const accessorMerge = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(rowSelection)],
[IUniverInstanceService, {}],
]);
expect(await InsertRangeMoveDownConfirmCommand.handler(accessorMerge)).toBe(true);
expect(await InsertRangeMoveDownConfirmCommand.handler(accessorMerge)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(InsertRangeMoveDownCommand.id);
const noMergeWorksheet = createWorksheet();
mockSheetCommandTarget(noMergeWorksheet);
expect(await InsertRangeMoveDownConfirmCommand.handler(accessorMerge)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(InsertRangeMoveDownCommand.id);
});
it('InsertRangeMoveRightConfirm executes directly and handles merge confirm', async () => {
const executeCommand = vi.fn(async () => true);
const confirm = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const locale = { t: (key: string) => key };
const range = { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 } as IRange;
const accessor = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(range)],
[IUniverInstanceService, {}],
]);
mockSheetCommandTarget(createWorksheet());
expect(await InsertRangeMoveRightConfirmCommand.handler(accessor)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(InsertRangeMoveRightCommand.id);
mockSheetCommandTarget(createWorksheet({
merges: [{ startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 } as IRange],
}));
expect(await InsertRangeMoveRightConfirmCommand.handler(accessor)).toBe(true);
expect(await InsertRangeMoveRightConfirmCommand.handler(accessor)).toBe(true);
});
it('DeleteRangeMoveUpConfirm handles filter/merge/no-merge branches', async () => {
const executeCommand = vi.fn(async () => true);
const confirm = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const locale = { t: (key: string) => key };
const range = { startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 } as IRange;
const accessor = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(range)],
[IUniverInstanceService, {}],
]);
mockSheetCommandTarget(createWorksheet({ filteredRows: [2] }));
expect(await DeleteRangeMoveUpConfirmCommand.handler(accessor)).toBe(false);
mockSheetCommandTarget(createWorksheet({
merges: [{ startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 } as IRange],
}));
expect(await DeleteRangeMoveUpConfirmCommand.handler(accessor)).toBe(true);
expect(await DeleteRangeMoveUpConfirmCommand.handler(accessor)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(DeleteRangeMoveUpCommand.id);
mockSheetCommandTarget(createWorksheet());
expect(await DeleteRangeMoveUpConfirmCommand.handler(accessor)).toBe(true);
});
it('DeleteRangeMoveLeftConfirm handles merge/no-merge and guard branches', async () => {
const executeCommand = vi.fn(async () => true);
const confirm = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const locale = { t: (key: string) => key };
const range = { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 } as IRange;
const accessor = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(range)],
[IUniverInstanceService, {}],
]);
mockSheetCommandTarget(createWorksheet({
merges: [{ startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 } as IRange],
}));
expect(await DeleteRangeMoveLeftConfirmCommand.handler(accessor)).toBe(true);
expect(await DeleteRangeMoveLeftConfirmCommand.handler(accessor)).toBe(true);
mockSheetCommandTarget(createWorksheet());
expect(await DeleteRangeMoveLeftConfirmCommand.handler(accessor)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(DeleteRangeMoveLeftCommand.id);
const noSelectionAccessor = createAccessor([
[IConfirmService, { confirm }],
[ICommandService, { executeCommand }],
[LocaleService, locale],
[SheetsSelectionsService, createSelectionService(null)],
[IUniverInstanceService, {}],
]);
expect(await DeleteRangeMoveLeftConfirmCommand.handler(noSelectionAccessor)).toBe(false);
vi.spyOn(sheets, 'getSheetCommandTarget').mockReturnValue(null);
expect(await DeleteRangeMoveLeftConfirmCommand.handler(accessor)).toBe(false);
});
});
describe('clipboard command branches', () => {
it.each(['copy', 'cut'] as const)('uses the internal %s cache without system clipboard support and restores history', async (mode) => {
const boundary: IClipboardInterfaceService = {
supportClipboard: false,
write: vi.fn(async () => {}),
writeText: vi.fn(async () => {}),
readText: vi.fn(async () => ''),
read: vi.fn(async () => []),
};
const { univer, get, sheet } = clipboardTestBed(undefined, undefined, boundary);
try {
const accessor = get(Injector);
const commands = get(ICommandService);
for (const command of [
SetRangeValuesMutation,
SetSelectionsOperation,
SetWorksheetActiveOperation,
SetWorksheetRowAutoHeightMutation,
SetWorksheetRowHeightMutation,
SetWorksheetColWidthMutation,
AddWorksheetMergeMutation,
RemoveWorksheetMergeMutation,
MoveRangeMutation,
]) {
commands.registerCommand(command);
}
const select = (column: number) => get(SheetsSelectionsService).setSelections('test', 'sheet1', [{
range: { startRow: 0, endRow: 0, startColumn: column, endColumn: column },
primary: null,
style: null,
}]);
select(0);
expect(await SheetPasteCommand.handler(accessor, { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE })).toBe(false);
commands.syncExecuteCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: { 0: { 0: { v: 'cached-value', s: { bl: 1 } } } },
});
const copyCommand = mode === 'copy' ? SheetCopyCommand : SheetCutCommand;
expect(await copyCommand.handler(accessor)).toBe(true);
select(1);
expect(await SheetPasteCommand.handler(accessor, { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE })).toBe(true);
const worksheet = sheet.getSheetBySheetId('sheet1')!;
expect(worksheet.getCell(0, 1)?.v).toBe('cached-value');
expect(worksheet.getCellStyle(0, 1)?.bl).toBe(1);
expect(worksheet.getCell(0, 0)?.v).toBe(mode === 'copy' ? 'cached-value' : undefined);
if (mode === 'copy') {
expect(await SheetOptionalPasteCommand.handler(accessor, { type: 'SPECIAL_PASTE_VALUE' })).toBe(true);
expect(worksheet.getCell(0, 1)?.v).toBe('cached-value');
expect(worksheet.getCellStyle(0, 1)?.bl).not.toBe(1);
}
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
expect(worksheet.getCell(0, 0)?.v).toBe('cached-value');
expect(worksheet.getCell(0, 1)?.v).toBeUndefined();
expect(await commands.executeCommand(RedoCommand.id)).toBe(true);
expect(worksheet.getCell(0, 1)?.v).toBe('cached-value');
expect(boundary.read).not.toHaveBeenCalled();
} finally {
univer.dispose();
}
});
it('keeps menu paste data and history on the selection captured before the system read', async () => {
let releaseRead = () => {};
const readGate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const boundary: IClipboardInterfaceService = {
supportClipboard: true,
write: vi.fn(async () => {}),
writeText: vi.fn(async () => {}),
readText: async () => 'system-value',
read: async () => {
await readGate;
return [{
presentationStyle: 'unspecified',
types: ['text/plain'],
getType: async () => new Blob(['system-value'], { type: 'text/plain' }),
}];
},
};
const { univer, get, sheet } = clipboardTestBed(undefined, undefined, boundary);
try {
const accessor = get(Injector);
const commands = get(ICommandService);
for (const command of [SetRangeValuesMutation, SetSelectionsOperation, SetWorksheetActiveOperation, SetWorksheetRowAutoHeightMutation]) {
commands.registerCommand(command);
}
const select = (column: number) => get(SheetsSelectionsService).setSelections('test', 'sheet1', [{
range: { startRow: 0, endRow: 0, startColumn: column, endColumn: column },
primary: null,
style: null,
}]);
select(0);
expect(await SheetPasteShortKeyCommand.handler(accessor, { textContent: 'source-value' })).toBe(true);
expect(await SheetCopyCommand.handler(accessor)).toBe(true);
expect(boundary.write).toHaveBeenCalled();
expect(await SheetCutCommand.handler(accessor)).toBe(true);
select(1);
const pendingPaste = SheetPasteCommand.handler(accessor, { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE });
select(2);
releaseRead();
expect(await pendingPaste).toBe(true);
const worksheet = sheet.getSheetBySheetId('sheet1')!;
expect(worksheet.getCell(0, 1)?.v).toBe('system-value');
expect(worksheet.getCell(0, 0)?.v).toBe('source-value');
expect(worksheet.getCell(0, 2)?.v).toBeUndefined();
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
expect(worksheet.getCell(0, 1)?.v).toBeUndefined();
} finally {
releaseRead();
univer.dispose();
}
});
it.each([
{ name: 'cell value', PermissionPoint: WorksheetSetCellValuePermission },
{ name: 'cell style', PermissionPoint: WorksheetSetCellStylePermission },
])('rejects menu paste when $name permission is revoked during the system read', async ({ PermissionPoint }) => {
let releaseRead = () => {};
const readGate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const getType = vi.fn(async () => new Blob(['forbidden'], { type: 'text/plain' }));
const boundary: IClipboardInterfaceService = {
supportClipboard: true,
write: vi.fn(async () => {}),
writeText: vi.fn(async () => {}),
readText: vi.fn(async () => ''),
read: vi.fn(async () => {
await readGate;
return [{ presentationStyle: 'unspecified' as const, types: ['text/plain'], getType }];
}),
};
const { univer, get, sheet } = clipboardTestBed(undefined, undefined, boundary);
try {
get(SheetsSelectionsService).setSelections('test', 'sheet1', [{
range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
primary: null,
style: null,
}]);
const permissions = get(IPermissionService);
const point = new PermissionPoint('test', 'sheet1');
if (!permissions.getPermissionPoint(point.id)) {
permissions.addPermissionPoint(point);
}
permissions.updatePermissionPoint(point.id, true);
const before = JSON.stringify(sheet.getSnapshot());
const history = get(IUndoRedoService);
const beforeHistory = history.getUndoRedoStatus('test');
const pendingPaste = SheetPasteCommand.handler(get(Injector), { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE });
expect(boundary.read).toHaveBeenCalledOnce();
permissions.updatePermissionPoint(point.id, false);
releaseRead();
await expect(pendingPaste).rejects.toThrow('have no permission');
expect(getType).not.toHaveBeenCalled();
expect(JSON.stringify(sheet.getSnapshot())).toBe(before);
expect(history.getUndoRedoStatus('test')).toEqual(beforeHistory);
} finally {
releaseRead();
univer.dispose();
}
});
it('checks real clipboard permissions without calling the system boundary or changing cells', async () => {
const boundary: IClipboardInterfaceService = {
supportClipboard: true,
write: vi.fn(async () => {}),
writeText: vi.fn(async () => {}),
readText: vi.fn(async () => ''),
read: vi.fn(async () => []),
};
const { univer, get, sheet } = clipboardTestBed(undefined, undefined, boundary);
try {
const accessor = get(Injector);
get(ISheetClipboardService);
get(SheetsSelectionsService).setSelections('test', 'sheet1', [{
range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
primary: null,
style: null,
}]);
const permissions = get(IPermissionService);
for (const point of [new WorkbookCopyPermission('test'), new WorkbookEditablePermission('test')]) {
if (!permissions.getPermissionPoint(point.id)) {
permissions.addPermissionPoint(point);
}
permissions.updatePermissionPoint(point.id, false);
}
const before = JSON.stringify(sheet.getSnapshot());
await expect(SheetCopyCommand.handler(accessor)).rejects.toThrow('have no permission');
await expect(SheetCutCommand.handler(accessor)).rejects.toThrow('have no permission');
await expect(SheetPasteCommand.handler(accessor, { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE })).rejects.toThrow('have no permission');
await expect(SheetPasteShortKeyCommand.handler(accessor, { textContent: 'forbidden' })).rejects.toThrow('have no permission');
expect(boundary.read).not.toHaveBeenCalled();
expect(boundary.write).not.toHaveBeenCalled();
expect(JSON.stringify(sheet.getSnapshot())).toBe(before);
} finally {
univer.dispose();
}
});
it('routes special paste commands to SheetPasteCommand', async () => {
const executeCommand = vi.fn(async () => true);
const accessor = createAccessor([
[ICommandService, { executeCommand }],
]);
expect(await SheetPasteValueCommand.handler(accessor)).toBe(true);
expect(await SheetPasteFormatCommand.handler(accessor)).toBe(true);
expect(await SheetPasteColWidthCommand.handler(accessor)).toBe(true);
expect(await SheetPasteBesidesBorderCommand.handler(accessor)).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
});
expect(executeCommand).toHaveBeenCalledWith(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
});
expect(executeCommand).toHaveBeenCalledWith(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
});
expect(executeCommand).toHaveBeenCalledWith(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_BESIDES_BORDER,
});
});
});

View File

@@ -0,0 +1,182 @@
/**
* 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, IWorkbookData, Workbook } from '@univerjs/core';
import type { IRenderContext } from '@univerjs/engine-render';
import {
BooleanNumber,
CellValueType,
DisposableCollection,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { LexerTreeBuilder } from '@univerjs/engine-formula';
import {
BorderStyleManagerService,
IRefSelectionsService,
RangeProtectionRuleModel,
SheetInterceptorService,
SheetsSelectionsService,
WorkbookPermissionService,
WorksheetPermissionService,
WorksheetProtectionPointModel,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import { BehaviorSubject } from 'rxjs';
import enUS from '../../../locale/en-US';
import { ISheetSelectionRenderService } from '../../../services/selection/base-selection-render.service';
import { SheetSelectionRenderService } from '../../../services/selection/selection-render.service';
const getTestWorkbookDataDemo = (): IWorkbookData => {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'A2',
},
},
// 20 test merge cell, do not change value here
20: {
1: {
v: 2,
t: CellValueType.NUMBER,
s: {
n: {
pattern: '0%',
},
},
},
},
},
columnData: {
1: {
hd: BooleanNumber.FALSE,
},
},
rowData: {
1: {
hd: BooleanNumber.FALSE,
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
};
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: Workbook;
}
export function createCommandTestBed(
workbookData?: IWorkbookData,
dependencies?: Dependency[],
renderDependencies?: Dependency[]
): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
injector.add([SheetsSelectionsService]);
injector.add([BorderStyleManagerService]);
injector.add([SheetInterceptorService]);
injector.add([LexerTreeBuilder]);
injector.add([WorksheetPermissionService]);
injector.add([WorksheetProtectionPointModel]);
injector.add([WorkbookPermissionService]);
injector.add([WorksheetProtectionRuleModel]);
injector.add([RangeProtectionRuleModel]);
injector.add([IRefSelectionsService, { useClass: SheetsSelectionsService }]);
dependencies?.forEach((d) => injector.add(d));
}
}
univer.registerPlugin(TestPlugin);
const snapshot = Tools.deepClone(workbookData || getTestWorkbookDataDemo());
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, snapshot);
if (!dependencies || !dependencies.find((d) => d[0] === ISheetSelectionRenderService)) {
const context: IRenderContext<Workbook> = {
unitId: sheet.getUnitId(),
unit: sheet,
type: UniverInstanceType.UNIVER_SHEET,
engine: new DisposableCollection() as any,
scene: new DisposableCollection() as any,
mainComponent: null as any,
components: new Map(),
isMainScene: true,
activated$: new BehaviorSubject(true),
activate: () => { },
deactivate: () => { },
};
injector.add([ISheetSelectionRenderService, { useFactory: () => injector.createInstance(SheetSelectionRenderService, context) }]);
}
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.VERBOSE); // change this to `LogLevel.VERBOSE` to debug tests via logs
const localeService = injector.get(LocaleService);
localeService.load({ enUS });
return {
univer,
get: injector.get.bind(injector),
sheet,
};
}

View File

@@ -0,0 +1,237 @@
/**
* 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 { IWorkbookData } from '@univerjs/core';
import { Disposable, DisposableCollection, ICommandService, IUniverInstanceService, LocaleType, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { CancelFrozenCommand, SetFrozenMutation, SetSelectionsOperation, SheetSkeletonService } from '@univerjs/sheets';
import { BehaviorSubject } from 'rxjs';
import { SheetScrollManagerService } from '../../../services/scroll-manager.service';
import { SelectAllService } from '../../../services/select-all/select-all.service';
import { SheetSkeletonManagerService } from '../../../services/sheet-skeleton-manager.service';
import { ShortcutExperienceService } from '../../../services/shortcut-experience.service';
import {
SetColumnFrozenCommand,
SetFirstColumnFrozenCommand,
SetFirstRowFrozenCommand,
SetRowFrozenCommand,
SetSelectionFrozenCommand,
} from '../set-frozen.command';
import { ExpandSelectionCommand, MoveSelectionCommand, SelectAllCommand } from '../set-selection.command';
import { createCommandTestBed } from './create-command-test-bed';
export const SELECTION_WITH_EMPTY_CELLS_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
3: {
v: '',
},
4: {
v: '',
},
5: {
v: 'F1',
},
6: {
v: '', // merged to F1
},
7: {
v: '',
},
8: {
v: '',
},
},
},
mergeData: [{ startRow: 0, startColumn: 5, endColumn: 6, endRow: 1 }],
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export const SELECTION_WITH_MERGED_CELLS_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
},
1: {
0: {
v: 'A2',
},
1: {
v: '', // merged to B1
},
2: {
v: 'C2',
},
},
},
mergeData: [{ startRow: 0, startColumn: 1, endRow: 1, endColumn: 1 }],
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export const SIMPLE_SELECTION_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'A2',
},
},
1: {
0: {
v: 'B1',
},
1: {
v: 'B2',
},
},
},
mergeData: [{ startRow: 3, startColumn: 4, endRow: 4, endColumn: 4 }],
rowCount: 20,
columnCount: 20,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export function createSelectionCommandTestBed(workbookData?: IWorkbookData) {
const { univer, get, sheet } = createCommandTestBed(workbookData || SIMPLE_SELECTION_WORKBOOK_DATA, [
[ShortcutExperienceService],
[SelectAllService],
[IRenderManagerService, { useClass: RenderManagerService }],
]);
const commandService = get(ICommandService);
[MoveSelectionCommand, ExpandSelectionCommand, SelectAllCommand, SetSelectionsOperation].forEach((c) => {
commandService.registerCommand(c);
});
return {
univer,
get,
sheet,
};
}
export function createFrozenCommandTestBed(workbookData?: IWorkbookData) {
const { univer, get, sheet } = createCommandTestBed(workbookData || SIMPLE_SELECTION_WORKBOOK_DATA, [
[SheetScrollManagerService],
[ShortcutExperienceService],
[IRenderManagerService, { useClass: RenderManagerService }],
]);
const commandService = get(ICommandService);
[
SetSelectionFrozenCommand,
SetRowFrozenCommand,
SetColumnFrozenCommand,
SetFirstRowFrozenCommand,
SetFirstColumnFrozenCommand,
CancelFrozenCommand,
SetSelectionsOperation,
SetFrozenMutation,
].forEach((c) => {
commandService.registerCommand(c);
});
const unitId = sheet.getUnitId();
const injector = univer.__getInjector();
injector.add([SheetSkeletonService]);
// NOTE: this is a hack. Please refer to ./services/clipboard/__tests__/clipboard-test-bed.ts
const mockSheetSkService = new SheetSkeletonService(injector, get(IUniverInstanceService));
const fakeSheetSkeletonManagerService = new SheetSkeletonManagerService({
unit: sheet,
unitId,
type: UniverInstanceType.UNIVER_SHEET,
engine: null as any,
scene: null as any,
mainComponent: null as any,
components: null as any,
isMainScene: true,
activated$: new BehaviorSubject(true),
activate: () => {},
deactivate: () => {},
}, injector.get(SheetSkeletonService));
injector.add([SheetSkeletonManagerService, { useValue: fakeSheetSkeletonManagerService }]);
injector.get(IRenderManagerService).addRender(unitId, {
unitId,
type: UniverInstanceType.UNIVER_SHEET,
engine: new Disposable() as any,
scene: new DisposableCollection() as any,
mainComponent: null as any,
components: new Map(),
isMainScene: true,
with: injector.get.bind(injector),
activated$: new BehaviorSubject(true),
activate: () => {},
deactivate: () => {},
isDisposed: () => false,
});
return {
univer,
get,
sheet,
};
}

View File

@@ -0,0 +1,170 @@
/**
* 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 { IWorkbookData } from '@univerjs/core';
import { ICommandService, LocaleType } from '@univerjs/core';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { SetBorderCommand, SetBorderPositionCommand, SetRangeValuesMutation, SetSelectionsOperation } from '@univerjs/sheets';
import { SelectAllService } from '../../../services/select-all/select-all.service';
import { ShortcutExperienceService } from '../../../services/shortcut-experience.service';
import { ExpandSelectionCommand, MoveSelectionCommand, SelectAllCommand } from '../set-selection.command';
import { createCommandTestBed } from './create-command-test-bed';
export const SELECTION_WITH_EMPTY_CELLS_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
3: {
v: '',
},
4: {
v: '',
},
5: {
v: 'F1',
},
6: {
v: '', // merged to F1
},
7: {
v: '',
},
8: {
v: '',
},
},
},
mergeData: [{ startRow: 0, startColumn: 5, endColumn: 6, endRow: 1 }],
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
/**
* A1 | B1 | C1
* ---| |----
* A2 | | C2
*
*/
export const MERGED_CELLS_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
},
1: {
0: {
v: 'A2',
},
1: {
v: '', // merged to B1
},
2: {
v: 'C2',
},
},
},
mergeData: [{ startRow: 0, startColumn: 1, endRow: 1, endColumn: 1 }],
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export const SIMPLE_SELECTION_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'A2',
},
},
1: {
0: {
v: 'B1',
},
1: {
v: 'B2',
},
},
},
mergeData: [{ startRow: 3, startColumn: 4, endRow: 4, endColumn: 4 }],
rowCount: 20,
columnCount: 20,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export function createSelectionCommandTestBed(workbookData?: IWorkbookData) {
const { univer, get, sheet } = createCommandTestBed(workbookData || SIMPLE_SELECTION_WORKBOOK_DATA, [
[ShortcutExperienceService],
[SelectAllService],
[IRenderManagerService, { useClass: RenderManagerService }],
]);
const commandService = get(ICommandService);
[MoveSelectionCommand, ExpandSelectionCommand, SelectAllCommand, SetBorderPositionCommand, SetBorderCommand, SetRangeValuesMutation, SetSelectionsOperation].forEach((c) => {
commandService.registerCommand(c);
});
return {
univer,
get,
sheet,
};
}

View File

@@ -0,0 +1,185 @@
/**
* 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 { Injector, IRange, Univer, Workbook } from '@univerjs/core';
import { ICommandService, IConfirmService, IUniverInstanceService, LocaleService, RANGE_TYPE, TestConfirmService, UniverInstanceType } from '@univerjs/core';
import {
AddWorksheetMergeAllCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeHorizontalCommand,
AddWorksheetMergeMutation,
AddWorksheetMergeVerticalCommand,
RemoveWorksheetMergeCommand,
RemoveWorksheetMergeMutation,
SetColHiddenCommand,
SetColHiddenMutation,
SetRangeValuesMutation,
SetRowHiddenCommand,
SetRowHiddenMutation,
SetSelectionsOperation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HideColConfirmCommand, HideRowConfirmCommand } from '../hide-row-col-confirm.command';
import { createCommandTestBed } from './create-command-test-bed';
describe('Test hide row col confirm commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
beforeEach(() => {
const testBed = createCommandTestBed(undefined, [
[IConfirmService, { useClass: TestConfirmService }],
]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(AddWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeAllCommand);
commandService.registerCommand(AddWorksheetMergeVerticalCommand);
commandService.registerCommand(AddWorksheetMergeHorizontalCommand);
commandService.registerCommand(RemoveWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeMutation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
commandService.registerCommand(HideColConfirmCommand);
commandService.registerCommand(HideRowConfirmCommand);
commandService.registerCommand(SetRowHiddenCommand);
commandService.registerCommand(SetRowHiddenMutation);
commandService.registerCommand(SetColHiddenCommand);
commandService.registerCommand(SetColHiddenMutation);
commandService.registerCommand(SetSelectionsOperation);
get(LocaleService).load({});
});
afterEach(() => {
univer.dispose();
});
describe('Hide row', () => {
it('Will apply when select some rows', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 1, startColumn: Number.NaN, endRow: 1, endColumn: Number.NaN, rangeType: RANGE_TYPE.ROW },
primary: null,
style: null,
},
]);
function getHiddenRows(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getRowManager()
.getHiddenRows();
}
expect(getHiddenRows()).toStrictEqual([]);
expect(await commandService.executeCommand(HideRowConfirmCommand.id)).toBeTruthy();
expect(getHiddenRows()).toStrictEqual([
{
endColumn: 0,
endRow: 1,
rangeType: RANGE_TYPE.ROW,
startColumn: 0,
startRow: 1,
},
]);
});
it('Will not apply when select all rows', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: Number.NaN, endRow: 999, endColumn: Number.NaN, rangeType: RANGE_TYPE.ROW },
primary: null,
style: null,
},
]);
function getHiddenRows(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getRowManager()
.getHiddenRows();
}
expect(getHiddenRows()).toStrictEqual([]);
expect(await commandService.executeCommand(HideRowConfirmCommand.id)).toBeFalsy();
});
});
describe('Hide col', () => {
it('Will apply when select some cols', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: Number.NaN, startColumn: 1, endRow: Number.NaN, endColumn: 1, rangeType: RANGE_TYPE.COLUMN },
primary: null,
style: null,
},
]);
function getHiddenCols(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getColumnManager()
.getHiddenCols();
}
expect(getHiddenCols()).toStrictEqual([]);
expect(await commandService.executeCommand(HideColConfirmCommand.id)).toBeTruthy();
expect(getHiddenCols()).toStrictEqual([
{
endColumn: 1,
endRow: 0,
rangeType: RANGE_TYPE.COLUMN,
startColumn: 1,
startRow: 0,
},
]);
});
it('Will not apply when select all cols', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: Number.NaN, startColumn: 0, endRow: Number.NaN, endColumn: 19, rangeType: RANGE_TYPE.COLUMN },
primary: null,
style: null,
},
]);
function getHiddenCols(): IRange[] | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getColumnManager()
.getHiddenCols();
}
expect(getHiddenCols()).toStrictEqual([]);
expect(await commandService.executeCommand(HideColConfirmCommand.id)).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,121 @@
/**
* 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 { Injector, IWorkbookData, Univer, Workbook } from '@univerjs/core';
import {
ICommandService,
IConfirmService,
LocaleService,
LocaleType,
TestConfirmService,
} from '@univerjs/core';
import {
MoveRangeCommand,
MoveRangeMutation,
SetSelectionsOperation,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MoveRangeConfirmCommand } from '../move-range-confirm.command';
import { createCommandTestBed } from './create-command-test-bed';
const FROM_RANGE = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 };
const TO_RANGE = { startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 };
describe('MoveRangeConfirmCommand', () => {
let univer: Univer;
let get: Injector['get'];
let workbook: Workbook;
let commandService: ICommandService;
let confirmService: TestConfirmService<unknown>;
beforeEach(() => {
const testBed = createCommandTestBed(createWorkbookData(), [
[IConfirmService, { useClass: TestConfirmService }],
]);
univer = testBed.univer;
get = testBed.get;
workbook = testBed.sheet;
commandService = get(ICommandService);
confirmService = get(IConfirmService) as TestConfirmService<unknown>;
get(LocaleService).setLocale(LocaleType.EN_US);
[MoveRangeCommand, MoveRangeConfirmCommand, MoveRangeMutation, SetSelectionsOperation].forEach((command) => {
commandService.registerCommand(command);
});
});
afterEach(() => {
univer.dispose();
});
it('does not overwrite target content when confirmation is canceled', async () => {
const confirm = vi.spyOn(confirmService, 'confirm').mockResolvedValue(false);
expect(await commandService.executeCommand(MoveRangeConfirmCommand.id, {
fromRange: FROM_RANGE,
toRange: TO_RANGE,
})).toBeFalsy();
expect(confirm).toHaveBeenCalledWith({
id: MoveRangeConfirmCommand.id,
title: { title: 'Warning' },
children: {
title: 'There is already data in the target area. This action will overwrite the existing data. Do you want to continue?',
},
cancelText: 'Cancel',
confirmText: 'OK',
});
expect(getCellValue(0, 0)).toBe('source');
expect(getCellValue(0, 1)).toBe('target');
});
it('overwrites target content after confirmation', async () => {
const confirm = vi.spyOn(confirmService, 'confirm').mockResolvedValue(true);
expect(await commandService.executeCommand(MoveRangeConfirmCommand.id, {
fromRange: FROM_RANGE,
toRange: TO_RANGE,
})).toBeTruthy();
expect(confirm).toHaveBeenCalledTimes(1);
expect(getCellValue(0, 0)).toBeUndefined();
expect(getCellValue(0, 1)).toBe('source');
});
function getCellValue(row: number, column: number) {
return workbook.getActiveSheet().getCellRaw(row, column)?.v;
}
});
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: { v: 'source' },
1: { v: 'target' },
},
},
},
},
};
}

View File

@@ -0,0 +1,180 @@
/**
* 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 { Injector, Univer, Workbook } from '@univerjs/core';
import { ICommandService, IConfirmService, IUniverInstanceService, LocaleService, RANGE_TYPE, TestConfirmService, UniverInstanceType } from '@univerjs/core';
import {
AddWorksheetMergeAllCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeHorizontalCommand,
AddWorksheetMergeMutation,
AddWorksheetMergeVerticalCommand,
InsertColByRangeCommand,
InsertRowByRangeCommand,
RemoveColByRangeCommand,
RemoveColCommand,
RemoveColMutation,
RemoveRowByRangeCommand,
RemoveRowCommand,
RemoveRowMutation,
RemoveWorksheetMergeCommand,
RemoveWorksheetMergeMutation,
SetRangeValuesMutation,
SetSelectionsOperation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RemoveColConfirmCommand, RemoveRowConfirmCommand } from '../remove-row-col-confirm.command';
import { createCommandTestBed } from './create-command-test-bed';
describe('Test remove row col confirm commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
beforeEach(() => {
const testBed = createCommandTestBed(undefined, [
[IConfirmService, { useClass: TestConfirmService }],
]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(AddWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeAllCommand);
commandService.registerCommand(AddWorksheetMergeVerticalCommand);
commandService.registerCommand(AddWorksheetMergeHorizontalCommand);
commandService.registerCommand(RemoveWorksheetMergeCommand);
commandService.registerCommand(AddWorksheetMergeMutation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
commandService.registerCommand(RemoveRowConfirmCommand);
commandService.registerCommand(RemoveRowCommand);
commandService.registerCommand(RemoveRowMutation);
commandService.registerCommand(RemoveColConfirmCommand);
commandService.registerCommand(RemoveColCommand);
commandService.registerCommand(RemoveColMutation);
commandService.registerCommand(SetSelectionsOperation);
[
RemoveColByRangeCommand,
RemoveRowByRangeCommand,
InsertRowByRangeCommand,
InsertColByRangeCommand,
].forEach((command) => {
commandService.registerCommand(command);
});
get(LocaleService).load({});
});
afterEach(() => {
univer.dispose();
});
describe('Remove row', () => {
it('Will apply when select some rows', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 1, startColumn: Number.NaN, endRow: 1, endColumn: Number.NaN, rangeType: RANGE_TYPE.ROW },
primary: null,
style: null,
},
]);
function getRowCount(): number | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getRowCount();
}
expect(getRowCount()).toBe(1000);
expect(await commandService.executeCommand(RemoveRowConfirmCommand.id)).toBeTruthy();
expect(getRowCount()).toBe(999);
});
it('Will not apply when select all rows', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: Number.NaN, endRow: 999, endColumn: Number.NaN, rangeType: RANGE_TYPE.ROW },
primary: null,
style: null,
},
]);
function getRowCount(): number | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getRowCount();
}
expect(getRowCount()).toBe(1000);
expect(await commandService.executeCommand(RemoveRowConfirmCommand.id)).toBeFalsy();
expect(getRowCount()).toBe(1000);
});
});
describe('Remove col', () => {
it('Will apply when select some cols', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: Number.NaN, startColumn: 1, endRow: Number.NaN, endColumn: 1, rangeType: RANGE_TYPE.COLUMN },
primary: null,
style: null,
},
]);
function getColumnCount(): number | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getColumnCount();
}
expect(getColumnCount()).toBe(20);
expect(await commandService.executeCommand(RemoveColConfirmCommand.id)).toBeTruthy();
expect(getColumnCount()).toBe(19);
});
it('Will not apply when select all cols', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: Number.NaN, startColumn: 0, endRow: Number.NaN, endColumn: 19, rangeType: RANGE_TYPE.COLUMN },
primary: null,
style: null,
},
]);
function getColumnCount(): number | undefined {
return get(IUniverInstanceService)
.getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)
?.getSheetBySheetId('sheet1')
?.getColumnCount();
}
expect(getColumnCount()).toBe(20);
expect(await commandService.executeCommand(RemoveColConfirmCommand.id)).toBeFalsy();
expect(getColumnCount()).toBe(20);
});
});
});

View File

@@ -0,0 +1,214 @@
/**
* 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 { Direction, RANGE_TYPE } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import {
checkIfShrink,
expandToNextCell,
expandToWholeSheet,
findNextGapRange,
findNextRange,
getMergeableSelectionsByType,
getStartRange,
isAllColumnsCovered,
isAllRowsCovered,
MergeType,
shrinkToNextCell,
} from '../utils/selection-utils';
function createMatrix(hasValue = false) {
return {
forValue: (cb: (...args: any[]) => void) => {
if (hasValue) {
cb(0, 0, { v: 1 });
}
return {
getLength: () => (hasValue ? 1 : 0),
forValue: (cb2: (...args: any[]) => void) => {
if (hasValue) {
cb2(0, 0, { v: 1 });
}
},
};
},
};
}
function createWorksheet() {
return {
getRowCount: () => 6,
getColumnCount: () => 6,
getRowVisible: (row: number) => row !== 1,
getColVisible: (col: number) => col !== 1,
getMatrixWithMergedCells: (_sr: number, _sc: number, _er: number, _ec: number) => createMatrix(false),
};
}
describe('Test selection utils', () => {
it('Function isAllRowsCovered', () => {
// single range
expect(
isAllRowsCovered(
[{ startRow: 0, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN }],
[{ startRow: 0, endRow: 1, startColumn: Number.NaN, endColumn: Number.NaN }]
)
).toBeFalsy();
expect(
isAllRowsCovered(
[{ startRow: 0, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN }],
[{ startRow: 0, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN }]
)
).toBeTruthy();
// multiple range
expect(
isAllRowsCovered(
[
{ startRow: 0, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN },
{ startRow: 1000, endRow: 1999, startColumn: Number.NaN, endColumn: Number.NaN },
],
[
{ startRow: 100, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN },
{ startRow: 1000, endRow: 1500, startColumn: Number.NaN, endColumn: Number.NaN },
]
)
).toBeFalsy();
expect(
isAllRowsCovered(
[
{ startRow: 0, endRow: 999, startColumn: Number.NaN, endColumn: Number.NaN },
{ startRow: 1000, endRow: 1999, startColumn: Number.NaN, endColumn: Number.NaN },
],
[
{ startRow: 0, endRow: 1500, startColumn: Number.NaN, endColumn: Number.NaN },
{ startRow: 1000, endRow: 1999, startColumn: Number.NaN, endColumn: Number.NaN },
]
)
).toBeTruthy();
});
it('Function isAllColumnsCovered', () => {
// single range
expect(
isAllColumnsCovered(
[{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 999 }],
[{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 1 }]
)
).toBeFalsy();
expect(
isAllColumnsCovered(
[{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 999 }],
[{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 999 }]
)
).toBeTruthy();
// multiple range
expect(
isAllColumnsCovered(
[
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 999 },
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 1000, endColumn: 1999 },
],
[
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 100, endColumn: 999 },
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 1000, endColumn: 1500 },
]
)
).toBeFalsy();
expect(
isAllColumnsCovered(
[
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 999 },
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 1000, endColumn: 1999 },
],
[
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 0, endColumn: 1500 },
{ startRow: Number.NaN, endRow: Number.NaN, startColumn: 1000, endColumn: 1999 },
]
)
).toBeTruthy();
});
it('findNextRange should move with hidden rows/cols and wrap in boundary', () => {
const worksheet = createWorksheet();
const start = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 };
expect(findNextRange(start as any, Direction.DOWN, worksheet as any)).toEqual({ ...start, startRow: 2, endRow: 2 });
expect(findNextRange(start as any, Direction.RIGHT, worksheet as any)).toEqual({ ...start, startColumn: 2, endColumn: 2 });
expect(findNextRange(start as any, Direction.LEFT, worksheet as any)).toEqual({ startRow: 5, endRow: 5, startColumn: 5, endColumn: 5 });
});
it('findNextGapRange should continue to gap boundary on empty ranges', () => {
const worksheet = createWorksheet();
const start = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 };
expect(findNextGapRange(start as any, Direction.RIGHT, worksheet as any)).toEqual({ startRow: 0, endRow: 0, startColumn: 5, endColumn: 5 });
expect(findNextGapRange(start as any, Direction.DOWN, worksheet as any)).toEqual({ startRow: 5, endRow: 5, startColumn: 0, endColumn: 0 });
});
it('expand/shrink helpers should return expected ranges', () => {
const worksheet = createWorksheet();
const start = { startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 };
expect(expandToNextCell(start as any, Direction.RIGHT, worksheet as any)).toEqual(expect.objectContaining({
startRow: 2,
endRow: 2,
startColumn: 2,
endColumn: 3,
}));
expect(shrinkToNextCell({ startRow: 2, endRow: 2, startColumn: 2, endColumn: 4 } as any, Direction.RIGHT, worksheet as any)).toEqual(expect.objectContaining({
startRow: 2,
endRow: 2,
startColumn: 3,
endColumn: 4,
}));
expect(expandToWholeSheet(worksheet as any)).toEqual({
startRow: 0,
startColumn: 0,
endRow: 5,
endColumn: 5,
rangeType: RANGE_TYPE.ALL,
});
});
it('getStartRange and checkIfShrink should follow direction and primary cell', () => {
const range = { startRow: 0, endRow: 3, startColumn: 0, endColumn: 3 };
const primary = { actualRow: 2, actualColumn: 1, startRow: 2, endRow: 2, startColumn: 1, endColumn: 1 };
const worksheet = createWorksheet();
expect(getStartRange(range as any, primary as any, Direction.DOWN)).toEqual({ ...range, startColumn: 1, endColumn: 1 });
expect(getStartRange(range as any, primary as any, Direction.RIGHT)).toEqual({ ...range, startRow: 2, endRow: 2 });
expect(checkIfShrink({ range, primary } as any, Direction.DOWN, worksheet as any)).toBe(true);
expect(checkIfShrink({ range, primary: { ...primary, startColumn: 0, endColumn: 0 } } as any, Direction.RIGHT, worksheet as any)).toBe(false);
});
it('getMergeableSelectionsByType should filter single-cell ranges by merge mode', () => {
const selections = [
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 0 },
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 1 },
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 },
];
expect(getMergeableSelectionsByType(MergeType.MergeAll, selections as any)?.length).toBe(3);
expect(getMergeableSelectionsByType(MergeType.MergeVertical, selections as any)?.length).toBe(2);
expect(getMergeableSelectionsByType(MergeType.MergeHorizontal, selections as any)?.length).toBe(2);
expect(getMergeableSelectionsByType(MergeType.MergeAll, null)).toBeNull();
});
});

View File

@@ -0,0 +1,487 @@
/**
* 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 { Injector, Univer, Workbook } from '@univerjs/core';
import type { ISelectionWithCoord, ISetRangeValuesMutationParams } from '@univerjs/sheets';
import {
Disposable,
ICommandService,
IUniverInstanceService,
LocaleType,
ObjectMatrix,
RedoCommand,
set,
ThemeService,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
AddWorksheetMergeMutation,
RemoveWorksheetMergeMutation,
SetRangeValuesCommand,
SetRangeValuesMutation,
SetSelectionsOperation,
} from '@univerjs/sheets';
import { BehaviorSubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FormatPainterController } from '../../../controllers/format-painter/format-painter.controller';
import {
FormatPainterService,
FormatPainterStatus,
IFormatPainterService,
} from '../../../services/format-painter/format-painter.service';
import { IMarkSelectionService } from '../../../services/mark-selection/mark-selection.service';
import { ISheetSelectionRenderService } from '../../../services/selection/base-selection-render.service';
import { SetFormatPainterOperation } from '../../operations/set-format-painter.operation';
import {
ApplyFormatPainterCommand,
SetInfiniteFormatPainterCommand,
SetOnceFormatPainterCommand,
} from '../set-format-painter.command';
import { createCommandTestBed } from './create-command-test-bed';
const TEST_WORKBOOK_DATA = {
id: 'workbook-01',
sheetOrder: ['sheet-0011'],
name: 'UniverSheet Demo',
appVersion: '3.0.0-alpha',
styles: {
yifA1t: {
bl: 1,
it: 1,
ul: {
s: 1,
},
st: {
s: 1,
},
},
M5JbP2: {
bg: {
rgb: '#409f11',
},
cl: {
rgb: '#E30909',
},
ff: 'Microsoft YaHei',
},
},
sheets: {
'sheet-0011': {
type: 0,
id: 'sheet-0011',
name: 'sheet11',
columnData: {
1: {
hd: 0,
},
},
mergeData: [
{
startRow: 1,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
],
status: 1,
cellData: {
0: {
0: {
v: 1,
t: 2,
s: 'yifA1t',
},
1: {
v: 2,
t: 2,
s: 'M5JbP2',
},
2: {
v: 3,
},
3: {
v: 1,
f: '=SUM(A1)',
si: '3e4r5t',
t: 2,
},
},
1: {
0: {
v: 4,
},
2: {
v: 1,
t: 2,
},
3: {
v: 1,
t: 2,
},
},
2: {
0: {
v: 44,
},
2: {
v: 1,
t: 2,
},
3: {
v: 1,
t: 2,
},
},
3: {
0: {
v: 444,
},
2: {
v: 1,
t: 2,
},
3: {
v: 1,
t: 2,
},
},
4: {
2: {
v: 1,
t: 2,
},
},
},
},
},
createdTime: '',
creator: '',
lastModifiedBy: '',
locale: LocaleType.EN_US,
modifiedTime: '',
timeZone: '',
};
class MarkSelectionService extends Disposable implements IMarkSelectionService {
addShape(): string | null {
return null;
}
addShapeWithNoFresh(): string | null {
return null;
}
refreshShapes() { /* TODO document why this method 'refreshShapes' is empty */ }
removeShape(id: string): void { /* TODO document why this method 'removeShape' is empty */ }
removeAllShapes(): void { /* TODO document why this method 'removeAllShapes' is empty */ }
getShapeMap(): Map<string, any> {
return new Map();
}
}
describe('Test format painter rules in controller', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let themeService: ThemeService;
beforeEach(() => {
class SheetSelectionRenderService {
private readonly _selectionMoveEnd$ = new BehaviorSubject<ISelectionWithCoord[]>([]);
readonly selectionMoveEnd$ = this._selectionMoveEnd$.asObservable();
}
const testBed = createCommandTestBed(TEST_WORKBOOK_DATA, [
[IMarkSelectionService, { useClass: MarkSelectionService }],
[IFormatPainterService, { useClass: FormatPainterService }],
[ISheetSelectionRenderService, { useClass: SheetSelectionRenderService }],
[IRenderManagerService, { useClass: RenderManagerService }],
[FormatPainterController],
]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
themeService = get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
get(FormatPainterController);
commandService.registerCommand(SetFormatPainterOperation);
commandService.registerCommand(SetInfiniteFormatPainterCommand);
commandService.registerCommand(SetOnceFormatPainterCommand);
commandService.registerCommand(ApplyFormatPainterCommand);
commandService.registerCommand(SetSelectionsOperation);
commandService.registerCommand(SetRangeValuesCommand);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
commandService.registerCommand(AddWorksheetMergeMutation);
});
afterEach(() => univer?.dispose());
describe('format painter', () => {
it('toggles toolbar commands between active mode and off', async () => {
const formatPainterService = get(IFormatPainterService);
expect(formatPainterService.getStatus()).toBe(FormatPainterStatus.OFF);
expect(await commandService.executeCommand(SetInfiniteFormatPainterCommand.id)).toBeTruthy();
expect(formatPainterService.getStatus()).toBe(FormatPainterStatus.INFINITE);
expect(await commandService.executeCommand(SetInfiniteFormatPainterCommand.id)).toBeTruthy();
expect(formatPainterService.getStatus()).toBe(FormatPainterStatus.OFF);
expect(await commandService.executeCommand(SetOnceFormatPainterCommand.id)).toBeTruthy();
expect(formatPainterService.getStatus()).toBe(FormatPainterStatus.ONCE);
expect(await commandService.executeCommand(SetOnceFormatPainterCommand.id)).toBeTruthy();
expect(formatPainterService.getStatus()).toBe(FormatPainterStatus.OFF);
});
describe('format painter the numbers', async () => {
it('correct situation', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
selections: [
{
range: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
},
],
});
await commandService.executeCommand(SetOnceFormatPainterCommand.id);
await commandService.executeCommand(ApplyFormatPainterCommand.id, {
range: {
startRow: 0,
endRow: 4,
startColumn: 2,
endColumn: 3,
},
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
});
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 2)?.s).toBe('yifA1t');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData().length).toBe(3);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[0].startRow).toBe(1);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[1].startRow).toBe(1);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[2].startRow).toBe(3);
get(IUniverInstanceService).focusUnit('workbook-01');
// undo
await commandService.executeCommand(UndoCommand.id);
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 2)?.s).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 3)?.s).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe(undefined);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData().length).toBe(1);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[0].startRow).toBe(1);
//redo
await commandService.executeCommand(RedoCommand.id);
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(2, 3)?.s).toBe('M5JbP2');
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData().length).toBe(3);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[0].startRow).toBe(1);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[1].startRow).toBe(1);
expect(workbook.getSheetBySheetId('sheet-0011')?.getMergeData()[2].startRow).toBe(3);
await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
selections: [
{
range: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
},
],
});
await commandService.executeCommand(SetOnceFormatPainterCommand.id);
await commandService.executeCommand(ApplyFormatPainterCommand.id, {
range: {
startRow: 10,
endRow: 13,
startColumn: 12,
endColumn: 13,
},
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
});
const mergeData = workbook.getSheetBySheetId('sheet-0011')?.getMergeData();
expect(mergeData?.length).toBe(5);
expect(mergeData?.[3].startRow).toBe(11);
expect(mergeData?.[4].startRow).toBe(13);
});
});
describe('format painter to single cell', () => {
it('changes only styles and preserves target formulas through undo and redo', async () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getSheetBySheetId('sheet-0011')!;
const originalCell = { ...worksheet.getCell(0, 3)! };
get(IUniverInstanceService).focusUnit(workbook.getUnitId());
expect(await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
selections: [
{
range: {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
},
},
],
})).toBeTruthy();
expect(await commandService.executeCommand(SetOnceFormatPainterCommand.id)).toBeTruthy();
const setRangeValuesParams: ISetRangeValuesMutationParams[] = [];
const disposable = commandService.onCommandExecuted((command) => {
if (command.id === SetRangeValuesMutation.id) {
setRangeValuesParams.push(command.params as ISetRangeValuesMutationParams);
}
});
expect(await commandService.executeCommand(ApplyFormatPainterCommand.id, {
range: {
startRow: 0,
endRow: 0,
startColumn: 3,
endColumn: 3,
},
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
})).toBeTruthy();
disposable.dispose();
expect(setRangeValuesParams.length).toBeGreaterThan(0);
for (const params of setRangeValuesParams) {
expect(params.trigger).toBe(ApplyFormatPainterCommand.id);
new ObjectMatrix(params.cellValue).forValue((_row, _column, cell) => {
expect(Object.keys(cell!)).toEqual(['s']);
});
}
expect(worksheet.getCell(0, 3)).toMatchObject({ ...originalCell, s: 'yifA1t' });
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(worksheet.getCell(0, 3)).toMatchObject(originalCell);
expect(worksheet.getCell(0, 3)?.s).toBeUndefined();
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(worksheet.getCell(0, 3)).toMatchObject({ ...originalCell, s: 'yifA1t' });
});
it('will copy whole original styles', async () => {
expect(await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
selections: [
{
range: {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 3,
},
},
],
})).toBeTruthy();
expect(await commandService.executeCommand(SetOnceFormatPainterCommand.id)).toBeTruthy();
expect(await commandService.executeCommand(ApplyFormatPainterCommand.id, {
range: {
startRow: 5,
endRow: 5,
startColumn: 0,
endColumn: 0,
},
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
})).toBeTruthy();
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(5, 0)?.s).toBe('yifA1t');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(5, 1)?.s).toBe('M5JbP2');
});
describe('format painter from non-style cell to styled cell', () => {
it('will clear styles', async () => {
expect(await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
selections: [
{
range: {
startRow: 6,
endRow: 6,
startColumn: 0,
endColumn: 0,
},
},
],
})).toBeTruthy();
expect(await commandService.executeCommand(SetOnceFormatPainterCommand.id)).toBeTruthy();
expect(await commandService.executeCommand(ApplyFormatPainterCommand.id, {
range: {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
},
unitId: 'workbook-01',
subUnitId: 'sheet-0011',
})).toBeTruthy();
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
expect(workbook.getSheetBySheetId('sheet-0011')?.getCell(0, 0)?.s).toBe(undefined);
});
});
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* 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 { IFreeze, Injector, IWorkbookData, Univer, Workbook } from '@univerjs/core';
import type { IScrollStateWithSearchParam } from '../../../services/scroll-manager.service';
import { ICommandService, IUniverInstanceService, RANGE_TYPE, UniverInstanceType } from '@univerjs/core';
import { CancelFrozenCommand, SheetsSelectionsService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetScrollManagerService } from '../../../services/scroll-manager.service';
import {
SetColumnFrozenCommand,
SetFirstColumnFrozenCommand,
SetFirstRowFrozenCommand,
SetRowFrozenCommand,
SetSelectionFrozenCommand,
} from '../set-frozen.command';
import { createFrozenCommandTestBed } from './create-selection-command-test-bed';
describe('Test commands used for change selections', () => {
let univer: Univer | null = null;
let get: Injector['get'];
let commandService: ICommandService;
let selectionManagerService: SheetsSelectionsService;
let scrollManagerService: SheetScrollManagerService;
const searchParam = {
unitId: 'test',
sheetId: 'sheet1',
};
function select(
startRow: number,
startColumn: number,
endRow: number,
endColumn: number,
actualRow: number,
actualColumn: number,
isMerged: boolean,
isMergedMainCell: boolean
) {
selectionManagerService.addSelections([
{
range: { startRow, startColumn, endRow, endColumn, rangeType: RANGE_TYPE.NORMAL },
primary: {
startRow,
startColumn,
endRow,
endColumn,
actualRow,
actualColumn,
isMerged,
isMergedMainCell,
},
style: null,
},
]);
}
const scrollTo = (startRow: number, startColumn: number, offsetX = 0, offsetY = 0) => {
const scrollInfo = {
sheetViewStartRow: startRow,
sheetViewStartColumn: startColumn,
offsetX,
offsetY,
...searchParam,
};
scrollManagerService.emitRawScrollParam(scrollInfo);
scrollManagerService.setValidScrollState(scrollInfo);
};
const getFreeze = () => {
const currentService = get(IUniverInstanceService);
const workbook = currentService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
return worksheet?.getConfig().freeze;
};
function disposeTestBed() {
univer?.dispose();
univer = null;
}
function prepareTestBed(snapshot?: IWorkbookData) {
const testBed = createFrozenCommandTestBed(snapshot);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
selectionManagerService = get(SheetsSelectionsService);
scrollManagerService = get(SheetScrollManagerService);
scrollManagerService.setSearchParam({
...searchParam,
});
const scrollState = scrollManagerService.getScrollStateByParam(searchParam);
const scrollStateWithSheetId: IScrollStateWithSearchParam = {
...scrollState!,
...searchParam,
};
scrollManagerService.emitRawScrollParam(scrollStateWithSheetId);
}
afterEach(disposeTestBed);
describe('Freeze', () => {
beforeEach(() => prepareTestBed());
it('Should freeze row and col on current cell', async () => {
select(2, 2, 2, 2, 2, 2, false, false);
await commandService.executeCommand(SetSelectionFrozenCommand.id);
const config = getFreeze();
expect(config?.startRow === 2 && config.startColumn === 2).toBeTruthy();
});
it('Should keep default freeze config when selection is invalid', async () => {
await commandService.executeCommand(CancelFrozenCommand.id);
const config = getFreeze();
expect(config?.startRow === -1 && config.startColumn === -1).toBeTruthy();
});
it('Should freeze current row', async () => {
select(2, 2, 2, 2, 2, 2, false, false);
await commandService.executeCommand(SetRowFrozenCommand.id);
const config = getFreeze();
expect(config?.startRow === 2 && config.startColumn === -1).toBeTruthy();
});
it('Should freeze current column', async () => {
select(2, 2, 2, 2, 2, 2, false, false);
await commandService.executeCommand(SetColumnFrozenCommand.id);
const config = getFreeze();
expect(config?.startRow === -1 && config.startColumn === 2).toBeTruthy();
});
it('xSplit or ySplit must bigger than 0 if row or column was set', async () => {
select(2, 2, 2, 2, 2, 2, false, false);
scrollTo(2, 2);
let config: IFreeze | undefined;
await commandService.executeCommand(SetColumnFrozenCommand.id);
config = getFreeze();
expect(config?.startRow === -1 && config.startColumn === 2).toBeTruthy();
expect(config?.xSplit).toBe(1);
await commandService.executeCommand(SetRowFrozenCommand.id);
config = getFreeze();
expect(config?.startRow === 2 && config.startColumn === -1).toBeTruthy();
expect(config?.ySplit).toBe(1);
await commandService.executeCommand(SetSelectionFrozenCommand.id);
config = getFreeze();
expect(config?.startRow === 2 && config.startColumn === 2).toBeTruthy();
expect(config?.ySplit).toBe(1);
expect(config?.xSplit).toBe(1);
});
it('Should freeze first row and first column based on current viewport', async () => {
scrollTo(4, 6);
await commandService.executeCommand(SetFirstRowFrozenCommand.id);
let config = getFreeze();
expect(config?.startRow).toBe(5);
expect(config?.startColumn).toBe(-1);
expect(config?.ySplit).toBe(1);
await commandService.executeCommand(SetFirstColumnFrozenCommand.id);
config = getFreeze();
expect(config?.startRow).toBe(-1);
expect(config?.startColumn).toBe(7);
expect(config?.xSplit).toBe(1);
});
it('Should not freeze when the whole sheet is selected', async () => {
selectionManagerService.setSelections([
{
range: {
startRow: 0,
startColumn: 0,
endRow: 19,
endColumn: 19,
rangeType: RANGE_TYPE.ALL,
},
primary: {
startRow: 0,
startColumn: 0,
endRow: 19,
endColumn: 19,
actualRow: 0,
actualColumn: 0,
isMerged: false,
isMergedMainCell: false,
},
style: null,
},
]);
await expect(commandService.executeCommand(SetSelectionFrozenCommand.id)).resolves.toBe(false);
expect(getFreeze()).toEqual({
startRow: -1,
startColumn: -1,
xSplit: 0,
ySplit: 0,
});
});
it('Should cancel all freeze', async () => {
await commandService.executeCommand(CancelFrozenCommand.id);
const config = getFreeze();
expect(config?.startRow === -1 && config.startColumn === -1).toBeTruthy();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
/**
* 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.
*/
/* eslint-disable dot-notation */
import type { ISelectionCell, IWorkbookData, Univer, Workbook, Worksheet } from '@univerjs/core';
import { BorderType, ICommandService, IConfigService, IContextService, Injector, LocaleService, RANGE_TYPE } from '@univerjs/core';
import { SpreadsheetSkeleton } from '@univerjs/engine-render';
import {
SetBorderPositionCommand,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createSelectionCommandTestBed,
MERGED_CELLS_DATA,
} from './create-sheet-skeleton-test-bed';
describe('Test commands used for change selections', () => {
let univer: Univer | null = null;
let workbook: Workbook;
let worksheet: Worksheet;
let get: Injector['get'];
let commandService: ICommandService;
let selectionManagerService: SheetsSelectionsService;
let spreadsheetSkeleton: SpreadsheetSkeleton;
let localeService: LocaleService;
let contextService: IContextService;
let configService: IConfigService;
function select(selection: ISelectionCell) {
const { startRow, startColumn, endRow, endColumn } = selection;
selectionManagerService.setSelections([
{
range: { startRow, startColumn, endRow, endColumn, rangeType: RANGE_TYPE.NORMAL },
primary: selection,
style: null,
},
]);
}
function disposeTestBed() {
univer?.dispose();
univer = null;
}
/**
* Compare whether all the props of the two border configurations exist in each other.
*
* @param border1
* @param border2
* @returns
*/
const borderLenEqual = (border1: string[], border2: string[]) => {
if (border1.length !== border2.length) return false;
const sortedArr1 = border1.slice().sort();
const sortedArr2 = border2.slice().sort();
for (let i = 0; i < sortedArr1.length; i++) {
if (sortedArr1[i] !== sortedArr2[i]) {
return false;
}
}
return true;
};
function prepareSelectionsTestBed(snapshot?: IWorkbookData) {
const testBed = createSelectionCommandTestBed(snapshot);
univer = testBed.univer;
workbook = testBed.sheet;
get = testBed.get;
commandService = get(ICommandService);
selectionManagerService = get(SheetsSelectionsService);
localeService = get(LocaleService);
contextService = get(IContextService);
configService = get(IConfigService);
const injector = get(Injector);
worksheet = workbook.getActiveSheet()!;
spreadsheetSkeleton = new SpreadsheetSkeleton(
worksheet,
workbook.getStyles(),
localeService,
contextService,
configService,
injector
);
}
afterEach(disposeTestBed);
describe('_stylesCache.border in skeleton', () => {
beforeEach(() => prepareSelectionsTestBed(MERGED_CELLS_DATA));
/**
* A1 | B1 | C1
* ---| |----
* A2 | | C2
*
* When user clicks on C2 and move cursor left twice, A2 should not selected not A1.
*/
it('_stylesCache.border should be same as worksheet.getRange', async () => {
const mergeRangeForB1B3 = { startRow: 0, endRow: 2, startColumn: 1, endColumn: 1 };
const rangeForA1 = { startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 };
select({ ...mergeRangeForB1B3, actualRow: 0, actualColumn: 1, isMerged: false, isMergedMainCell: true } as ISelectionCell);
//set BorderType.ALL for B1:B3
expect(
await commandService.executeCommand(SetBorderPositionCommand.id, { value: BorderType.ALL })
).toBeTruthy();
spreadsheetSkeleton['_setStylesCacheForOneCell'](mergeRangeForB1B3.startRow, mergeRangeForB1B3.startColumn, {
mergeRange: mergeRangeForB1B3,
});
const borderOfCell01 = spreadsheetSkeleton.stylesCache.border?.getValue(0, 1) || {};
let actualBorderList = Object.keys(borderOfCell01);
let borderOfRange = worksheet.getRange(0, 1, 0, 1).getBorder();
let expectBorderList = Object.keys(borderOfRange);
expect(borderLenEqual(actualBorderList, expectBorderList)).toBe(true);
// set BorderType.NONE for A1
spreadsheetSkeleton.resetCache();
select({ ...rangeForA1, actualRow: 1, actualColumn: 1, isMerged: false, isMergedMainCell: false } as ISelectionCell);
expect(
await commandService.executeCommand(SetBorderPositionCommand.id, { value: BorderType.NONE })
).toBeTruthy();
spreadsheetSkeleton['_setStylesCacheForOneCell'](mergeRangeForB1B3.startRow, mergeRangeForB1B3.startColumn, {
mergeRange: mergeRangeForB1B3,
});
spreadsheetSkeleton['_setStylesCacheForOneCell'](0, 0, { cacheItem: { bg: true, border: true } });
spreadsheetSkeleton['_setStylesCacheForOneCell'](0, 1, { cacheItem: { bg: true, border: true } });
spreadsheetSkeleton['_setStylesCacheForOneCell'](1, 0, { cacheItem: { bg: true, border: true } });
spreadsheetSkeleton['_setStylesCacheForOneCell'](1, 1, { cacheItem: { bg: true, border: true } });
spreadsheetSkeleton['_setStylesCacheForOneCell'](2, 0, { cacheItem: { bg: true, border: true } });
spreadsheetSkeleton['_setStylesCacheForOneCell'](2, 1, { cacheItem: { bg: true, border: true } });
const borderOfCell11 = spreadsheetSkeleton.stylesCache.border?.getValue(1, 1) || {};
actualBorderList = Object.keys(borderOfCell11);
borderOfRange = worksheet.getRange(1, 1, 1, 1).getBorder();
expectBorderList = Object.keys(borderOfRange);
expect(borderLenEqual(actualBorderList, expectBorderList)).toBe(true);
expect(actualBorderList.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,288 @@
/**
* 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, ICommand, IMultiCommand, Workbook } from '@univerjs/core';
import type { ISheetRangeLocation } from '@univerjs/sheets';
import type { LocaleKey } from '../../locale/types';
import type { IPasteHookKeyType } from '../../services/clipboard/type';
import { CommandType, ICommandService, IPermissionService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import {
getSheetCommandTarget,
RangeProtectionPermissionEditPoint,
RangeProtectionPermissionViewPoint,
SheetPermissionCheckController,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorksheetCopyPermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
WorksheetSetCellValuePermission,
WorksheetSetColumnStylePermission,
} from '@univerjs/sheets';
import { CopyCommand, CutCommand, IClipboardInterfaceService, PasteCommand, SheetPasteShortKeyCommandName } from '@univerjs/ui';
import { whenSheetFocused } from '../../controllers/shortcuts/utils';
import { ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE } from '../../services/clipboard/clipboard.service';
const SHEET_CLIPBOARD_PRIORITY = 998;
export const SheetCopyCommand: IMultiCommand = {
id: CopyCommand.id,
name: 'sheet.command.copy',
type: CommandType.COMMAND,
multi: true,
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor) => {
checkSheetClipboardPermission(accessor, CopyCommand.id);
const sheetClipboardService = accessor.get(ISheetClipboardService);
return sheetClipboardService.copy();
},
};
export const SheetCutCommand: IMultiCommand = {
id: CutCommand.id,
name: 'sheet.command.cut',
type: CommandType.COMMAND,
multi: true,
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor) => {
checkSheetClipboardPermission(accessor, CutCommand.id);
const sheetClipboardService = accessor.get(ISheetClipboardService);
return sheetClipboardService.cut();
},
};
export interface ISheetPasteParams {
value: string;
target?: ISheetRangeLocation;
}
export interface ISheetPasteByShortKeyParams {
htmlContent?: string;
textContent?: string;
files?: File[];
formulaClipboardPayload?: string;
target?: ISheetRangeLocation;
}
export const SheetPasteCommand: IMultiCommand = {
id: PasteCommand.id,
type: CommandType.COMMAND,
multi: true,
name: 'sheet.command.paste',
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor: IAccessor, params: ISheetPasteParams) => {
const sheetClipboardService = accessor.get(ISheetClipboardService);
const target = params?.target ?? sheetClipboardService.capturePasteTarget();
if (!target) {
return false;
}
const instanceService = accessor.get(IUniverInstanceService);
const workbook = instanceService.getUnit<Workbook>(target.unitId);
const worksheet = workbook?.getSheetBySheetId(target.subUnitId);
if (!workbook || !worksheet) {
return false;
}
const pasteParams = { value: params?.value, target };
checkSheetClipboardPermission(accessor, PasteCommand.id, pasteParams);
// const messageService = accessor.get(IMessageService);
// TODO: @yuhongz: check if there is excel content in the clipboard, if so
// ask users to use shortcuts instead.
const clipboardInterfaceService = accessor.get(IClipboardInterfaceService);
if (clipboardInterfaceService.supportClipboard) {
const clipboardItems = await clipboardInterfaceService.read();
// A restored unit may reuse the same IDs but must not inherit an old clipboard request.
if (instanceService.getUnit(target.unitId) !== workbook || workbook.getSheetBySheetId(target.subUnitId) !== worksheet) {
return false;
}
if (clipboardItems.length !== 0) {
checkSheetClipboardPermission(accessor, PasteCommand.id, pasteParams);
return sheetClipboardService.paste(clipboardItems[0], params?.value, target);
}
}
const lastCopyId = sheetClipboardService.copyContentCache().getLastCopyId();
if (lastCopyId) {
console.warn('Since the current environment does not support the Clipboard API, we will use the internal copyId to paste the content.');
return sheetClipboardService.pasteByCopyId(lastCopyId, params?.value, target);
}
return false;
},
};
export const SheetPasteShortKeyCommand: ICommand = {
id: SheetPasteShortKeyCommandName,
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: ISheetPasteByShortKeyParams) => {
const clipboardService = accessor.get(ISheetClipboardService);
const target = params.target ?? clipboardService.capturePasteTarget();
if (!target) {
return false;
}
checkSheetClipboardPermission(accessor, PasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE,
target,
});
const { htmlContent, textContent, files, formulaClipboardPayload } = params;
return clipboardService.legacyPaste(htmlContent, textContent, files, formulaClipboardPayload, target);
},
};
function checkSheetClipboardPermission(accessor: IAccessor, commandId: string, params?: ISheetPasteParams): void {
const permissionCheckController = accessor.get(SheetPermissionCheckController);
let permission = true;
let errorKey: LocaleKey = 'sheets-ui.permission.dialog.commonErr';
switch (commandId) {
case CopyCommand.id:
permission = permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission],
worksheetTypes: [WorksheetCopyPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
});
errorKey = 'sheets-ui.permission.dialog.copyErr';
break;
case CutCommand.id:
permission = permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission, WorkbookEditablePermission],
worksheetTypes: [WorksheetCopyPermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionViewPoint, RangeProtectionPermissionEditPoint],
});
errorKey = 'sheets-ui.permission.dialog.cutErr';
break;
case PasteCommand.id:
permission = checkSheetPastePermission(permissionCheckController, params);
errorKey = 'sheets-ui.permission.dialog.pasteErr';
break;
}
if (permission) {
return;
}
const localeService = accessor.get(LocaleService);
let errorMsg = localeService.t<LocaleKey>(errorKey);
if (commandId === CopyCommand.id || commandId === CutCommand.id) {
const instanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(instanceService);
const permissionService = accessor.get(IPermissionService);
if (
target &&
!permissionService.getPermissionPoint(new WorkbookCopyPermission(target.unitId).id)?.value
) {
errorMsg = localeService.t<LocaleKey>('sheets-ui.permission.dialog.workbookCopyErr');
}
}
permissionCheckController.blockExecuteWithoutPermission(errorMsg);
}
function checkSheetPastePermission(
permissionCheckController: SheetPermissionCheckController,
params?: ISheetPasteParams
): boolean {
const target = params?.target;
const ranges = target ? [target.range] : undefined;
if (
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE ||
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA ||
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT
) {
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, ranges, target?.unitId, target?.subUnitId);
}
if (params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH) {
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [
WorksheetEditPermission,
WorksheetSetCellValuePermission,
WorksheetSetCellStylePermission,
WorksheetSetColumnStylePermission,
],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, ranges, target?.unitId, target?.subUnitId);
}
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellValuePermission, WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, ranges, target?.unitId, target?.subUnitId);
}
export const SheetPasteValueCommand: ICommand = {
id: 'sheet.command.paste-value',
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
});
},
};
export const SheetPasteFormatCommand: ICommand = {
id: 'sheet.command.paste-format',
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
});
},
};
export const SheetPasteColWidthCommand: ICommand = {
id: 'sheet.command.paste-col-width',
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
});
},
};
export const SheetPasteBesidesBorderCommand: ICommand = {
id: 'sheet.command.paste-besides-border',
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SheetPasteCommand.id, {
value: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_BESIDES_BORDER,
});
},
};
export const SheetOptionalPasteCommand: ICommand = {
id: 'sheet.command.optional-paste',
type: CommandType.COMMAND,
handler: async (accessor, { type }: { type: IPasteHookKeyType }) => {
const clipboardService = accessor.get(ISheetClipboardService);
return clipboardService.rePasteWithPasteType(type);
},
};

View File

@@ -0,0 +1,70 @@
/**
* 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, IRange } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, getIntersectRange, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { DeleteRangeMoveLeftCommand, getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
export const DeleteRangeMoveLeftConfirmCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.delete-range-move-left-confirm',
handler: async (accessor) => {
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selection = selectionManagerService.getCurrentSelections();
if (!selection) {
return false;
}
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
let range = selection[0].range;
if (!range) {
return false;
}
range = { ...range, endColumn: worksheet.getColumnCount() - 1 };
const getRowLength = (range: IRange) => range.endRow - range.startRow;
const mergeData = worksheet.getMergeData().find((mergeRange) => {
const interSectedRange = getIntersectRange(mergeRange, range);
return interSectedRange ? getRowLength(mergeRange) > getRowLength(interSectedRange) : false;
});
if (!mergeData) {
return commandService.executeCommand(DeleteRangeMoveLeftCommand.id);
}
const result = await confirmService.confirm({
id: DeleteRangeMoveLeftConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.warning') },
children: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.dismantleMergeCellWarning') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return commandService.executeCommand(DeleteRangeMoveLeftCommand.id);
}
return true;
},
};

View File

@@ -0,0 +1,80 @@
/**
* 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, IRange } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, getIntersectRange, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { DeleteRangeMoveUpCommand, getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
export const DeleteRangeMoveUpConfirmCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.delete-range-move-up-confirm',
handler: async (accessor) => {
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selection = selectionManagerService.getCurrentSelections();
if (!selection) return false;
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
let range = selection[0].range;
if (!range) return false;
range = { ...range, endRow: worksheet.getRowCount() - 1 };
for (let i = range.startRow; i <= range.endRow; i++) {
if (worksheet.getRowFiltered(i)) {
const result = await confirmService.confirm({
id: DeleteRangeMoveUpConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.filter.confirm.error') },
children: { title: localeService.t<LocaleKey>('sheets-ui.filter.confirm.notAllowedToInsertRange') },
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return false;
}
}
}
const getColLength = (range: IRange) => range.endColumn - range.startColumn;
const mergeData = worksheet.getMergeData().find((mergeRange) => {
const interSectedRange = getIntersectRange(mergeRange, range);
return interSectedRange ? getColLength(mergeRange) > getColLength(interSectedRange) : false;
});
if (!mergeData) {
return commandService.executeCommand(DeleteRangeMoveUpCommand.id);
}
const result = await confirmService.confirm({
id: DeleteRangeMoveUpConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.warning') },
children: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.dismantleMergeCellWarning') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return commandService.executeCommand(DeleteRangeMoveUpCommand.id);
}
return true;
},
};

View File

@@ -0,0 +1,86 @@
/**
* 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 type { SpreadsheetColumnHeader, SpreadsheetRowHeader } from '@univerjs/engine-render';
import { CommandType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SHEET_VIEW_KEY } from '../../common/keys';
import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service';
export interface ISetHeaderSizeParams {
size: number;
unitId: string;
subUnitId: string;
}
export const SetRowHeaderWidthCommand: ICommand<ISetHeaderSizeParams> = {
id: 'sheet.command.set-row-header-width',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
if (!params) {
return false;
}
const renderManagerSrv = accessor.get(IRenderManagerService);
if (!renderManagerSrv) {
return false;
}
const { unitId, subUnitId, size } = params;
const render = renderManagerSrv.getRenderUnitById(unitId);
if (render) {
const skm = renderManagerSrv.getRenderUnitById(unitId)!.with(SheetSkeletonManagerService);
skm.setRowHeaderSize(render, subUnitId, size);
const { components } = render;
const renderComponent = components.get(SHEET_VIEW_KEY.ROW);
if (renderComponent) {
(renderComponent as SpreadsheetRowHeader).setCustomHeader({ headerStyle: { size } }, subUnitId);
}
}
return true;
},
};
export const SetColumnHeaderHeightCommand: ICommand<ISetHeaderSizeParams> = {
id: 'sheet.command.set-col-header-height',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
if (!params) {
return false;
}
const renderManagerSrv = accessor.get(IRenderManagerService);
if (!renderManagerSrv) {
return false;
}
const { unitId, subUnitId, size } = params;
const render = renderManagerSrv.getRenderUnitById(unitId);
if (render) {
const skm = renderManagerSrv.getRenderUnitById(unitId)!.with(SheetSkeletonManagerService);
skm.setColumnHeaderSize(render, subUnitId, size);
const { components } = render;
const renderComponent = components.get(SHEET_VIEW_KEY.COLUMN);
if (renderComponent) {
(renderComponent as SpreadsheetColumnHeader).setCustomHeader({ headerStyle: { size } }, subUnitId);
}
}
return true;
},
};

View File

@@ -0,0 +1,108 @@
/**
* 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, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import {
getSheetCommandTarget,
SetColHiddenCommand,
SetRowHiddenCommand,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { isAllColumnsCovered, isAllRowsCovered } from './utils/selection-utils';
export const HideRowConfirmCommand: ICommand = {
id: 'sheet.command.hide-row-confirm',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const selectionManagerService = accessor.get(SheetsSelectionsService);
const ranges = selectionManagerService.getCurrentSelections()?.map((s) => s.range);
if (!ranges?.length) return false;
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
const allRowRanges = worksheet.getVisibleRows();
if (isAllRowsCovered(allRowRanges, ranges)) {
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
await confirmService.confirm({
id: 'sheet.confirm.hide-row',
title: {
title: localeService.t<LocaleKey>('sheets-ui.info.problem'),
},
children: { title: localeService.t<LocaleKey>('sheets-ui.rightClick.hideAllRowsAlert') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
return false;
}
await commandService.executeCommand(SetRowHiddenCommand.id);
return true;
},
};
export const HideColConfirmCommand: ICommand = {
id: 'sheet.command.hide-col-confirm',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const selectionManagerService = accessor.get(SheetsSelectionsService);
const ranges = selectionManagerService.getCurrentSelections()?.map((s) => s.range);
if (!ranges?.length) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
const allColumnRanges = worksheet.getVisibleCols();
if (isAllColumnsCovered(allColumnRanges, ranges)) {
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
await confirmService.confirm({
id: 'sheet.confirm.hide-col',
title: {
title: localeService.t<LocaleKey>('sheets-ui.info.problem'),
},
children: { title: localeService.t<LocaleKey>('sheets-ui.rightClick.hideAllColumnsAlert') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
return false;
}
await commandService.executeCommand(SetColHiddenCommand.id);
return true;
},
};

View File

@@ -0,0 +1,293 @@
/**
* 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, Workbook } from '@univerjs/core';
import { CommandType, DEFAULT_STYLES, EDITOR_ACTIVATED, ICommandService, IContextService, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import {
SetInlineFormatBoldCommand,
SetInlineFormatFontFamilyCommand,
SetInlineFormatFontSizeCommand,
SetInlineFormatItalicCommand,
SetInlineFormatStrikethroughCommand,
SetInlineFormatSubscriptCommand,
SetInlineFormatSuperscriptCommand,
SetInlineFormatTextColorCommand,
SetInlineFormatUnderlineCommand,
} from '@univerjs/docs-ui';
import {
SetBoldCommand,
SetFontFamilyCommand,
SetFontSizeCommand,
SetItalicCommand,
SetStrikeThroughCommand,
SetTextColorCommand,
SetUnderlineCommand,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { getFontStyleAtCursor } from '../../menu/utils';
/**
* It is used to set the bold style of selections or one cell, need to distinguish between
* **selection state** and **edit state**. If you are in the selective state,
* you need to set the style on the cell and the style on the rich text(p textRuns) at the same time,
* and if it is only in edit state, then you only need to set the style of the rich text(p textRuns)
*/
export const SetRangeBoldCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-bold',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatBoldCommand.id);
}
return commandService.executeCommand(SetBoldCommand.id);
},
};
export const SetRangeItalicCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-italic',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatItalicCommand.id);
}
return commandService.executeCommand(SetItalicCommand.id);
},
};
export const SetRangeUnderlineCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-underline',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatUnderlineCommand.id);
}
return commandService.executeCommand(SetUnderlineCommand.id);
},
};
export const SetRangeStrickThroughCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-stroke',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatStrikethroughCommand.id);
}
return commandService.executeCommand(SetStrikeThroughCommand.id);
},
};
export const SetRangeSubscriptCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-subscript',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatSubscriptCommand.id);
}
return false;
},
};
export const SetRangeSuperscriptCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-superscript',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatSuperscriptCommand.id);
}
return false;
},
};
export const SetRangeFontSizeCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-fontsize',
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatFontSizeCommand.id, params);
}
return commandService.executeCommand(SetFontSizeCommand.id, params);
},
};
export const SetRangeFontIncreaseCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-font-increase',
handler: async (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const defaultValue = DEFAULT_STYLES.fs;
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
const primary = selectionManagerService.getCurrentLastSelection()?.primary;
const params = {
value: defaultValue,
};
if (isCellEditorFocus) {
const textRun = getFontStyleAtCursor(accessor);
if (textRun != null) {
const value = textRun.ts?.fs ?? defaultValue;
params.value = value >= 400 ? 400 : value + 1;
}
return commandService.executeCommand(SetInlineFormatFontSizeCommand.id, params);
}
if (primary != null) {
const style = worksheet.getComposedCellStyle(primary.startRow, primary.startColumn);
const value = style.fs ?? defaultValue;
params.value = value >= 400 ? 400 : value + 1;
return commandService.executeCommand(SetFontSizeCommand.id, params);
}
return false;
},
};
export const SetRangeFontDecreaseCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-font-decrease',
handler: async (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const defaultValue = DEFAULT_STYLES.fs;
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
const primary = selectionManagerService.getCurrentLastSelection()?.primary;
const params = {
value: defaultValue,
};
if (isCellEditorFocus) {
const textRun = getFontStyleAtCursor(accessor);
if (textRun != null) {
const value = textRun.ts?.fs ?? defaultValue;
params.value = value <= 6 ? 6 : value - 1;
}
return commandService.executeCommand(SetInlineFormatFontSizeCommand.id, params);
}
if (primary != null) {
const style = worksheet.getComposedCellStyle(primary.startRow, primary.startColumn);
const value = style.fs ?? defaultValue;
params.value = value <= 6 ? 6 : value - 1;
return commandService.executeCommand(SetFontSizeCommand.id, params);
}
return false;
},
};
export const SetRangeFontFamilyCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-font-family',
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatFontFamilyCommand.id, params);
}
return commandService.executeCommand(SetFontFamilyCommand.id, params);
},
};
export const SetRangeTextColorCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-text-color',
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatTextColorCommand.id, params);
}
return commandService.executeCommand(SetTextColorCommand.id, params);
},
};
export const ResetRangeTextColorCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.reset-range-text-color',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const contextService = accessor.get(IContextService);
const isCellEditorFocus = contextService.getContextValue(EDITOR_ACTIVATED);
const themeService = accessor.get(ThemeService);
if (isCellEditorFocus) {
return commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: null });
}
return commandService.executeCommand(SetTextColorCommand.id, {
value: themeService.getColorFromTheme('gray.900'),
});
},
};

View File

@@ -0,0 +1,84 @@
/**
* 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, IRange } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, getIntersectRange, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { getSheetCommandTarget, InsertRangeMoveDownCommand, SheetsSelectionsService } from '@univerjs/sheets';
export const InsertRangeMoveDownConfirmCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.insert-range-move-down-confirm',
handler: async (accessor) => {
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selection = selectionManagerService.getCurrentSelections();
if (!selection) {
return false;
}
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
let range = selection[0].range;
if (!range) {
return false;
}
range = { ...range, endRow: worksheet.getRowCount() - 1 };
for (let i = range.startRow; i <= range.endRow; i++) {
if (worksheet.getRowFiltered(i)) {
const result = await confirmService.confirm({
id: InsertRangeMoveDownConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.filter.confirm.error') },
children: { title: localeService.t<LocaleKey>('sheets-ui.filter.confirm.notAllowedToInsertRange') },
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return false;
}
}
}
const getColLength = (range: IRange) => range.endColumn - range.startColumn;
const mergeData = worksheet.getMergeData().find((mergeRange) => {
const interSectedRange = getIntersectRange(mergeRange, range);
return interSectedRange ? getColLength(mergeRange) > getColLength(interSectedRange) : false;
});
if (!mergeData) {
return commandService.executeCommand(InsertRangeMoveDownCommand.id);
}
const result = await confirmService.confirm({
id: InsertRangeMoveDownConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.warning') },
children: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.dismantleMergeCellWarning') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return commandService.executeCommand(InsertRangeMoveDownCommand.id);
}
return true;
},
};

View File

@@ -0,0 +1,72 @@
/**
* 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, IRange } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, getIntersectRange, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { getSheetCommandTarget, InsertRangeMoveRightCommand, SheetsSelectionsService } from '@univerjs/sheets';
export const InsertRangeMoveRightConfirmCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.insert-range-move-right-confirm',
handler: async (accessor) => {
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selection = selectionManagerService.getCurrentSelections();
if (!selection) {
return false;
}
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
let range = selection[0].range;
if (!range) {
return false;
}
range = { ...range, endColumn: worksheet.getColumnCount() - 1 };
const getRowLength = (range: IRange) => range.endRow - range.startRow;
const mergeData = worksheet.getMergeData().find((mergeRange) => {
const interSectedRange = getIntersectRange(mergeRange, range);
return interSectedRange ? getRowLength(mergeRange) > getRowLength(interSectedRange) : false;
});
if (!mergeData) {
return commandService.executeCommand(InsertRangeMoveRightCommand.id);
}
const result = await confirmService.confirm({
id: InsertRangeMoveRightConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.warning') },
children: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.dismantleMergeCellWarning') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (result) {
return commandService.executeCommand(InsertRangeMoveRightCommand.id);
}
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 type { IMoveRangeCommandParams } from '@univerjs/sheets';
import type { LocaleKey } from '../../locale/types';
import {
CommandType,
ICommandService,
IConfirmService,
IUniverInstanceService,
LocaleService,
Rectangle,
} from '@univerjs/core';
import { findFirstNonEmptyCell, getSheetCommandTarget, MoveRangeCommand } from '@univerjs/sheets';
export const MoveRangeConfirmCommand: ICommand<IMoveRangeCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.move-range-confirm',
handler: async (accessor, params) => {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const sourceTarget = getSheetCommandTarget(univerInstanceService, {
unitId: params.fromUnitId,
subUnitId: params.fromSubUnitId,
});
const targetTarget = getSheetCommandTarget(univerInstanceService, {
unitId: params.toUnitId ?? params.fromUnitId,
subUnitId: params.toSubUnitId ?? params.fromSubUnitId,
});
if (!targetTarget) {
return commandService.executeCommand(MoveRangeCommand.id, params);
}
const isSameWorksheet = sourceTarget?.unitId === targetTarget.unitId
&& sourceTarget.subUnitId === targetTarget.subUnitId;
const targetRanges = isSameWorksheet
? Rectangle.subtract(params.toRange, params.fromRange)
: [params.toRange];
const hasTargetContent = targetRanges.some(
(range) => findFirstNonEmptyCell(range, targetTarget.worksheet) !== null
);
if (hasTargetContent) {
const localeService = accessor.get(LocaleService);
const confirmed = await accessor.get(IConfirmService).confirm({
id: MoveRangeConfirmCommand.id,
title: { title: localeService.t<LocaleKey>('sheets-ui.merge.confirm.warning') },
children: { title: localeService.t<LocaleKey>('sheets-ui.info.overwriteCellContent') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (!confirmed) {
return false;
}
}
return commandService.executeCommand(MoveRangeCommand.id, params);
},
};

View File

@@ -0,0 +1,185 @@
/**
* 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, Workbook } from '@univerjs/core';
import type { IRangeProtectionRule } from '@univerjs/sheets';
import { CommandType, ICommandService, IUndoRedoService, IUniverInstanceService, Rectangle, Tools, UniverInstanceType } from '@univerjs/core';
import { AddRangeProtectionMutation, DeleteRangeProtectionMutation, DeleteWorksheetProtectionCommand, RangeProtectionRuleModel, SheetsSelectionsService, WorksheetProtectionRuleModel } from '@univerjs/sheets';
import { SheetPermissionOpenPanelOperation } from '../operations/sheet-permission-open-panel.operation';
export interface IAddRangeProtectionParams {
permissionId: string;
rule: IRangeProtectionRule;
}
export type ISetRangeProtectionParams = IAddRangeProtectionParams;
export interface IDeleteRangeProtectionParams {
unitId: string;
subUnitId: string;
rule: IRangeProtectionRule;
}
export const AddRangeProtectionFromToolbarCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.add-range-protection-from-toolbar',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: true });
return true;
},
};
export const AddRangeProtectionFromContextMenuCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.add-range-protection-from-context-menu',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: true });
return true;
},
};
export const ViewSheetPermissionFromContextMenuCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.view-sheet-permission-from-context-menu',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: false });
return true;
},
};
export const AddRangeProtectionFromSheetBarCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.add-range-protection-from-sheet-bar',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { fromSheetBar: true, showDetail: true });
return true;
},
};
export const ViewSheetPermissionFromSheetBarCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.view-sheet-permission-from-sheet-bar',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: false });
return true;
},
};
export const DeleteRangeProtectionFromContextMenuCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.delete-range-protection-from-context-menu',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const undoRedoService = accessor.get(IUndoRedoService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const worksheetRuleModel = accessor.get(WorksheetProtectionRuleModel);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet()!;
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const worksheetRule = worksheetRuleModel.getRule(unitId, subUnitId);
if (worksheetRule?.permissionId) {
return commandService.executeCommand(DeleteWorksheetProtectionCommand.id, { unitId, subUnitId, rule: worksheetRule });
} else {
const selectRange = selectionManagerService.getCurrentLastSelection()?.range;
if (!selectRange) {
return false;
}
const sheetPermissionRuleModal = accessor.get(RangeProtectionRuleModel);
const subRuleList = sheetPermissionRuleModal.getSubunitRuleList(unitId, subUnitId);
const rule = subRuleList.find((item) => {
return item.ranges.some((range) => Rectangle.intersects(range, selectRange));
});
if (rule) {
const redoMutationParam = {
unitId,
subUnitId,
ruleIds: [rule.id],
};
const result = await commandService.executeCommand(DeleteRangeProtectionMutation.id, redoMutationParam);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: [{ id: DeleteRangeProtectionMutation.id, params: redoMutationParam }],
undoMutations: [{ id: AddRangeProtectionMutation.id, params: { unitId, subUnitId, rules: [rule] } }],
});
}
return true;
} else {
return false;
}
}
},
};
export const SetRangeProtectionFromContextMenuCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-range-protection-from-context-menu',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const worksheetRuleModel = accessor.get(WorksheetProtectionRuleModel);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet()!;
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const worksheetRule = worksheetRuleModel.getRule(unitId, subUnitId);
if (worksheetRule?.permissionId) {
const oldRule = {
...worksheetRule,
unitId,
subUnitId,
ranges: [],
};
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: true, rule: Tools.deepClone(oldRule), oldRule: Tools.deepClone(oldRule) });
return true;
} else {
const selectRange = selectionManagerService.getCurrentLastSelection()?.range;
if (!selectRange) {
return false;
}
const sheetPermissionRuleModal = accessor.get(RangeProtectionRuleModel);
const subRuleList = sheetPermissionRuleModal.getSubunitRuleList(unitId, subUnitId);
const rule = subRuleList.find((item) => {
return item?.ranges?.some((range) => Rectangle.intersects(range, selectRange));
});
if (rule) {
const oldRule = {
...rule,
unitId,
subUnitId,
};
await commandService.executeCommand(SheetPermissionOpenPanelOperation.id, { showDetail: true, rule: Tools.deepClone(oldRule), oldRule: Tools.deepClone(oldRule) });
return true;
} else {
return false;
}
}
},
};

View File

@@ -0,0 +1,112 @@
/**
* 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, ICommand } from '@univerjs/core';
import type { IRemoveRowColCommandParams } from '@univerjs/sheets';
import type { LocaleKey } from '../../locale/types';
import { CommandType, ICommandService, IConfirmService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { getSheetCommandTarget, RemoveColCommand, RemoveRowCommand, SheetsSelectionsService } from '@univerjs/sheets';
import { isAllColumnsCovered, isAllRowsCovered } from './utils/selection-utils';
export const RemoveRowConfirmCommand: ICommand = {
id: 'sheet.command.remove-row-confirm',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params?: IRemoveRowColCommandParams) => {
const selectionManagerService = accessor.get(SheetsSelectionsService);
let range = params?.range;
if (!range) {
range = selectionManagerService.getCurrentLastSelection()?.range;
}
if (!range) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
const allRowRanges = worksheet.getVisibleRows();
if (isAllRowsCovered(allRowRanges, [range])) {
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
await confirmService.confirm({
id: 'sheet.confirm.remove-row',
title: {
title: localeService.t<LocaleKey>('sheets-ui.info.problem'),
},
children: { title: localeService.t<LocaleKey>('sheets-ui.rightClick.deleteAllRowsAlert') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
return false;
}
await commandService.executeCommand(RemoveRowCommand.id, { range });
return true;
},
};
export const RemoveColConfirmCommand: ICommand = {
id: 'sheet.command.remove-col-confirm',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params?: IRemoveRowColCommandParams) => {
const selectionManagerService = accessor.get(SheetsSelectionsService);
let range = params?.range;
if (!range) {
range = selectionManagerService.getCurrentLastSelection()?.range;
}
if (!range) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet } = target;
const allColumnRanges = worksheet.getVisibleCols();
if (isAllColumnsCovered(allColumnRanges, [range])) {
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
await confirmService.confirm({
id: 'sheet.confirm.remove-col',
title: {
title: localeService.t<LocaleKey>('sheets-ui.info.problem'),
},
children: { title: localeService.t<LocaleKey>('sheets-ui.rightClick.deleteAllColumnsAlert') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
return false;
}
await commandService.executeCommand(RemoveColCommand.id, { range });
return true;
},
};

View File

@@ -0,0 +1,82 @@
/**
* 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, ICommand } from '@univerjs/core';
import type { IUniverSheetsConfig } from '@univerjs/sheets';
import type { LocaleKey } from '../../locale/types';
import {
CommandType,
ICommandService,
IConfigService,
IConfirmService,
IUniverInstanceService,
LocaleService,
} from '@univerjs/core';
import {
countCells,
defaultLargeSheetOperationConfig,
getSheetCommandTarget,
RemoveSheetCommand,
SHEETS_PLUGIN_CONFIG_KEY,
} from '@univerjs/sheets';
interface IRemoveSheetConfirmCommandParams {
subUnitId: string;
}
export const RemoveSheetConfirmCommand: ICommand = {
id: 'sheet.command.remove-sheet-confirm',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IRemoveSheetConfirmCommandParams) => {
const { subUnitId } = params;
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const configService = accessor.get(IConfigService);
const univerInstanceService = accessor.get(IUniverInstanceService);
// Check if this is a large sheet that needs confirmation
const target = getSheetCommandTarget(univerInstanceService, { subUnitId });
if (!target) return false;
const { worksheet } = target;
const pluginConfig = configService.getConfig<IUniverSheetsConfig>(SHEETS_PLUGIN_CONFIG_KEY);
const largeSheetConfig = {
...defaultLargeSheetOperationConfig,
...pluginConfig?.largeSheetOperation,
};
const cellCount = countCells(worksheet.getCellMatrix());
const isLargeSheet = cellCount >= largeSheetConfig.largeSheetCellCountThreshold;
// Only show confirmation dialog for large sheets
const result = await confirmService.confirm({
id: 'sheet.confirm.remove-sheet',
title: {
title: localeService.t<LocaleKey>('sheets-ui.sheetConfig.deleteSheet'),
},
children: { title: isLargeSheet ? localeService.t<LocaleKey>('sheets-ui.sheetConfig.deleteLargeSheetContent') : localeService.t<LocaleKey>('sheets-ui.sheetConfig.deleteSheetContent') },
cancelText: localeService.t<LocaleKey>('sheets-ui.button.cancel'),
confirmText: localeService.t<LocaleKey>('sheets-ui.button.confirm'),
});
if (!result) {
return false;
}
await commandService.executeCommand(RemoveSheetCommand.id, { subUnitId });
return true;
},
};

View File

@@ -0,0 +1,114 @@
/**
* 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, ICommand } from '@univerjs/core';
import { CommandType, LocaleService } from '@univerjs/core';
import {
RangeProtectionPermissionEditPoint,
SheetPermissionCheckController,
SheetsSelectionsService,
WorkbookEditablePermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
WorksheetSetCellValuePermission,
WorksheetSetColumnStylePermission,
WorksheetSetRowStylePermission,
} from '@univerjs/sheets';
import { IRepeatLastActionService, RepeatLastActionPermission } from '../../services/repeat-last-action.service';
const permissionCheckMap = {
[RepeatLastActionPermission.Editable]: {
permissionTypes: {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
},
errorMessageKey: 'sheets-ui.permission.dialog.editErr',
},
[RepeatLastActionPermission.CellStyle]: {
permissionTypes: {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellStylePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
},
errorMessageKey: 'sheets-ui.permission.dialog.setStyleErr',
},
[RepeatLastActionPermission.CellValue]: {
permissionTypes: {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellValuePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
},
errorMessageKey: 'sheets-ui.permission.dialog.editErr',
},
[RepeatLastActionPermission.RowStyle]: {
permissionTypes: {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetRowStylePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
},
errorMessageKey: 'sheets-ui.permission.dialog.setRowColStyleErr',
},
[RepeatLastActionPermission.ColumnStyle]: {
permissionTypes: {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetColumnStylePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
},
errorMessageKey: 'sheets-ui.permission.dialog.setRowColStyleErr',
},
};
export const RepeatLastActionCommand: ICommand = {
id: 'sheet.command.repeat-last-action',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const selectionManagerService = accessor.get(SheetsSelectionsService);
const selections = selectionManagerService.getCurrentSelections()?.map((selection) => selection.range);
if (!selections?.length) {
return false;
}
const repeatLastActionService = accessor.get(IRepeatLastActionService);
const sheetPermissionCheckController = accessor.get(SheetPermissionCheckController);
const localeService = accessor.get(LocaleService);
const actionPermissionType = repeatLastActionService.getActionPermission();
if (Array.isArray(actionPermissionType)) {
for (const permissionType of actionPermissionType) {
if (permissionType === RepeatLastActionPermission.None) {
continue;
}
const permissionConfig = permissionCheckMap[permissionType];
const permission = sheetPermissionCheckController.permissionCheckWithRanges(permissionConfig.permissionTypes);
if (!permission) {
sheetPermissionCheckController.blockExecuteWithoutPermission(localeService.t(permissionConfig.errorMessageKey));
return false;
}
}
} else if (actionPermissionType !== RepeatLastActionPermission.None) {
const permissionConfig = permissionCheckMap[actionPermissionType];
const permission = sheetPermissionCheckController.permissionCheckWithRanges(permissionConfig.permissionTypes);
if (!permission) {
sheetPermissionCheckController.blockExecuteWithoutPermission(localeService.t(permissionConfig.errorMessageKey));
return false;
}
}
return await repeatLastActionService.runWithoutRecording(selections);
},
};

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 { IAccessor, ICommand, IRange } from '@univerjs/core';
import {
CommandType,
ICommandService,
} from '@univerjs/core';
import { FormatPainterStatus, IFormatPainterService } from '../../services/format-painter/format-painter.service';
import { SetFormatPainterOperation } from '../operations/set-format-painter.operation';
export interface ISetFormatPainterCommandParams {
status: FormatPainterStatus;
}
export const SetInfiniteFormatPainterCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-infinite-format-painter',
handler: async (accessor: IAccessor) => {
const formatPainterService = accessor.get(IFormatPainterService);
const status = formatPainterService.getStatus();
let newStatus: FormatPainterStatus;
if (status !== FormatPainterStatus.OFF) {
newStatus = FormatPainterStatus.OFF;
} else {
newStatus = FormatPainterStatus.INFINITE;
}
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SetFormatPainterOperation.id, { status: newStatus });
},
};
export const SetOnceFormatPainterCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-once-format-painter',
handler: async (accessor: IAccessor) => {
const formatPainterService = accessor.get(IFormatPainterService);
const status = formatPainterService.getStatus();
let newStatus: FormatPainterStatus;
if (status !== FormatPainterStatus.OFF) {
newStatus = FormatPainterStatus.OFF;
} else {
newStatus = FormatPainterStatus.ONCE;
}
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SetFormatPainterOperation.id, { status: newStatus });
},
};
export interface IApplyFormatPainterCommandParams {
subUnitId: string;
unitId: string;
range: IRange;
}
export const ApplyFormatPainterCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.apply-format-painter',
handler: async (accessor: IAccessor, params: IApplyFormatPainterCommandParams) => {
const formatPainterService = accessor.get(IFormatPainterService);
return formatPainterService.applyFormatPainter(params.unitId, params.subUnitId, params.range);
},
};

View File

@@ -0,0 +1,184 @@
/**
* 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 type { ISetFrozenMutationParams } from '@univerjs/sheets';
import { CommandType, ICommandService, IUndoRedoService, IUniverInstanceService, RANGE_TYPE } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getSheetCommandTarget, SetFrozenMutation, SetFrozenMutationFactory, SheetsSelectionsService } from '@univerjs/sheets';
import { SheetScrollManagerService } from '../../services/scroll-manager.service';
export enum SetSelectionFrozenType {
RowColumn = 0,
Row = 1,
Column = 2,
FirstRow = 3,
FirstColumn = 4,
}
export interface ISetSelectionFrozenCommandParams {
type?: SetSelectionFrozenType;
}
export const SetSelectionFrozenCommand: ICommand<ISetSelectionFrozenCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.set-selection-frozen',
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params) => {
const { type } = params || {};
const univerInstanceService = accessor.get(IUniverInstanceService);
const undoRedoService = accessor.get(IUndoRedoService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId, subUnitId } = target;
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const selections = selectionManagerService.getCurrentSelections();
if (!selections) {
return false;
}
const renderManagerSrv = accessor.get(IRenderManagerService);
const scrollManagerService = renderManagerSrv.getRenderUnitById(unitId)!.with(SheetScrollManagerService);
const { sheetViewStartRow = 0, sheetViewStartColumn = 0 } = scrollManagerService.getCurrentScrollState() || {};
let startRow;
let startColumn;
let freezedRowCount;
let freezedColCount;
if (type === SetSelectionFrozenType.FirstRow) {
startRow = sheetViewStartRow + 1; ;
freezedRowCount = 1;
startColumn = -1;
freezedColCount = 0;
} else if (type === SetSelectionFrozenType.FirstColumn) {
startRow = -1;
freezedRowCount = 0;
startColumn = sheetViewStartColumn + 1;
freezedColCount = 1;
} else {
const currentSelection = selections[selections?.length - 1];
const { primary, range } = currentSelection;
let selectRow = primary?.startRow ?? range.startRow;
if (selectRow === 0) {
selectRow = 1;
}
let selectColumn = primary?.startColumn ?? range.startColumn;
if (selectColumn === 0) {
selectColumn = 1;
}
const rangeType = range.rangeType;
if (rangeType === RANGE_TYPE.ROW || type === SetSelectionFrozenType.Row) {
// Frozen to Row
startRow = selectRow;
freezedRowCount = selectRow - sheetViewStartRow;
startColumn = -1;
freezedColCount = 0;
} else if (rangeType === RANGE_TYPE.COLUMN || type === SetSelectionFrozenType.Column) {
// Frozen to Column
startRow = -1;
freezedRowCount = 0;
startColumn = selectColumn;
freezedColCount = selectColumn - sheetViewStartColumn;
} else if (rangeType === RANGE_TYPE.NORMAL) {
// Frozen to Range
startRow = selectRow;
freezedRowCount = selectRow - sheetViewStartRow;
startColumn = selectColumn;
freezedColCount = selectColumn - sheetViewStartColumn;
} else {
// Unexpected value
return false;
}
}
const redoMutationParams: ISetFrozenMutationParams = {
unitId,
subUnitId,
startRow,
startColumn,
xSplit: startColumn > 0 ? Math.max(1, freezedColCount) : freezedColCount,
ySplit: startRow > 0 ? Math.max(1, freezedRowCount) : freezedRowCount,
};
const undoMutationParams: ISetFrozenMutationParams = SetFrozenMutationFactory(accessor, redoMutationParams);
const result = commandService.syncExecuteCommand(SetFrozenMutation.id, redoMutationParams);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [{ id: SetFrozenMutation.id, params: undoMutationParams }],
redoMutations: [{ id: SetFrozenMutation.id, params: redoMutationParams }],
});
return true;
}
return true;
},
};
export const SetRowFrozenCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-row-frozen',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
commandService.executeCommand(SetSelectionFrozenCommand.id, {
type: SetSelectionFrozenType.Row,
});
return true;
},
};
export const SetColumnFrozenCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-col-frozen',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
commandService.executeCommand(SetSelectionFrozenCommand.id, {
type: SetSelectionFrozenType.Column,
});
return true;
},
};
export const SetFirstRowFrozenCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-first-row-frozen',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
commandService.executeCommand(SetSelectionFrozenCommand.id, {
type: SetSelectionFrozenType.FirstRow,
});
return true;
},
};
export const SetFirstColumnFrozenCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-first-column-frozen',
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
commandService.executeCommand(SetSelectionFrozenCommand.id, {
type: SetSelectionFrozenType.FirstColumn,
});
return true;
},
};

View File

@@ -0,0 +1,225 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IScrollState } from '../../services/scroll-manager.service';
import { CommandType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { SheetsScrollRenderController } from '../../controllers/render-controllers/scroll.render-controller';
import { SheetScrollManagerService } from '../../services/scroll-manager.service';
import { SetScrollOperation } from '../operations/scroll.operation';
export interface ISetScrollRelativeCommandParams {
offsetX?: number;
offsetY?: number;
}
export interface IScrollCommandParams {
unitId?: string;
sheetId?: string;
offsetX?: number;
offsetY?: number;
/**
* The index of row in spreadsheet.
* e.g. if row start 10 at current viewport after freeze, and scroll value is zero, startRow is 0.
* e.g. if scrolled about 2 rows, now top is 12, then sheetViewStartRow is 2.
*/
sheetViewStartRow?: number;
/**
* Not the index of col in spreadsheet, but index of first column in current viewport.
* e.g. if col start C at current viewport after freeze, and scroll value is zero, startColumn is 0.
* e.g. if scrolled about 2 columns, now left is E, then sheetViewStartColumn is 2.
*/
sheetViewStartColumn?: number;
/**
* The duration of the scroll animation in milliseconds.
*/
duration?: number;
}
/**
* This command is used to manage the scroll by relative offset
* Usually triggered by wheel event.
* NOT same as ScrollCommand, which is usually triggered by scrollbar.
*/
export const SetScrollRelativeCommand: ICommand<ISetScrollRelativeCommandParams> = {
id: 'sheet.command.set-scroll-relative',
type: CommandType.COMMAND,
// offsetXY derived from mouse wheel event
// this._commandService.executeCommand(SetScrollRelativeCommand.id, { offsetY });
handler: async (accessor, params: ISetScrollRelativeCommandParams) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerSrv = accessor.get(IRenderManagerService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId, subUnitId } = target;
const scrollManagerService = renderManagerSrv.getRenderUnitById(unitId)!.with(SheetScrollManagerService);
const currentScroll = scrollManagerService.getCurrentScrollState();
const { offsetX = 0, offsetY = 0 } = params || {};
const {
sheetViewStartRow = 0,
sheetViewStartColumn = 0,
offsetX: currentOffsetX = 0,
offsetY: currentOffsetY = 0,
} = currentScroll || {};
// the receiver is scroll.operation.ts
// const { xSplit, ySplit } = target.worksheet.getConfig().freeze;
return commandService.executeCommand(SetScrollOperation.id, {
unitId,
sheetId: subUnitId,
// why + ySplit? receiver - ySplit in scroll.operation.ts
// sheetViewStartRow: sheetViewStartRow + ySplit,
// sheetViewStartColumn: sheetViewStartColumn + xSplit,
sheetViewStartRow,
sheetViewStartColumn,
offsetX: currentOffsetX + offsetX, // currentOffsetX + offsetX may be negative or over max
offsetY: currentOffsetY + offsetY,
});
},
};
/**
* This command is used to manage the scroll position of the current view by specifying the cell index of the top left cell
* Usually triggered by dragging scroll bar and click scroll track or moving selection range.
* NOT same as SetScrollRelativeCommand which usually trigger by wheelevent.
*/
export const ScrollCommand: ICommand<IScrollCommandParams> = {
id: 'sheet.command.scroll-view',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerSrv = accessor.get(IRenderManagerService);
const target = params.unitId
? getScrollCommandTargetByParams(univerInstanceService, params)
: getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { workbook, worksheet, unitId } = target;
const renderUnit = renderManagerSrv.getRenderUnitById(unitId);
if (!renderUnit) return false;
const scrollManagerService = renderUnit.with(SheetScrollManagerService);
const currentScroll: Readonly<Nullable<IScrollState>> = scrollManagerService.getCurrentScrollState();
if (!worksheet) {
return false;
}
const { sheetViewStartRow, sheetViewStartColumn, offsetX, offsetY, duration } = params;
const {
sheetViewStartColumn: currentColumn,
sheetViewStartRow: currentRow,
offsetX: currentOffsetX,
offsetY: currentOffsetY,
} = currentScroll || {};
const { xSplit, ySplit } = target.worksheet.getConfig().freeze;
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetScrollOperation.id, {
unitId: workbook.getUnitId(),
sheetId: worksheet.getSheetId(),
// why + ySplit? receiver - ySplit in scroll.operation.ts
// sheetViewStartRow: sheetViewStartRow + ySplit,
// sheetViewStartColumn: sheetViewStartColumn + xSplit,
sheetViewStartRow: sheetViewStartRow ?? (currentRow ?? 0 + ySplit),
sheetViewStartColumn: sheetViewStartColumn ?? (currentColumn ?? 0 + xSplit),
offsetX: offsetX ?? currentOffsetX,
offsetY: offsetY ?? currentOffsetY,
duration,
});
},
};
function getScrollCommandTargetByParams(
univerInstanceService: IUniverInstanceService,
params: Pick<IScrollCommandParams, 'unitId' | 'sheetId'>
) {
if (!params.unitId) {
return null;
}
const workbook = univerInstanceService.getUnit<Workbook>(params.unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = params.sheetId ? workbook?.getSheetBySheetId(params.sheetId) : workbook?.getActiveSheet();
if (!workbook || !worksheet) {
return null;
}
return {
workbook,
worksheet,
unitId: params.unitId,
subUnitId: worksheet.getSheetId(),
};
}
export interface IScrollToCellCommandParams {
range: IRange;
forceTop?: boolean;
forceLeft?: boolean;
unitId?: string;
}
/**
* The command is used to scroll to the specific cell if the target cell is not in the viewport.
*/
export const ScrollToCellCommand: ICommand<IScrollToCellCommandParams> = {
id: 'sheet.command.scroll-to-cell',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const instanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const scrollController = renderManagerService
.getRenderUnitById(instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!.getUnitId())!
.with(SheetsScrollRenderController);
return scrollController.scrollToRange(params!.range, params!.forceTop, params!.forceLeft);
},
};
/**
* This command is reset the scroll position of the current view to 0, 0
*/
export const ResetScrollCommand: ICommand = {
id: 'sheet.command.scroll-view-reset',
type: CommandType.COMMAND,
handler: async (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { subUnitId, unitId } = target;
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(SetScrollOperation.id, {
unitId,
sheetId: subUnitId,
sheetViewStartRow: 0,
sheetViewStartColumn: 0,
});
},
};

View File

@@ -0,0 +1,533 @@
/**
* 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, IMutationInfo, IRange } from '@univerjs/core';
import type {
ISetSelectionsOperationParams,
} from '@univerjs/sheets';
import { CommandType, Direction, ICommandService, IUniverInstanceService, RANGE_TYPE, Rectangle, sequenceExecute, Tools } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
expandToContinuousRange,
getCellAtRowCol,
getSelectionsService,
getSheetCommandTarget,
SelectionMoveType,
SetSelectionsOperation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { KeyCode } from '@univerjs/ui';
import { SelectAllService } from '../../services/select-all/select-all.service';
import { ISheetSelectionRenderService } from '../../services/selection/base-selection-render.service';
import { ShortcutExperienceService } from '../../services/shortcut-experience.service';
import {
checkIfShrink,
expandToNextCell,
expandToNextGapRange,
expandToWholeSheet,
findNextGapRange,
findNextRange,
getStartRange,
shrinkToNextCell,
shrinkToNextGapRange,
} from './utils/selection-utils';
// TODO@DR-UNIVER: moveStepPage and moveStepEnd implement
export enum JumpOver {
moveStopeOne,
moveGap,
moveStepPage,
moveStepEnd,
}
export interface IMoveSelectionCommandParams {
direction: Direction;
jumpOver?: JumpOver;
nextStep?: number;
extra?: string;
fromCurrentSelection?: boolean;
}
export interface IMoveSelectionEnterAndTabCommandParams {
direction: Direction;
keycode: KeyCode;
extra?: string;
fromCurrentSelection?: boolean;
}
/**
* Move selection (Mainly by keyboard arrow keys, For Tab and Enter key, check @MoveSelectionEnterAndTabCommand)
*/
export const MoveSelectionCommand: ICommand<IMoveSelectionCommandParams> = {
id: 'sheet.command.move-selection',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: (accessor, params) => {
if (!params) {
return false;
}
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!target) return false;
const { workbook, worksheet } = target;
const selection = getSelectionsService(accessor, params.fromCurrentSelection).getCurrentLastSelection();
if (!selection) {
return false;
}
const { direction, jumpOver, extra } = params;
let { range, primary } = selection;
if (extra === 'formula-editor') {
// When in formula editor, if the current cell is a merged cell, expand the selection to the merged cell range for calculation.
if (range.startRow === range.endRow && range.startColumn === range.endColumn) {
const mergedCell = worksheet.getMergedCell(range.startRow, range.startColumn);
if (mergedCell) {
range = {
...range,
...mergedCell,
};
}
}
// When in formula editor, try to use the last primary cell in ref selections as the primary cell for calculation.
if (!primary) {
const lastSelectionPrimary = getSelectionsService(accessor, params.fromCurrentSelection).getCurrentLastSelectionPrimaryCell();
if (lastSelectionPrimary && Rectangle.contains(range, lastSelectionPrimary)) {
primary = lastSelectionPrimary;
}
}
}
const startRange = getStartRange(range, primary, direction);
// the start range is from the primary selection range
const next =
jumpOver === JumpOver.moveGap
? findNextGapRange(startRange, direction, worksheet)
: findNextRange(startRange, direction, worksheet);
const destRange = getCellAtRowCol(next.startRow, next.startColumn, worksheet);
if (Rectangle.equals(destRange, startRange)) {
return false;
}
const unitId = workbook.getUnitId();
// If there are changes to the selection, clear the start position saved by the tab.
// This function works in conjunction with the enter and tab shortcuts.
accessor.get(ShortcutExperienceService).remove({
unitId,
sheetId: worksheet.getSheetId(),
keycode: KeyCode.TAB,
});
const selections = [
{
range: Rectangle.clone(destRange),
primary: {
startRow: destRange.startRow,
startColumn: destRange.startColumn,
endRow: destRange.endRow,
endColumn: destRange.endColumn,
actualRow: next.startRow,
actualColumn: next.startColumn,
isMerged: destRange.isMerged,
isMergedMainCell:
destRange.startRow === next.startRow && destRange.startColumn === next.startColumn,
},
},
];
const rs = accessor.get(ICommandService).executeCommand(SetSelectionsOperation.id, {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
selections,
type: SelectionMoveType.MOVE_END,
extra,
fromCurrentSelection: params.fromCurrentSelection,
reveal: true,
} as ISetSelectionsOperationParams);
return rs;
},
};
/**
* Move selection for enter and tab.
*/
export const MoveSelectionEnterAndTabCommand: ICommand<IMoveSelectionEnterAndTabCommandParams> = {
id: 'sheet.command.move-selection-enter-tab',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (accessor, params) => {
if (!params) {
return false;
}
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!target) return false;
const { workbook, worksheet } = target;
const selectionsService = getSelectionsService(accessor, params.fromCurrentSelection);
const { direction, keycode } = params;
const isReverse = direction === Direction.LEFT || direction === Direction.UP;
const selections = selectionsService.getCurrentSelections().concat();
const currentSelectionIndex = selections.findIndex((s) => s.primary);
const selection = selections[currentSelectionIndex];
if (!selection) {
return false;
}
// for shift tab or shift enter, the direction should be reversed. so we need find the previous selection.
const delta = isReverse ? -1 : 1;
const nextSelectionIndex = (currentSelectionIndex + delta + selections.length) % selections.length;
const nextSelection = selections[nextSelectionIndex];
const unitId = workbook.getUnitId();
const sheetId = worksheet.getSheetId();
if (!selection) {
return false;
}
const { range } = selection;
const primary = selection.primary!;
let startRange = getStartRange(range, primary, direction);
const shortcutExperienceService = accessor.get(ShortcutExperienceService);
const shortcutExperienceParam = shortcutExperienceService.getCurrentBySearch({
unitId,
sheetId,
keycode: KeyCode.TAB,
});
let resultRange;
const { startRow, endRow, startColumn, endColumn } = range;
// for the last cell in the selection, the next cell should be the next selection's start cell.
const isLastCell = isReverse ? primary.startRow === startRow && primary.startColumn === startColumn : primary.endRow === endRow && primary.endColumn === endColumn;
// not single cell should be handled by the move-selection active cell
if (!Rectangle.equals(range, primary)) {
// Handle the situation of moving the active cell within the selection area.
shortcutExperienceService.remove({
unitId,
sheetId,
keycode: KeyCode.TAB,
});
const newPrimary = Tools.deepClone(primary);
const next = isLastCell
? nextSelection.range :
findNextRange(
{
startRow: newPrimary.startRow,
startColumn: newPrimary.startColumn,
endRow: newPrimary.endRow,
endColumn: newPrimary.endColumn,
},
direction,
worksheet,
{
startRow,
endRow,
startColumn,
endColumn,
}
);
const useLeftTopAsDest = isLastCell && isReverse;
const destRange = useLeftTopAsDest ? getCellAtRowCol(next.endRow, next.endColumn, worksheet) : getCellAtRowCol(next.startRow, next.startColumn, worksheet);
resultRange = {
range: isLastCell ? nextSelection.range : Rectangle.clone(range),
primary: {
startRow: destRange.startRow,
startColumn: destRange.startColumn,
endRow: destRange.endRow,
endColumn: destRange.endColumn,
actualRow: useLeftTopAsDest ? destRange.startRow : next.startRow,
actualColumn: useLeftTopAsDest ? destRange.startColumn : next.startColumn,
isMerged: destRange.isMerged,
isMergedMainCell:
destRange.startRow === next.startRow && destRange.startColumn === next.startColumn,
},
style: isLastCell ? nextSelection.style : selection.style,
};
} else {
// Handle the regular situation of moving the selection area.
if (keycode === KeyCode.TAB) {
if (direction === Direction.RIGHT && shortcutExperienceParam == null) {
shortcutExperienceService.addOrUpdate({
unitId,
sheetId,
keycode: KeyCode.TAB,
selection,
});
}
} else {
const selectionRemain = shortcutExperienceParam?.selection;
if (selectionRemain != null) {
const { range, primary } = selectionRemain;
startRange = getStartRange(range, primary, direction);
}
shortcutExperienceService.remove({
unitId,
sheetId,
keycode: KeyCode.TAB,
});
}
/**
* The start range is from the primary selection range.
* If the edited cell is the last row/column of the worksheet, and the submitted operation is Enter/Tab to move down/right, the next selected cell should still be the current cell (not move to the first row/column of the worksheet).
*/
const isLastRowCell = keycode === KeyCode.ENTER && direction === Direction.DOWN && startRange.endRow === worksheet.getMaxRows() - 1;
const isLastColumnCell = keycode === KeyCode.TAB && direction === Direction.RIGHT && startRange.endColumn === worksheet.getMaxColumns() - 1;
const next = (isLastRowCell || isLastColumnCell) ? startRange : findNextRange(startRange, direction, worksheet);
const destRange = getCellAtRowCol(next.startRow, next.startColumn, worksheet);
if (Rectangle.equals(destRange, startRange)) {
return false;
}
resultRange = {
range: Rectangle.clone(destRange),
primary: {
startRow: destRange.startRow,
startColumn: destRange.startColumn,
endRow: destRange.endRow,
endColumn: destRange.endColumn,
actualRow: next.startRow,
actualColumn: next.startColumn,
isMerged: destRange.isMerged,
isMergedMainCell:
destRange.startRow === next.startRow && destRange.startColumn === next.startColumn,
},
};
}
if (isLastCell) {
selections[currentSelectionIndex].primary = null;
selections[nextSelectionIndex] = resultRange;
selectionsService.setSelections(unitId, sheetId, [], SelectionMoveType.MOVE_END);
} else {
selections[currentSelectionIndex] = resultRange;
}
const rs = accessor.get(ICommandService).executeCommand(SetSelectionsOperation.id, {
unitId,
subUnitId: sheetId,
type: SelectionMoveType.MOVE_END,
selections,
reveal: true,
extra: params.extra,
fromCurrentSelection: params.fromCurrentSelection,
});
const renderManagerService = accessor.get(IRenderManagerService);
const selectionService = renderManagerService.getRenderUnitById(unitId)?.with(ISheetSelectionRenderService);
selectionService?.refreshSelectionMoveEnd();
return rs;
},
};
export interface IExpandSelectionCommandParams {
direction: Direction;
jumpOver?: JumpOver;
nextStep?: number;
extra?: string;
}
// Though the command's name is "expand-selection", it actually does not expand but shrink the selection.
// That depends on the position of currentCell and the whole selection.
export const ExpandSelectionCommand: ICommand<IExpandSelectionCommandParams> = {
id: 'sheet.command.expand-selection',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) return false;
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!target) return false;
const { worksheet, unitId, subUnitId } = target;
const selection = getSelectionsService(accessor).getCurrentLastSelection();
if (!selection) return false;
const { jumpOver, direction, extra } = params;
let { range: startRange, primary } = selection;
if (extra === 'formula-editor') {
// When in formula editor, if the current cell is a merged cell, expand the selection to the merged cell range for calculation.
if (startRange.startRow === startRange.endRow && startRange.startColumn === startRange.endColumn) {
const mergedCell = worksheet.getMergedCell(startRange.startRow, startRange.startColumn);
if (mergedCell) {
startRange = {
...startRange,
...mergedCell,
};
}
}
// When in formula editor, try to use the last primary cell in ref selections as the primary cell for calculation.
if (!primary) {
const lastSelectionPrimary = getSelectionsService(accessor).getCurrentLastSelectionPrimaryCell();
if (lastSelectionPrimary && Rectangle.contains(startRange, lastSelectionPrimary)) {
primary = lastSelectionPrimary;
}
}
}
const isShrink = checkIfShrink({ range: startRange, primary }, direction, worksheet);
const destRange = !isShrink
? jumpOver === JumpOver.moveGap
? expandToNextGapRange(startRange, direction, worksheet)
: expandToNextCell(startRange, direction, worksheet)
: jumpOver === JumpOver.moveGap
? shrinkToNextGapRange(
startRange,
{ ...Rectangle.clone(primary), rangeType: RANGE_TYPE.NORMAL },
direction,
worksheet
)
: shrinkToNextCell(startRange, direction, worksheet);
destRange.rangeType = selection.range.rangeType;
if (Rectangle.equals(destRange, startRange)) {
return false;
}
return accessor.get(ICommandService).syncExecuteCommand(SetSelectionsOperation.id, {
unitId,
subUnitId,
type: SelectionMoveType.MOVE_END,
selections: [
{
range: destRange,
primary, // this remains unchanged
},
],
extra,
reveal: true,
});
},
};
export interface ISelectAllCommandParams {
expandToGapFirst?: boolean;
loop?: boolean;
}
/**
* This command expand selection to all neighbor ranges. If there are no neighbor ranges. Select the whole sheet.
*/
export const SelectAllCommand: ICommand<ISelectAllCommandParams> = {
id: 'sheet.command.select-all',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params = { expandToGapFirst: true, loop: false }) => {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!target) return false;
const selection = getSelectionsService(accessor).getCurrentLastSelection();
if (!selection) return false;
const selectAllService = accessor.get(SelectAllService);
const { worksheet, unitId, subUnitId } = target;
const id = `${unitId}|${subUnitId}`;
if (id !== selectAllService.selectedRangeWorksheet) {
selectAllService.rangesStack = [];
selectAllService.selectedRangeWorksheet = id;
}
const maxRow = worksheet.getMaxRows();
const maxCol = worksheet.getMaxColumns();
const { expandToGapFirst, loop } = params;
const { range, primary } = selection;
const isWholeSheetSelected =
range.endColumn === maxCol - 1 &&
range.endRow === maxRow - 1 &&
range.startRow === 0 &&
range.startColumn === 0;
if (!selectAllService.rangesStack.some((s) => Rectangle.equals(s, range))) {
selectAllService.rangesStack = [];
selectAllService.rangesStack.push(range);
}
let destRange: IRange;
// determined what kind of adjustment it should get
if (isWholeSheetSelected) {
if (loop) {
const currentSelectionIndex = selectAllService.rangesStack.findIndex((s) => Rectangle.equals(s, range));
if (currentSelectionIndex !== selectAllService.rangesStack.length - 1) {
return false;
}
destRange = selectAllService.rangesStack[0];
} else {
return false;
}
} else if (expandToGapFirst) {
destRange = expandToContinuousRange(range, { left: true, right: true, up: true, down: true }, worksheet);
if (Rectangle.equals(destRange, range)) {
// already to gaps
destRange = expandToWholeSheet(worksheet);
}
} else {
destRange = expandToWholeSheet(worksheet);
}
if (!selectAllService.rangesStack.some((s) => Rectangle.equals(s, destRange))) {
selectAllService.rangesStack.push(destRange);
}
const redos: IMutationInfo[] = [];
redos.push({
id: SetSelectionsOperation.id,
params: {
unitId,
subUnitId,
reveal: true,
selections: [
{
range: destRange,
primary, // this remains unchanged
},
],
},
});
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const interceptor = sheetInterceptorService.onCommandExecute({
id: SelectAllCommand.id,
params: { range },
});
if (interceptor.redos.length) {
redos.push(...interceptor.redos);
}
const commandService = accessor.get(ICommandService);
const result = sequenceExecute(redos, commandService);
return result.result;
},
};

View File

@@ -0,0 +1,88 @@
/**
* 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, ICommand, IRange } from '@univerjs/core';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
} from '@univerjs/core';
import {
getSheetCommandTarget,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { AutoWidthController } from '../../controllers/auto-width.controller';
export interface ISetWorksheetColIsAutoWidthCommandParams {
unitId?: string;
subUnitId?: string;
ranges?: IRange[]; // For Facade API
}
export const SetWorksheetColAutoWidthCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.set-col-auto-width',
handler: (accessor: IAccessor, params?: ISetWorksheetColIsAutoWidthCommandParams) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
let ranges = [];
if (params?.ranges) {
ranges = [...params.ranges];
} else {
const selections = selectionManagerService.getCurrentSelections();
for (let i = 0; i < selections.length; i++) {
ranges.push(selections[i].range);
}
}
if (!ranges?.length) {
return false;
}
const redoMutationParams: Required<ISetWorksheetColIsAutoWidthCommandParams> = {
unitId,
subUnitId,
ranges,
};
// undos redos comes from auto-width.controller
const { redos, undos } = accessor.get(AutoWidthController).getUndoRedoParamsOfColWidth(redoMutationParams);
const result = sequenceExecute([...redos], commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [...undos],
redoMutations: [...redos],
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,43 @@
/**
* 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, ICommandService, IUniverInstanceService } from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { SHEET_ZOOM_RANGE } from '../../common/keys';
import { SetZoomRatioCommand } from './set-zoom-ratio.command';
export interface ISetZoomRatioFromToolbarCommandParams {
value: number;
}
export const SetZoomRatioFromToolbarCommand: ICommand<ISetZoomRatioFromToolbarCommandParams> = {
id: 'sheet.command.set-zoom-ratio-from-toolbar',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params || !Number.isFinite(params.value)) return false;
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!target) return false;
const zoom = Math.min(SHEET_ZOOM_RANGE[1], Math.max(SHEET_ZOOM_RANGE[0], params.value));
return accessor.get(ICommandService).executeCommand(SetZoomRatioCommand.id, {
unitId: target.unitId,
subUnitId: target.subUnitId,
zoomRatio: zoom / 100,
});
},
};

View File

@@ -0,0 +1,90 @@
/**
* 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, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, ICommandService, IUniverInstanceService } from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { SHEET_ZOOM_RANGE } from '../../common/keys';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { SetZoomRatioOperation } from '../operations/set-zoom-ratio.operation';
export interface ISetZoomRatioCommandParams {
zoomRatio: number;
unitId: string;
subUnitId: string;
}
export interface IChangeZoomRatioCommandParams {
reset?: boolean;
delta: number;
}
/**
* Zoom
*/
export const ChangeZoomRatioCommand: ICommand<IChangeZoomRatioCommandParams> = {
id: 'sheet.command.change-zoom-ratio',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { worksheet, subUnitId, unitId } = target;
const { delta, reset } = params;
let zoom = reset ? 100 : Math.round((worksheet.getConfig().zoomRatio + delta) * 100);
zoom = Math.max(SHEET_ZOOM_RANGE[0], zoom);
zoom = Math.min(SHEET_ZOOM_RANGE[1], zoom);
const zoomRatio = zoom / 100;
const editorBridgeService = accessor.get(IEditorBridgeService);
const state = editorBridgeService.isVisible();
if ((state.unitId === unitId || state.unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) && state.visible) return false;
return accessor.get(ICommandService).executeCommand(SetZoomRatioOperation.id, {
unitId,
subUnitId,
zoomRatio,
});
},
};
export const SetZoomRatioCommand: ICommand<ISetZoomRatioCommandParams> = {
id: 'sheet.command.set-zoom-ratio',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) {
return false;
}
const { unitId, subUnitId, zoomRatio } = params;
const editorBridgeService = accessor.get(IEditorBridgeService);
const state = editorBridgeService.isVisible();
if ((state.unitId === unitId || state.unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) && state.visible) return false;
return accessor.get(ICommandService).executeCommand(SetZoomRatioOperation.id, {
unitId,
subUnitId,
zoomRatio,
});
},
};

View File

@@ -0,0 +1,31 @@
/**
* 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, ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ISheetBarService } from '../../services/sheet-bar/sheet-bar.service';
export const ShowMenuListCommand: ICommand = {
id: 'ui-sheet.command.show-menu-list',
type: CommandType.OPERATION,
handler: async (accessor: IAccessor) => {
const sheetBarService = accessor.get(ISheetBarService);
sheetBarService.triggerSheetBarMenu();
return true;
},
};

View File

@@ -0,0 +1,649 @@
/**
* 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 { ICellData, IRange, ISelection, ISelectionCell, Nullable, ObjectMatrix, Worksheet } from '@univerjs/core';
import { Direction, getReverseDirection, RANGE_TYPE, Rectangle } from '@univerjs/core';
import { alignToMergedCellsBorders } from '@univerjs/sheets';
export enum MergeType {
MergeAll = 'mergeAll',
MergeVertical = 'mergeVertical',
MergeHorizontal = 'mergeHorizontal',
}
export interface IExpandParams {
left?: boolean;
right?: boolean;
up?: boolean;
down?: boolean;
}
// TODO@wzhudev: methods in this file should use `worksheet.getCell()` instead of using raw data
export function findNextRange(
startRange: IRange,
direction: Direction,
worksheet: Worksheet,
boundary?: IRange,
isFindNext: boolean = true,
nextStep: number = 1,
isGoBack: boolean = true
): IRange {
let destRange: IRange = { ...startRange };
/**
* Set the boundaries for moving the selection area.
* Here you can customize the boundaries to implement the ability to move the primary within the selection area.
*/
if (boundary == null) {
boundary = {
startRow: 0,
endRow: worksheet.getRowCount() - 1,
startColumn: 0,
endColumn: worksheet.getColumnCount() - 1,
};
}
let next: number;
switch (direction) {
case Direction.UP:
next = startRange.startRow - nextStep;
while (next > -1 && !worksheet.getRowVisible(next)) {
next -= 1;
}
if (next >= boundary.startRow) {
destRange.startRow = next;
destRange.endRow = next;
} else if (isGoBack) {
destRange.startRow = boundary.endRow;
destRange.endRow = boundary.endRow;
if (isFindNext) {
destRange = findNextRange(destRange, Direction.LEFT, worksheet, boundary, false);
}
}
break;
case Direction.DOWN:
next = startRange.endRow + nextStep;
while (next < worksheet.getRowCount() && !worksheet.getRowVisible(next)) {
next += 1;
}
if (next <= boundary.endRow) {
destRange.startRow = next;
destRange.endRow = next;
} else if (isGoBack) {
destRange.startRow = boundary.startRow;
destRange.endRow = boundary.startRow;
if (isFindNext) {
destRange = findNextRange(destRange, Direction.RIGHT, worksheet, boundary, false);
}
}
break;
case Direction.LEFT:
next = startRange.startColumn - nextStep;
while (next > -1 && !worksheet.getColVisible(next)) {
next -= 1;
}
if (next >= boundary.startColumn) {
destRange.startColumn = next;
destRange.endColumn = next;
} else if (isGoBack) {
destRange.startColumn = boundary.endColumn;
destRange.endColumn = boundary.endColumn;
if (isFindNext) {
destRange = findNextRange(destRange, Direction.UP, worksheet, boundary, false);
}
}
break;
case Direction.RIGHT:
next = startRange.endColumn + nextStep;
while (next < worksheet.getColumnCount() && !worksheet.getColVisible(next)) {
next += 1;
}
if (next <= boundary.endColumn) {
destRange.startColumn = next;
destRange.endColumn = next;
} else if (isGoBack) {
destRange.startColumn = boundary.startColumn;
destRange.endColumn = boundary.startColumn;
if (isFindNext) {
destRange = findNextRange(destRange, Direction.DOWN, worksheet, boundary, false);
}
}
break;
default:
break;
}
return destRange;
}
export function findNextGapRange(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
const destRange = { ...startRange };
const { startRow, startColumn, endRow, endColumn } = getEdgeOfRange(startRange, direction, worksheet);
let currentPositionHasValue = rangeHasValue(worksheet, startRow, startColumn, endRow, endColumn).hasValue;
let firstMove = true;
let shouldContinue = true;
while (shouldContinue) {
if (Direction.UP === direction) {
let next = destRange.startRow - 1;
while (next > -1 && !worksheet.getRowVisible(next)) {
next -= 1;
}
if (next === -1) {
shouldContinue = false;
break;
}
const { hasValue: nextRangeHasValue, matrix } = rangeHasValue(
worksheet,
next,
destRange.startColumn,
next,
destRange.endColumn
);
if (currentPositionHasValue && !nextRangeHasValue && !firstMove) {
shouldContinue = false;
break;
} else {
if (matrix.getLength() !== 0) {
// update searching ranges
let min = next;
matrix.forValue((row) => {
min = Math.min(row, min);
});
destRange.startRow = min;
} else {
destRange.startRow = next;
}
destRange.endRow = destRange.startRow;
if (!currentPositionHasValue && nextRangeHasValue) {
shouldContinue = false;
break;
}
currentPositionHasValue = nextRangeHasValue;
firstMove = false;
}
}
if (Direction.DOWN === direction) {
let next = destRange.endRow + 1;
while (next < worksheet.getRowCount() && !worksheet.getRowVisible(next)) {
next += 1;
}
if (next === worksheet.getRowCount()) {
shouldContinue = false;
break;
}
const { hasValue: nextRangeHasValue, matrix } = rangeHasValue(
worksheet,
next,
destRange.startColumn,
next,
destRange.endColumn
);
if (currentPositionHasValue && !nextRangeHasValue && !firstMove) {
shouldContinue = false;
break;
} else {
if (matrix.getLength() !== 0) {
let max = next;
matrix.forValue((row, _, value) => {
max = Math.max(row + (value.rowSpan || 1) - 1, max);
});
destRange.endRow = max;
} else {
destRange.endRow = next;
}
destRange.startRow = destRange.endRow;
if (!currentPositionHasValue && nextRangeHasValue) {
shouldContinue = false;
break;
}
currentPositionHasValue = nextRangeHasValue;
firstMove = false;
}
}
if (Direction.LEFT === direction) {
let next = destRange.startColumn - 1;
while (next > -1 && !worksheet.getColVisible(next)) {
next -= 1;
}
if (next === -1) {
shouldContinue = false;
break;
}
const { hasValue: nextRangeHasValue, matrix } = rangeHasValue(
worksheet,
destRange.startRow,
next,
destRange.endRow,
next
);
if (currentPositionHasValue && !nextRangeHasValue && !firstMove) {
shouldContinue = false;
break;
} else {
if (matrix.getLength() !== 0) {
let min = next;
matrix.forValue((_, col) => {
min = Math.min(col, min);
});
destRange.startColumn = min;
} else {
destRange.startColumn = next;
}
destRange.endColumn = destRange.startColumn;
if (!currentPositionHasValue && nextRangeHasValue) {
shouldContinue = false;
break;
}
currentPositionHasValue = nextRangeHasValue;
firstMove = false;
}
}
if (Direction.RIGHT === direction) {
let next = destRange.endColumn + 1;
while (next < worksheet.getColumnCount() && !worksheet.getColVisible(next)) {
next += 1;
}
if (next === worksheet.getColumnCount()) {
shouldContinue = false;
break;
}
const { hasValue: nextRangeHasValue, matrix } = rangeHasValue(
worksheet,
destRange.startRow,
next,
destRange.endRow,
next
);
if (currentPositionHasValue && !nextRangeHasValue && !firstMove) {
shouldContinue = false;
break;
} else {
if (matrix.getLength() !== 0) {
let max = next;
matrix.forValue((_, col, value) => {
max = Math.max(col + (value.colSpan || 1) - 1, max);
});
destRange.endColumn = max;
} else {
destRange.endColumn = next;
}
destRange.startColumn = destRange.endColumn;
if (!currentPositionHasValue && nextRangeHasValue) {
shouldContinue = false;
break;
}
currentPositionHasValue = nextRangeHasValue;
firstMove = false;
}
}
}
return alignToMergedCellsBorders(destRange, worksheet, true);
}
export function findNextRangeExpand(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
return findNextRange(startRange, direction, worksheet, undefined, false, 1, false);
}
export function expandToNextGapRange(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
const next = findNextGapRange(startRange, direction, worksheet);
return alignToMergedCellsBorders(Rectangle.union(next, startRange), worksheet, true);
}
export function expandToNextCell(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
const next = findNextRangeExpand(startRange, direction, worksheet);
const destRange: IRange = {
startRow: Math.min(startRange.startRow, next.startRow),
startColumn: Math.min(startRange.startColumn, next.startColumn),
endRow: Math.max(startRange.endRow, next.endRow),
endColumn: Math.max(startRange.endColumn, next.endColumn),
};
return alignToMergedCellsBorders(Rectangle.union(startRange, destRange), worksheet);
}
/**
* This function is considered as a reversed operation of `expandToNextGapCell` but there are slightly differences.
* @param startRange
* @param direction
* @param worksheet
*/
export function shrinkToNextGapRange(
startRange: IRange,
anchorRange: IRange,
direction: Direction,
worksheet: Worksheet
): IRange {
// use `moveToNextGapCell` reversely to get the next going to cell
const reversedDirection = getReverseDirection(direction);
const nextGap = findNextGapRange(getEdgeOfRange(startRange, reversedDirection, worksheet), direction, worksheet);
// if next exceed the startRange we should just expand anchorRange with
if (direction === Direction.UP && nextGap.startRow <= startRange.startRow) {
return alignToMergedCellsBorders(
{ ...anchorRange, startColumn: startRange.startColumn, endColumn: startRange.endColumn },
worksheet,
true
);
}
if (direction === Direction.DOWN && nextGap.endRow >= startRange.endRow) {
return alignToMergedCellsBorders(
{ ...anchorRange, startColumn: startRange.startColumn, endColumn: startRange.endColumn },
worksheet,
true
);
}
if (direction === Direction.LEFT && nextGap.startColumn <= startRange.startColumn) {
return alignToMergedCellsBorders(
{ ...anchorRange, startRow: startRange.startRow, endRow: startRange.endRow },
worksheet,
true
);
}
if (direction === Direction.RIGHT && nextGap.endColumn >= startRange.endColumn) {
return alignToMergedCellsBorders(
{ ...anchorRange, startRow: startRange.startRow, endRow: startRange.endRow },
worksheet,
true
);
}
return Rectangle.union(Rectangle.clone(anchorRange), nextGap);
}
/**
* This function is considered as a reversed operation of `expandToNextCell` but there are some slightly differences.
* @param startRange
* @param direction
* @param worksheet
*/
export function shrinkToNextCell(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
// use `moveToNextCell` reversely to get the next going to range
const reversedDirection = getReverseDirection(direction);
const shrinkFromEdge = getEdgeOfRange(startRange, reversedDirection, worksheet);
const otherEdge = getEdgeOfRange(startRange, direction, worksheet);
const next = findNextRangeExpand(shrinkFromEdge, direction, worksheet);
return alignToMergedCellsBorders(Rectangle.union(otherEdge, next), worksheet, false);
}
export function expandToWholeSheet(worksheet: Worksheet): IRange {
// DEBT: RANGE_TYPE.ALL should not use startRow, startColumn, endRow, endColumn
return {
startRow: 0,
startColumn: 0,
endRow: worksheet.getRowCount() - 1,
endColumn: worksheet.getColumnCount() - 1,
rangeType: RANGE_TYPE.ALL,
};
}
function getEdgeOfRange(startRange: IRange, direction: Direction, worksheet: Worksheet): IRange {
let destRange: IRange;
switch (direction) {
case Direction.UP:
destRange = {
startRow: startRange.startRow,
startColumn: startRange.startColumn,
endRow: startRange.startRow,
endColumn: startRange.endColumn,
rangeType: RANGE_TYPE.NORMAL,
};
break;
case Direction.DOWN:
destRange = {
startRow: startRange.endRow,
startColumn: startRange.startColumn,
endRow: startRange.endRow,
endColumn: startRange.endColumn,
rangeType: RANGE_TYPE.NORMAL,
};
break;
case Direction.LEFT:
destRange = {
startRow: startRange.startRow,
startColumn: startRange.startColumn,
endRow: startRange.endRow,
endColumn: startRange.startColumn,
rangeType: RANGE_TYPE.NORMAL,
};
break;
case Direction.RIGHT:
destRange = {
startRow: startRange.startRow,
startColumn: startRange.endColumn,
endRow: startRange.endRow,
endColumn: startRange.endColumn,
rangeType: RANGE_TYPE.NORMAL,
};
break;
default:
throw new Error('Invalid direction');
}
return alignToMergedCellsBorders(destRange, worksheet, false);
}
function rangeHasValue(
worksheet: Worksheet,
row: number,
col: number,
rowEnd: number,
colEnd: number
): { hasValue: boolean; matrix: ObjectMatrix<ICellData & { rowSpan?: number; colSpan?: number }> } {
let hasValue = false;
const matrix = worksheet.getMatrixWithMergedCells(row, col, rowEnd, colEnd).forValue((_, __, value) => {
if (cellHasValue(value)) {
hasValue = true;
return false; // stop looping
}
});
return {
hasValue,
matrix,
};
}
export function getStartRange(range: IRange, primary: Nullable<ISelectionCell>, direction: Direction): IRange {
const ret = Rectangle.clone(range);
if (primary == null) {
return ret;
}
switch (direction) {
case Direction.UP:
case Direction.DOWN:
ret.startColumn = ret.endColumn = primary.actualColumn;
break;
case Direction.LEFT:
case Direction.RIGHT:
ret.startRow = ret.endRow = primary.actualRow;
break;
}
return ret;
}
export function checkIfShrink(selection: ISelection, direction: Direction, worksheet: Worksheet): boolean {
const { primary, range } = selection;
const startRange: IRange = Rectangle.clone(range);
switch (direction) {
case Direction.UP:
case Direction.DOWN:
startRange.startRow = primary?.startRow ?? range.startRow;
startRange.endRow = primary?.endRow ?? range.startRow;
break;
case Direction.LEFT:
case Direction.RIGHT:
startRange.startColumn = primary?.startColumn ?? range.startColumn;
startRange.endColumn = primary?.endColumn ?? range.startColumn;
break;
}
const anchorRange = getEdgeOfRange(startRange, direction, worksheet);
switch (direction) {
case Direction.DOWN:
return range.startRow < anchorRange.startRow;
case Direction.UP:
return range.endRow > anchorRange.endRow;
case Direction.LEFT:
return anchorRange.endColumn < range.endColumn;
case Direction.RIGHT:
return anchorRange.startColumn > range.startColumn;
}
}
/**
* Determine whether the entire row is selected
* @param allRowRanges Range of all rows
* @param ranges Range of selected rows
* @returns Whether the entire row is selected
*/
export function isAllRowsCovered(allRowRanges: IRange[], ranges: IRange[]): boolean {
// Find the minimum start point and maximum end point in all row ranges
let start = allRowRanges[0].startRow;
let end = allRowRanges[0].endRow;
allRowRanges.forEach((range) => {
const { startRow, endRow } = range;
start = Math.min(start, startRow);
end = Math.max(end, endRow);
});
const covered = new Array(end - start + 1).fill(false);
// Mark true in ranges
ranges.forEach((range) => {
const { startRow, endRow } = range;
for (let i = Math.max(startRow, start); i <= Math.min(endRow, end); i++) {
covered[i - start] = true;
}
});
// Check if every range in allRowRanges is covered
return allRowRanges.every((range) => {
const { startRow, endRow } = range;
for (let i = startRow; i <= endRow; i++) {
if (!covered[i - start]) {
return false;
}
}
return true;
});
}
/**
* Determine whether the entire column is selected
* @param allColumnRanges Range of all columns
* @param ranges Range of selected columns
* @returns Whether the entire column is selected
*/
export function isAllColumnsCovered(allColumnRanges: IRange[], ranges: IRange[]): boolean {
// Find the minimum start point and maximum end point in all column ranges
let start = allColumnRanges[0].startColumn;
let end = allColumnRanges[0].endColumn;
allColumnRanges.forEach((range) => {
const { startColumn, endColumn } = range;
start = Math.min(start, startColumn);
end = Math.max(end, endColumn);
});
const covered = new Array(end - start + 1).fill(false);
// Mark true in ranges
ranges.forEach((range) => {
const { startColumn, endColumn } = range;
for (let i = Math.max(startColumn, start); i <= Math.min(endColumn, end); i++) {
covered[i - start] = true;
}
});
// Check if every range in allColumnRanges is covered
return allColumnRanges.every((range) => {
const { startColumn, endColumn } = range;
for (let i = startColumn; i <= endColumn; i++) {
if (!covered[i - start]) {
return false;
}
}
return true;
});
}
export function getMergeableSelectionsByType(type: MergeType, selections: Nullable<IRange[]>): Nullable<IRange[]> {
if (!selections) return null;
if (type === MergeType.MergeAll) {
return selections.filter((selection) => {
if (selection.startRow === selection.endRow && selection.startColumn === selection.endColumn) {
return false;
}
return true;
});
} else if (type === MergeType.MergeVertical) {
return selections.filter((selection) => {
if (selection.startRow === selection.endRow) {
return false;
}
return true;
});
} else if (type === MergeType.MergeHorizontal) {
return selections.filter((selection) => {
if (selection.startColumn === selection.endColumn) {
return false;
}
return true;
});
}
return selections;
}
function cellHasValue(cell: ICellData): boolean {
return (cell.v !== undefined && cell.v !== null && cell.v !== '') || cell.p !== undefined;
}

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 { ICommand, Workbook } from '@univerjs/core';
import { CommandType, ICommandService, IUndoRedoService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { AddWorksheetProtectionCommand, DeleteWorksheetProtectionCommand, WorksheetProtectionRuleModel } from '@univerjs/sheets';
export const DeleteWorksheetProtectionFormSheetBarCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.delete-worksheet-protection-from-sheet-bar',
async handler(accessor, params) {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const worksheetProtectionRuleModel = accessor.get(WorksheetProtectionRuleModel);
const univerInstanceService = accessor.get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook?.getActiveSheet();
const unitId = workbook.getUnitId();
if (!worksheet) {
return false;
}
const subUnitId = worksheet.getSheetId();
const rule = worksheetProtectionRuleModel.getRule(unitId, subUnitId);
const result = await commandService.executeCommand(DeleteWorksheetProtectionCommand.id, {
unitId,
subUnitId,
});
if (result) {
const redoMutations = [{ id: DeleteWorksheetProtectionCommand.id, params: { unitId, subUnitId } }];
const undoMutations = [{ id: AddWorksheetProtectionCommand.id, params: { unitId, rule } }];
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations,
undoMutations,
});
}
return true;
},
};
export const ChangeSheetProtectionFromSheetBarCommand: ICommand = {
type: CommandType.COMMAND,
id: 'sheet.command.change-sheet-protection-from-sheet-bar',
async handler(accessor) {
const commandService = accessor.get(ICommandService);
await commandService.executeCommand('sheet-permission.operation.openDialog');
return true;
},
};

View File

@@ -0,0 +1,158 @@
/**
* 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 { IUniverInstanceService } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import { IFormatPainterService } from '../../../services/format-painter/format-painter.service';
import { SetActivateCellEditOperation } from '../activate-cell-edit.operation';
import { ScrollToRangeOperation } from '../scroll-to-range.operation';
import { SetScrollOperation } from '../scroll.operation';
import { SetFormatPainterOperation } from '../set-format-painter.operation';
import { SetZoomRatioOperation } from '../set-zoom-ratio.operation';
function createAccessor(pairs: Array<[unknown, unknown]>) {
const map = new Map<unknown, unknown>(pairs);
return {
get(token: unknown) {
if (!map.has(token)) {
throw new Error(`Unknown token: ${String(token)}`);
}
return map.get(token);
},
} as any;
}
describe('sheets-ui basic operations', () => {
it('SetFormatPainterOperation should guard empty params and update service', () => {
const setStatus = vi.fn();
const accessor = createAccessor([[IFormatPainterService, { setStatus }]]);
expect(SetFormatPainterOperation.handler(accessor, undefined as any)).toBe(false);
expect(SetFormatPainterOperation.handler(accessor, { status: 'ONCE' as any })).toBe(true);
expect(setStatus).toHaveBeenCalledWith('ONCE');
});
it('SetActivateCellEditOperation should update current edit cell state', () => {
const setEditCell = vi.fn();
const accessor = createAccessor([[IEditorBridgeService, { setEditCell }]]);
expect(SetActivateCellEditOperation.handler(accessor, { unitId: 'u1' } as any)).toBe(true);
expect(setEditCell).toHaveBeenCalledWith({ unitId: 'u1' });
});
it('SetScrollOperation should compute final offsets with screen ratios', () => {
const emitRawScrollParam = vi.fn();
const renderUnit = {
with: vi.fn(() => ({ emitRawScrollParam })),
engine: { width: 200, height: 100 },
};
const renderManagerService = {
getRenderUnitById: vi.fn()
.mockReturnValueOnce(renderUnit)
.mockReturnValueOnce(renderUnit),
};
const accessor = createAccessor([[IRenderManagerService, renderManagerService]]);
expect(SetScrollOperation.handler(accessor, {
unitId: 'u1',
sheetId: 's1',
offsetX: 10,
offsetY: 20,
screenRatioX: 0.5,
screenRatioY: 0.25,
} as any)).toBe(true);
expect(emitRawScrollParam).toHaveBeenCalledWith(expect.objectContaining({
unitId: 'u1',
sheetId: 's1',
offsetX: 110,
offsetY: 45,
}));
});
it('SetScrollOperation should return false for empty params or missing render unit', () => {
const accessor = createAccessor([[IRenderManagerService, { getRenderUnitById: vi.fn() }]]);
expect(SetScrollOperation.handler(accessor, undefined as any)).toBe(false);
const renderManagerService = {
getRenderUnitById: vi.fn()
.mockReturnValueOnce({ with: () => ({ emitRawScrollParam: vi.fn() }) })
.mockReturnValueOnce(null),
};
const accessor2 = createAccessor([[IRenderManagerService, renderManagerService]]);
expect(SetScrollOperation.handler(accessor2, { unitId: 'u1', sheetId: 's1' } as any)).toBe(false);
});
it('SetZoomRatioOperation should delegate to zoom controller', () => {
const updateZoom = vi.fn(() => true);
const renderManagerService = {
getRenderUnitById: vi.fn(() => ({ with: () => ({ updateZoom }) })),
};
const accessor = createAccessor([[IRenderManagerService, renderManagerService]]);
expect(SetZoomRatioOperation.handler(accessor, {
unitId: 'u1',
subUnitId: 's1',
zoomRatio: 1.25,
})).toBe(true);
expect(updateZoom).toHaveBeenCalledWith('s1', 1.25);
const accessor2 = createAccessor([[IRenderManagerService, { getRenderUnitById: () => null }]]);
expect(SetZoomRatioOperation.handler(accessor2, { unitId: 'u1', subUnitId: 's1', zoomRatio: 1 } as any)).toBe(false);
});
it('ScrollToRangeOperation should guard params and call scroll controller', () => {
const scrollToRange = vi.fn(() => true);
const getRenderUnitById = vi.fn(() => ({ with: () => ({ scrollToRange }) }));
const accessor = createAccessor([
[IUniverInstanceService, { getCurrentUnitOfType: () => ({ getUnitId: () => 'u1' }) }],
[IRenderManagerService, { getRenderUnitById }],
]);
expect(ScrollToRangeOperation.handler(accessor, undefined as any)).toBe(false);
expect(ScrollToRangeOperation.handler(accessor, {
range: { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 },
forceTop: true,
forceLeft: false,
} as any)).toBe(true);
expect(scrollToRange).toHaveBeenCalledWith(
{ startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 },
true,
false
);
expect(getRenderUnitById).toHaveBeenCalledWith('u1');
expect(ScrollToRangeOperation.handler(accessor, {
unitId: 'embedded-u1',
range: { startRow: 2, endRow: 3, startColumn: 2, endColumn: 3 },
} as any)).toBe(true);
expect(getRenderUnitById).toHaveBeenLastCalledWith('embedded-u1');
const missingRenderAccessor = createAccessor([
[IUniverInstanceService, { getCurrentUnitOfType: () => ({ getUnitId: () => 'missing' }) }],
[IRenderManagerService, { getRenderUnitById: () => null }],
]);
expect(ScrollToRangeOperation.handler(missingRenderAccessor, {
range: { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
} as any)).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
/**
* 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 { ICommandService, IConfigService, IUniverInstanceService } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import {
SetCellEditVisibleArrowOperation,
SetCellEditVisibleOperation,
SetCellEditVisibleWithF2Operation,
} from '../cell-edit.operation';
function createAccessor(pairs: Array<[unknown, unknown]>) {
const map = new Map<unknown, unknown>(pairs);
return {
get(token: unknown) {
if (!map.has(token)) {
throw new Error(`Unknown token: ${String(token)}`);
}
return map.get(token);
},
} as any;
}
describe('cell edit operations', () => {
it('SetCellEditVisibleOperation guards empty params and disable-edit mode', () => {
const changeVisible = vi.fn();
const accessor = createAccessor([
[IConfigService, { getConfig: vi.fn(() => ({ disableEdit: true })) }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'u1' })) }],
[IEditorBridgeService, { changeVisible }],
]);
expect(SetCellEditVisibleOperation.handler(accessor, undefined as any)).toBe(false);
expect(SetCellEditVisibleOperation.handler(accessor, { visible: true } as any)).toBe(false);
expect(changeVisible).not.toHaveBeenCalled();
});
it('SetCellEditVisibleOperation returns false when no active workbook', () => {
const accessor = createAccessor([
[IConfigService, { getConfig: vi.fn(() => ({ disableEdit: false })) }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
[IEditorBridgeService, { changeVisible: vi.fn() }],
]);
expect(SetCellEditVisibleOperation.handler(accessor, { visible: false } as any)).toBe(false);
});
it('SetCellEditVisibleOperation falls back to workbook unitId', () => {
const changeVisible = vi.fn();
const accessor = createAccessor([
[IConfigService, { getConfig: vi.fn(() => ({ disableEdit: false })) }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'workbook-u1' })) }],
[IEditorBridgeService, { changeVisible }],
]);
expect(SetCellEditVisibleOperation.handler(accessor, { visible: true } as any)).toBe(true);
expect(changeVisible).toHaveBeenCalledWith({ visible: true, unitId: 'workbook-u1' });
});
it('SetCellEditVisibleWithF2Operation respects workbook existence and delegates command execution', () => {
const syncExecuteCommand = vi.fn(() => true);
const commandAccessor = createAccessor([
[ICommandService, { syncExecuteCommand }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'wb-u2' })) }],
]);
expect(SetCellEditVisibleWithF2Operation.handler(commandAccessor, { visible: true } as any)).toBe(true);
expect(syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, {
visible: true,
unitId: 'wb-u2',
});
const noWorkbookAccessor = createAccessor([
[ICommandService, { syncExecuteCommand: vi.fn() }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
]);
expect(SetCellEditVisibleWithF2Operation.handler(noWorkbookAccessor, { visible: true } as any)).toBe(false);
});
it('SetCellEditVisibleArrowOperation is a no-op event operation', () => {
expect(SetCellEditVisibleArrowOperation.handler({} as any, undefined as any)).toBe(true);
});
});

View File

@@ -0,0 +1,229 @@
/**
* 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 { Injector, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { IEditorService } from '@univerjs/docs-ui';
import { UnitObject } from '@univerjs/protocol';
import { EditStateEnum, ViewStateEnum } from '@univerjs/sheets';
import { IDialogService, ISidebarService } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import {
UNIVER_SHEET_PERMISSION_DIALOG,
UNIVER_SHEET_PERMISSION_DIALOG_ID,
UNIVER_SHEET_PERMISSION_PANEL,
} from '../../../consts/permission';
import { SheetPermissionPanelModel } from '../../../services/permission/sheet-permission-panel.model';
import { SheetPermissionUserManagerService } from '../../../services/permission/sheet-permission-user-list.service';
import { ISheetBarService } from '../../../services/sheet-bar/sheet-bar.service';
import { DEFINED_NAME_CONTAINER } from '../../../views/defined-name/component-name';
import { RenameSheetOperation } from '../rename-sheet.operation';
import { SheetPermissionOpenDialogOperation } from '../sheet-permission-open-dialog.operation';
import { SheetPermissionOpenPanelOperation } from '../sheet-permission-open-panel.operation';
import { SidebarDefinedNameOperation } from '../sidebar-defined-name.operation';
class SidebarServiceForTest {
readonly openCalls: unknown[] = [];
closeCount = 0;
open(props: unknown) {
this.openCalls.push(props);
}
close() {
this.closeCount += 1;
}
}
class DialogServiceForTest {
readonly openCalls: unknown[] = [];
readonly closeIds: string[] = [];
open(props: unknown) {
this.openCalls.push(props);
}
close(id: string) {
this.closeIds.push(id);
}
}
class SheetBarServiceForTest {
readonly renameIds: string[] = [];
setRenameId(subUnitId: string) {
this.renameIds.push(subUnitId);
}
}
class EditorServiceForTest {
}
class LocaleServiceForTest {
t(key: string) {
return `localized:${key}`;
}
}
class WorkbookForTest {
getUnitId() {
return 'unit-1';
}
getActiveSheet() {
return {
getSheetId: () => 'sheet-1',
};
}
}
class UniverInstanceWithSheet {
getCurrentUnitOfType() {
return new WorkbookForTest();
}
}
class UniverInstanceWithoutSheet {
getCurrentUnitOfType() {
return null;
}
}
function addUseClass(injector: Injector, token: unknown, useClass: unknown) {
injector.add([token, { useClass }] as any);
}
function createOperationInjector(univerClass: typeof UniverInstanceWithSheet | typeof UniverInstanceWithoutSheet = UniverInstanceWithSheet) {
const injector = new Injector();
addUseClass(injector, ISidebarService, SidebarServiceForTest);
addUseClass(injector, IDialogService, DialogServiceForTest);
addUseClass(injector, ISheetBarService, SheetBarServiceForTest);
addUseClass(injector, IEditorService, EditorServiceForTest);
addUseClass(injector, LocaleService, LocaleServiceForTest);
addUseClass(injector, IUniverInstanceService, univerClass);
injector.add([SheetPermissionPanelModel]);
injector.add([SheetPermissionUserManagerService]);
return injector;
}
describe('SidebarDefinedNameOperation', () => {
it('returns false when there is no sheet command target', async () => {
const injector = createOperationInjector(UniverInstanceWithoutSheet);
await expect(SidebarDefinedNameOperation.handler(injector, { value: 'open' })).resolves.toBe(false);
const sidebarService = injector.get(ISidebarService) as unknown as SidebarServiceForTest;
expect(sidebarService.openCalls).toEqual([]);
expect(sidebarService.closeCount).toBe(0);
});
it('opens sidebar with localized title when value is open', async () => {
const injector = createOperationInjector();
await expect(SidebarDefinedNameOperation.handler(injector, { value: 'open' })).resolves.toBe(true);
const sidebarService = injector.get(ISidebarService) as unknown as SidebarServiceForTest;
expect(sidebarService.openCalls).toEqual([expect.objectContaining({
id: DEFINED_NAME_CONTAINER,
width: 333,
header: { title: 'localized:sheets-ui.definedName.featureTitle' },
children: { label: DEFINED_NAME_CONTAINER },
})]);
(sidebarService.openCalls[0] as { onClose: () => void }).onClose();
expect(sidebarService.closeCount).toBe(0);
});
it('closes sidebar for close/default action', async () => {
const injector = createOperationInjector();
await expect(SidebarDefinedNameOperation.handler(injector, { value: 'close' })).resolves.toBe(true);
await expect(SidebarDefinedNameOperation.handler(injector, { value: 'other' })).resolves.toBe(true);
expect((injector.get(ISidebarService) as unknown as SidebarServiceForTest).closeCount).toBe(2);
});
});
describe('sheet bar and permission operations', () => {
it('marks the requested sheet as being renamed from the sheet bar', async () => {
const injector = createOperationInjector();
await expect(RenameSheetOperation.handler(injector, { subUnitId: 'sheet-2' })).resolves.toBe(true);
await expect(RenameSheetOperation.handler(injector)).resolves.toBe(true);
expect((injector.get(ISheetBarService) as unknown as SheetBarServiceForTest).renameIds).toEqual(['sheet-2']);
});
it('opens the permission dialog after closing find and replace', async () => {
const injector = createOperationInjector();
await expect(SheetPermissionOpenDialogOperation.handler(injector)).resolves.toBe(true);
const dialogService = injector.get(IDialogService) as unknown as DialogServiceForTest;
expect(dialogService.closeIds).toEqual(['DESKTOP_FIND_REPLACE_DIALOG']);
expect(dialogService.openCalls).toEqual([expect.objectContaining({
id: UNIVER_SHEET_PERMISSION_DIALOG_ID,
title: { title: 'sheets-ui.permission.dialog.allowedPermissionType' },
children: { label: UNIVER_SHEET_PERMISSION_DIALOG },
width: 393,
})]);
(dialogService.openCalls[0] as { onClose: () => void }).onClose();
expect(dialogService.closeIds).toEqual(['DESKTOP_FIND_REPLACE_DIALOG', UNIVER_SHEET_PERMISSION_DIALOG_ID]);
});
it('opens the permission panel with rule context and resets panel state on close', async () => {
const injector = createOperationInjector();
const model = injector.get(SheetPermissionPanelModel);
const userManager = injector.get(SheetPermissionUserManagerService);
const rule = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
permissionId: 'permission-1',
unitType: UnitObject.SelectRange,
id: 'rule-1',
ranges: [{ startRow: 0, endRow: 2, startColumn: 0, endColumn: 2 }],
viewState: ViewStateEnum.OthersCanView,
editState: EditStateEnum.OnlyMe,
};
userManager.setCanEditUserList([{ id: 'user-1', role: 1, subject: undefined }]);
userManager.setSelectUserList([{ id: 'user-1', role: 1, subject: undefined }]);
await expect(SheetPermissionOpenPanelOperation.handler(injector, {
fromSheetBar: true,
showDetail: false,
rule,
oldRule: rule,
})).resolves.toBe(true);
const sidebarService = injector.get(ISidebarService) as unknown as SidebarServiceForTest;
expect(model.getVisible()).toBe(true);
expect(sidebarService.openCalls).toEqual([expect.objectContaining({
header: { title: 'sheets-ui.permission.panel.title' },
width: 330,
children: {
label: UNIVER_SHEET_PERMISSION_PANEL,
showDetail: false,
fromSheetBar: true,
rule,
oldRule: rule,
},
})]);
(sidebarService.openCalls[0] as { onClose: () => void }).onClose();
expect(model.getVisible()).toBe(false);
expect(userManager.userList).toEqual([]);
expect(userManager.selectUserList).toEqual([]);
});
});

View File

@@ -0,0 +1,31 @@
/**
* 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 type { ICurrentEditCellParam } from '../../services/editor-bridge.service';
import { CommandType } from '@univerjs/core';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
export const SetActivateCellEditOperation: IOperation<ICurrentEditCellParam> = {
id: 'sheet.operation.set-activate-cell-edit',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const editorBridgeService = accessor.get(IEditorBridgeService);
editorBridgeService.setEditCell(params);
return true;
},
};

View File

@@ -0,0 +1,81 @@
/**
* 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, Workbook } from '@univerjs/core';
import type { IUniverSheetsUIConfig } from '../../config/config';
import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import { CommandType, ICommandService, IConfigService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
export const SetCellEditVisibleOperation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
const configService = accessor.get(IConfigService);
const disableEdit = configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY)?.disableEdit;
if (disableEdit && params.visible) {
return false;
}
const { unitId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return false;
}
const editorBridgeService = accessor.get(IEditorBridgeService);
editorBridgeService.changeVisible({
...params,
unitId: unitId ?? workbook.getUnitId(),
});
return true;
},
};
export const SetCellEditVisibleWithF2Operation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible-f2',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return false;
}
return commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
...params,
unitId: workbook.getUnitId(),
});
},
};
/**
* When the editor is not clicked to change the cursor,
* the arrow keys will exit editing and move the cell.
*/
export const SetCellEditVisibleArrowOperation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible-arrow',
type: CommandType.OPERATION,
handler: () => true,
};

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 type { IAccessor, ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ISheetBarService } from '../../services/sheet-bar/sheet-bar.service';
interface IRenameSheetOperationParams {
subUnitId: string;
}
export const RenameSheetOperation: ICommand = {
id: 'sheet.operation.rename-sheet',
type: CommandType.OPERATION,
handler: async (accessor: IAccessor, params?: IRenameSheetOperationParams) => {
const sheetBarService = accessor.get(ISheetBarService);
if (params) {
sheetBarService.setRenameId(params.subUnitId);
}
return true;
},
};

View File

@@ -0,0 +1,46 @@
/**
* 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 type { IScrollToCellCommandParams } from '../commands/set-scroll.command';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetsScrollRenderController } from '../../controllers/render-controllers/scroll.render-controller';
export const ScrollToRangeOperation: ICommand<IScrollToCellCommandParams> = {
id: 'sheet.operation.scroll-to-range',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
const instanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const unitId = params.unitId ?? instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId();
if (!unitId) {
return false;
}
const renderUnit = renderManagerService.getRenderUnitById(unitId);
if (!renderUnit) {
return false;
}
const scrollController = renderUnit.with(SheetsScrollRenderController);
return scrollController.scrollToRange(params.range, params.forceTop, params.forceLeft);
},
};

View File

@@ -0,0 +1,62 @@
/**
* 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 type { IScrollStateWithSearchParam } from '../../services/scroll-manager.service';
import { CommandType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetScrollManagerService } from '../../services/scroll-manager.service';
export const SetScrollOperation: IOperation<IScrollStateWithSearchParam> = {
id: 'sheet.operation.set-scroll',
type: CommandType.OPERATION,
handler: (accessor, params: IScrollStateWithSearchParam) => {
if (!params) {
return false;
}
// freeze is handled by set-scroll.command.ts
const { unitId, sheetId, offsetX = 0, offsetY = 0, sheetViewStartColumn, sheetViewStartRow, duration, screenRatioX, screenRatioY } = params;
const renderManagerService = accessor.get(IRenderManagerService);
const scrollManagerService = renderManagerService.getRenderUnitById(unitId)!.with(SheetScrollManagerService);
const renderUnit = renderManagerService.getRenderUnitById(unitId);
if (!renderUnit) {
return false;
}
const engine = renderUnit.engine;
const canvasWidth = engine.width;
const canvasHeight = engine.height;
const finalOffsetX = screenRatioX !== undefined ? (canvasWidth * screenRatioX) + offsetX : offsetX;
const finalOffsetY = screenRatioY !== undefined ? (canvasHeight * screenRatioY) + offsetY : offsetY;
scrollManagerService.emitRawScrollParam({
unitId,
sheetId,
offsetX: finalOffsetX,
offsetY: finalOffsetY,
sheetViewStartRow,
sheetViewStartColumn,
duration,
});
return true;
},
};

View File

@@ -0,0 +1,37 @@
/**
* 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 type { FormatPainterStatus } from '../../services/format-painter/format-painter.service';
import { CommandType } from '@univerjs/core';
import { IFormatPainterService } from '../../services/format-painter/format-painter.service';
export interface ISetFormatPainterOperationParams {
status: FormatPainterStatus;
}
export const SetFormatPainterOperation: IOperation<ISetFormatPainterOperationParams> = {
id: 'sheet.operation.set-format-painter',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
const formatPainterService = accessor.get(IFormatPainterService);
formatPainterService.setStatus(params.status);
return true;
},
};

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.
*/
import type { IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetsZoomRenderController } from '../../controllers/render-controllers/zoom.render-controller';
export interface ISetZoomRatioOperationParams {
zoomRatio: number;
unitId: string;
subUnitId: string;
}
export const SetZoomRatioOperation: IOperation<ISetZoomRatioOperationParams> = {
id: 'sheet.operation.set-zoom-ratio',
type: CommandType.OPERATION,
handler: (accessor, params: ISetZoomRatioOperationParams) => {
const renderManagerService = accessor.get(IRenderManagerService);
const renderUnit = renderManagerService.getRenderUnitById(params.unitId);
if (!renderUnit) return false;
return renderUnit.with(SheetsZoomRenderController).updateZoom(params.subUnitId, params.zoomRatio);
},
};

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 { ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { IDialogService } from '@univerjs/ui';
import { UNIVER_SHEET_PERMISSION_DIALOG, UNIVER_SHEET_PERMISSION_DIALOG_ID } from '../../consts/permission';
export const SheetPermissionOpenDialogOperation: ICommand = {
type: CommandType.OPERATION,
id: 'sheet-permission.operation.openDialog',
async handler(accessor, _params) {
const dialogService = accessor.get(IDialogService);
dialogService.close('DESKTOP_FIND_REPLACE_DIALOG');
dialogService.open({
id: UNIVER_SHEET_PERMISSION_DIALOG_ID,
title: { title: 'sheets-ui.permission.dialog.allowedPermissionType' },
children: { label: UNIVER_SHEET_PERMISSION_DIALOG },
width: 393,
onClose: () => dialogService.close(UNIVER_SHEET_PERMISSION_DIALOG_ID),
});
return true;
},
};

View File

@@ -0,0 +1,63 @@
/**
* 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 type { IPermissionPanelRule } from '../../services/permission/sheet-permission-panel.model';
import { CommandType } from '@univerjs/core';
import { ISidebarService } from '@univerjs/ui';
import { UNIVER_SHEET_PERMISSION_PANEL } from '../../consts/permission';
import { SheetPermissionPanelModel } from '../../services/permission/sheet-permission-panel.model';
import { SheetPermissionUserManagerService } from '../../services/permission/sheet-permission-user-list.service';
export interface IPermissionOpenPanelParam {
fromSheetBar?: boolean;
showDetail?: boolean;
rule?: IPermissionPanelRule;
oldRule?: IPermissionPanelRule;
}
export const SheetPermissionOpenPanelOperation: ICommand<IPermissionOpenPanelParam> = {
type: CommandType.OPERATION,
id: 'sheet-permission.operation.openPanel',
async handler(accessor, _params = {}) {
const sidebarService = accessor.get(ISidebarService);
const sheetPermissionPanelModel = accessor.get(SheetPermissionPanelModel);
const sheetPermissionUserManagerService = accessor.get(SheetPermissionUserManagerService);
const { showDetail = true, fromSheetBar = false, rule, oldRule } = _params;
const sidebarProps = {
header: { title: 'sheets-ui.permission.panel.title' },
children: {
label: UNIVER_SHEET_PERMISSION_PANEL,
showDetail,
fromSheetBar,
rule,
oldRule,
},
width: 330,
onClose: () => {
sheetPermissionPanelModel.reset();
sheetPermissionUserManagerService.reset();
},
};
sheetPermissionPanelModel.setVisible(true);
sidebarService.open(sidebarProps);
return true;
},
};

View File

@@ -0,0 +1,61 @@
/**
* 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, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { IEditorService } from '@univerjs/docs-ui';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ISidebarService } from '@univerjs/ui';
import { DEFINED_NAME_CONTAINER } from '../../views/defined-name/component-name';
export interface IUIComponentCommandParams {
value: string;
}
export const SidebarDefinedNameOperation: ICommand = {
id: 'sidebar.operation.defined-name',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IUIComponentCommandParams) => {
const sidebarService = accessor.get(ISidebarService);
const editorService = accessor.get(IEditorService);
const localeService = accessor.get(LocaleService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId } = target;
switch (params.value) {
case 'open':
sidebarService.open({
id: DEFINED_NAME_CONTAINER,
header: { title: localeService.t<LocaleKey>('sheets-ui.definedName.featureTitle') },
children: { label: DEFINED_NAME_CONTAINER },
onClose: () => {
},
width: 333,
});
break;
case 'close':
default:
sidebarService.close();
break;
}
return true;
},
};

View File

@@ -0,0 +1,201 @@
/**
* 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 { SHEET_VIEWPORT_KEY } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import {
checkCellContentInRange,
checkCellContentInRanges,
getCellIndexByOffsetWithMerge,
getCellRealRange,
getHoverCellPosition,
getUserListEqual,
getViewportByCell,
transformBound2OffsetBound,
transformPosition2Offset,
} from '../utils';
describe('common utils', () => {
it('compares collaborator user lists by user id and role', () => {
const list1 = [
{ id: 'b', subject: { userID: 'u2' }, role: 'viewer' },
{ id: 'a', subject: { userID: 'u1' }, role: 'editor' },
] as any;
const list2 = [
{ id: 'a', subject: { userID: 'u1' }, role: 'editor' },
{ id: 'b', subject: { userID: 'u2' }, role: 'viewer' },
] as any;
const list3 = [{ id: 'a', subject: { userID: 'u1' }, role: 'viewer' }] as any;
expect(getUserListEqual(list1, list2)).toBe(true);
expect(getUserListEqual(list1, list3)).toBe(false);
expect(getUserListEqual(list2, [{ id: 'a', subject: { userID: 'u1' }, role: 'viewer' }, { id: 'b', subject: { userID: 'u2' }, role: 'viewer' }] as any)).toBe(false);
});
it('checks whether cell contents exist in single or multiple ranges', () => {
const matrixWithValue = {
forValue: (cb: (...args: any[]) => void) => {
cb(0, 0, { v: 'primary' });
cb(0, 1, { v: 'other' });
},
};
const matrixWithoutValue = {
forValue: (cb: (...args: any[]) => void) => {
cb(0, 0, { v: 'primary' });
cb(1, 1, null);
},
};
const worksheet = {
getMatrixWithMergedCells: (_sr: number, _sc: number, _er: number, ec: number) => (ec > 1 ? matrixWithoutValue : matrixWithValue),
cellHasValue: (cell: any) => !!cell?.v,
};
expect(checkCellContentInRange(worksheet as any, { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } as any)).toBe(true);
expect(checkCellContentInRanges(worksheet as any, [
{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 2 },
{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 },
] as any)).toBe(true);
});
it('gets cell index by offset with merged-cell fallback', () => {
const scene = {
getActiveViewportByCoord: () => ({ viewportScrollX: 10, viewportScrollY: 20 }),
getAncestorScale: () => ({ scaleX: 2, scaleY: 3 }),
};
const skeleton = {
getCellIndexByOffset: () => ({ row: 4, column: 5 }),
worksheet: {
getMergedCell: () => ({ startRow: 1, startColumn: 2 }),
},
};
const result = getCellIndexByOffsetWithMerge(100, 120, scene as any, skeleton as any)!;
expect(result).toEqual(expect.objectContaining({
row: 4,
col: 5,
actualRow: 1,
actualCol: 2,
}));
const noViewport = {
getActiveViewportByCoord: () => null,
};
expect(getCellIndexByOffsetWithMerge(1, 2, noViewport as any, skeleton as any)).toBeUndefined();
});
it('resolves viewport by cell with and without freeze panes', () => {
const scene = {
getViewport: (key: string) => key,
};
const noFreezeSheet = { getFreeze: () => null };
expect(getViewportByCell(1, 1, scene as any, noFreezeSheet as any)).toBe(SHEET_VIEWPORT_KEY.VIEW_MAIN);
const freezeSheet = { getFreeze: () => ({ startRow: 2, startColumn: 3 }) };
expect(getViewportByCell(3, 3, scene as any, freezeSheet as any)).toBe(SHEET_VIEWPORT_KEY.VIEW_MAIN);
expect(getViewportByCell(1, 1, scene as any, freezeSheet as any)).toBe(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT_TOP);
expect(getViewportByCell(1, 3, scene as any, freezeSheet as any)).toBe(SHEET_VIEWPORT_KEY.VIEW_MAIN_TOP);
expect(getViewportByCell(3, 1, scene as any, freezeSheet as any)).toBe(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT);
});
it('transforms sheet position to viewport offset', () => {
const sceneNoViewport = {
getAncestorScale: () => ({ scaleX: 2, scaleY: 2 }),
getViewport: () => null,
};
expect(transformPosition2Offset(10, 20, sceneNoViewport as any, {} as any, {} as any)).toEqual({ x: 10, y: 20 });
const scene = {
getAncestorScale: () => ({ scaleX: 2, scaleY: 3 }),
getViewport: () => ({ top: 50, left: 40, viewportScrollX: 5, viewportScrollY: 6 }),
};
const skeleton = {
getNoMergeCellWithCoordByIndex: (row: number, col: number) => {
if (row === 1 && col === 2) {
return { startX: 20, startY: 30 };
}
return { startX: 70, startY: 90 };
},
rowHeaderWidth: 10,
columnHeaderHeight: 8,
};
const worksheet = {
getFreeze: () => ({ startColumn: 3, startRow: 2, xSplit: 1, ySplit: 1 }),
};
const result = transformPosition2Offset(60, 20, scene as any, skeleton as any, worksheet as any);
expect(result).toEqual({ x: 110, y: 76 });
const bound = transformBound2OffsetBound({ left: 30, top: 20, right: 60, bottom: 70 } as any, scene as any, skeleton as any, worksheet as any);
expect(bound.left).toBeTypeOf('number');
expect(bound.right).toBeTypeOf('number');
expect(bound.top).toBeTypeOf('number');
expect(bound.bottom).toBeTypeOf('number');
});
it('gets real cell range and hover cell position with overflow and merge fallback', () => {
const overflowRange = { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 };
const skeleton = {
overflowCache: {
forValue: (cb: (...args: any[]) => void) => cb(1, 1, overflowRange),
},
getCellWithCoordByIndex: (row: number, col: number) => ({
actualRow: row,
actualColumn: col,
mergeInfo: row === 1 && col === 1 ? { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 } : null,
}),
getOffsetByColumn: (col: number) => col * 20,
getOffsetByRow: (row: number) => row * 10,
getCellIndexByOffset: () => ({ row: 1, column: 1 }),
worksheet: { getMergedCell: () => null },
};
const workbook = { getUnitId: () => 'unit-1' };
const worksheet = { getSheetId: () => 'sheet-1' };
const location = getCellRealRange(workbook as any, worksheet as any, skeleton as any, 2, 2);
expect(location.row).toBe(1);
expect(location.col).toBe(1);
const scene = {
getActiveViewportByCoord: () => ({ viewportScrollX: 3, viewportScrollY: 4 }),
getAncestorScale: () => ({ scaleX: 2, scaleY: 2 }),
};
const currentRender = { scene };
const hover = getHoverCellPosition(
currentRender as any,
workbook as any,
worksheet as any,
{ skeleton, sheetId: 'sheet-1' } as any,
100,
120
);
expect(hover?.location.row).toBe(1);
expect(hover?.overflowLocation.row).toBe(1);
expect(hover?.position.startX).toBeTypeOf('number');
const noCellIndexSkeleton = {
...skeleton,
getCellIndexByOffset: () => ({ row: 1, column: 1 }),
};
const noActiveViewportRender = {
scene: {
getActiveViewportByCoord: () => null,
getAncestorScale: () => ({ scaleX: 1, scaleY: 1 }),
},
};
expect(getHoverCellPosition(noActiveViewportRender as any, workbook as any, worksheet as any, { skeleton: noCellIndexSkeleton, sheetId: 'sheet-1' } as any, 1, 2)).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
/**
* 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.
*/
export const SHEET_ZOOM_RANGE = [10, 400];
/**
* TODO@ggg: To avoid introducing the range-selector of the sheet-formula-ui package into sheet-ui, use a component key for now
*/
export const RANGE_SELECTOR_COMPONENT_KEY = 'RANGE_SELECTOR_COMPONENT_KEY';
export const EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY = 'EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY';
export const EMBEDDING_CELL_EDITOR_COMPONENT_KEY = 'EMBEDDING_CELL_EDITOR_COMPONENT_KEY';
// end
export enum SHEET_VIEW_KEY {
MAIN = '__SpreadsheetRender__',
ROW = '__SpreadsheetRowHeader__',
COLUMN = '__SpreadsheetColumnHeader__',
LEFT_TOP = '__SpreadsheetLeftTopPlaceholder__',
}
export const SHEET_COMPONENT_MAIN_LAYER_INDEX = 0;
export const SHEET_COMPONENT_SELECTION_LAYER_INDEX = 1;
export const SHEET_COMPONENT_HEADER_LAYER_INDEX = 10;
export const SHEET_COMPONENT_HEADER_SELECTION_LAYER_INDEX = 11;
export const SHEET_COMPONENT_UNHIDE_LAYER_INDEX = 12;
// TODO@wzhudev: there should be a global zIndex layer fo sheet selections // And global zIndex for extension

View File

@@ -0,0 +1,258 @@
/**
* 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 { IPosition, IRange, Workbook, Worksheet } from '@univerjs/core';
import type { IBoundRectNoAngle, IRender, Scene, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ICollaborator } from '@univerjs/protocol';
import type { ISheetLocation, ISheetSkeletonManagerParam } from '@univerjs/sheets';
import { SHEET_VIEWPORT_KEY, Vector2 } from '@univerjs/engine-render';
export function getUserListEqual(userList1: ICollaborator[], userList2: ICollaborator[]) {
if (userList1.length !== userList2.length) return false;
const sorted1 = userList1.sort((a, b) => a.id.localeCompare(b.id));
const sorted2 = userList2.sort((a, b) => a.id.localeCompare(b.id));
return sorted1.every((user, index) => {
return user.subject?.userID === sorted2[index].subject?.userID && user.role === sorted2[index].role;
});
}
export function checkCellContentInRanges(worksheet: Worksheet, ranges: IRange[]): boolean {
return ranges.some((range) => checkCellContentInRange(worksheet, range));
}
export function checkCellContentInRange(worksheet: Worksheet, range: IRange): boolean {
const { startRow, startColumn, endColumn, endRow } = range;
const cellMatrix = worksheet.getMatrixWithMergedCells(startRow, startColumn, endRow, endColumn);
let someCellGoingToBeRemoved = false;
cellMatrix.forValue((row, col, cellData) => {
if (cellData && (row !== startRow || col !== startColumn) && worksheet.cellHasValue(cellData)) {
someCellGoingToBeRemoved = true;
return false;
}
});
return someCellGoingToBeRemoved;
}
export function getCellIndexByOffsetWithMerge(offsetX: number, offsetY: number, scene: Scene, skeleton: SpreadsheetSkeleton) {
const activeViewport = scene.getActiveViewportByCoord(
Vector2.FromArray([offsetX, offsetY])
);
if (!activeViewport) {
return;
}
const { scaleX, scaleY } = scene.getAncestorScale();
const scrollXY = {
x: activeViewport.viewportScrollX,
y: activeViewport.viewportScrollY,
};
const cellIndex = skeleton.getCellIndexByOffset(offsetX, offsetY, scaleX, scaleY, scrollXY);
// const mergeCell = skeleton.mergeData.find((range) => {
// const { startColumn, startRow, endColumn, endRow } = range;
// return cellPos.row >= startRow && cellPos.column >= startColumn && cellPos.row <= endRow && cellPos.column <= endColumn;
// });
const mergeCell = skeleton.worksheet.getMergedCell(cellIndex.row, cellIndex.column);
const params = {
actualRow: mergeCell ? mergeCell.startRow : cellIndex.row,
actualCol: mergeCell ? mergeCell.startColumn : cellIndex.column,
mergeCell,
row: cellIndex.row,
col: cellIndex.column,
};
return params;
}
export function getViewportByCell(row: number, column: number, scene: Scene, worksheet: Worksheet) {
const freeze = worksheet.getFreeze();
if (!freeze || (freeze.startRow <= 0 && freeze.startColumn <= 0)) {
return scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN);
}
if (row >= freeze.startRow && column >= freeze.startColumn) {
return scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN);
}
if (row < freeze.startRow && column < freeze.startColumn) {
return scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT_TOP);
}
if (row < freeze.startRow && column >= freeze.startColumn) {
return scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN_TOP);
}
if (row >= freeze.startRow && column < freeze.startColumn) {
return scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT);
}
}
export function transformBound2OffsetBound(originBound: IBoundRectNoAngle, scene: Scene, skeleton: SpreadsheetSkeleton, worksheet: Worksheet): IBoundRectNoAngle {
const topLeft = transformPosition2Offset(originBound.left, originBound.top, scene, skeleton, worksheet);
const bottomRight = transformPosition2Offset(originBound.right, originBound.bottom, scene, skeleton, worksheet);
return {
left: topLeft.x,
top: topLeft.y,
right: bottomRight.x,
bottom: bottomRight.y,
};
}
export function transformPosition2Offset(x: number, y: number, scene: Scene, skeleton: SpreadsheetSkeleton, worksheet: Worksheet) {
const { scaleX, scaleY } = scene.getAncestorScale();
const viewMain = scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN);
if (!viewMain) {
return {
x,
y,
};
}
const freeze = worksheet.getFreeze();
const { startColumn, startRow, xSplit, ySplit } = freeze;
// freeze start
const startSheetView = skeleton.getNoMergeCellWithCoordByIndex(startRow - ySplit, startColumn - xSplit, false);
// freeze end
const endSheetView = skeleton.getNoMergeCellWithCoordByIndex(startRow, startColumn, false);
const { rowHeaderWidth, columnHeaderHeight } = skeleton;
const freezeWidth = endSheetView.startX - startSheetView.startX;
const freezeHeight = endSheetView.startY - startSheetView.startY;
const { top, left, viewportScrollX, viewportScrollY } = viewMain;
let offsetX: number;
// viewMain or viewTop
if (x > left) {
offsetX = (x - viewportScrollX) * scaleX;
} else {
offsetX = ((freezeWidth + rowHeaderWidth) - (left - x)) * scaleX;
}
let offsetY: number;
if (y > top) {
offsetY = (y - viewportScrollY) * scaleY;
} else {
offsetY = ((freezeHeight + columnHeaderHeight) - (top - y)) * scaleX;
}
return {
x: offsetX,
y: offsetY,
};
}
export function getCellRealRange(workbook: Workbook, worksheet: Worksheet, skeleton: SpreadsheetSkeleton, row: number, col: number) {
let actualRow = row;
let actualCol = col;
skeleton.overflowCache.forValue((r, c, range) => {
if (range.startRow <= actualRow && range.endRow >= actualRow && range.startColumn <= actualCol && range.endColumn >= actualCol) {
actualCol = c;
actualRow = r;
}
});
const actualCell = skeleton.getCellWithCoordByIndex(actualRow, actualCol);
const location: ISheetLocation = {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
workbook,
worksheet,
row: actualCell.actualRow,
col: actualCell.actualColumn,
};
return location;
}
export function getHoverCellPosition(currentRender: IRender, workbook: Workbook, worksheet: Worksheet, skeletonParam: ISheetSkeletonManagerParam, offsetX: number, offsetY: number) {
const { scene } = currentRender;
const unitId = workbook.getUnitId();
const { skeleton, sheetId } = skeletonParam;
const cellIndex = getCellIndexByOffsetWithMerge(offsetX, offsetY, scene, skeleton);
if (!cellIndex) {
return null;
}
let { actualCol, actualRow } = cellIndex;
const originLocation = {
unitId,
subUnitId: sheetId,
workbook,
worksheet,
row: actualRow,
col: actualCol,
};
skeleton.overflowCache.forValue((r, c, range) => {
if (range.startRow <= actualRow && range.endRow >= actualRow && range.startColumn <= actualCol && range.endColumn >= actualCol) {
actualCol = c;
actualRow = r;
}
});
const actualCell = skeleton.getCellWithCoordByIndex(actualRow, actualCol);
const location: ISheetLocation = getCellRealRange(workbook, worksheet, skeleton, actualRow, actualCol);
let anchorCell: IRange;
if (actualCell.mergeInfo) {
anchorCell = actualCell.mergeInfo;
} else {
anchorCell = {
startRow: location.row,
endRow: location.row,
startColumn: location.col,
endColumn: location.col,
};
}
const activeViewport = scene.getActiveViewportByCoord(
Vector2.FromArray([offsetX, offsetY])
);
if (!activeViewport) {
return;
}
const { scaleX, scaleY } = scene.getAncestorScale();
const scrollXY = {
x: activeViewport.viewportScrollX,
y: activeViewport.viewportScrollY,
};
const position: IPosition = {
startX: (skeleton.getOffsetByColumn(anchorCell.startColumn - 1) - scrollXY.x) * scaleX,
endX: (skeleton.getOffsetByColumn(anchorCell.endColumn) - scrollXY.x) * scaleX,
startY: (skeleton.getOffsetByRow(anchorCell.startRow - 1) - scrollXY.y) * scaleY,
endY: (skeleton.getOffsetByRow(anchorCell.endRow) - scrollXY.y) * scaleY,
};
return {
position,
location: originLocation,
overflowLocation: location,
};
}

View File

@@ -0,0 +1,163 @@
/**
* 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 { DependencyOverride } from '@univerjs/core';
import type { IScrollBarProps } from '@univerjs/engine-render';
import type { ComponentType, MenuConfig } from '@univerjs/ui';
import type { IPermissionDetailUserPartProps } from '../views/permission/panel-detail/PermissionDetailUserPart';
export const SHEETS_UI_PLUGIN_CONFIG_KEY = 'sheets-ui.config';
export const configSymbol = Symbol(SHEETS_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsUIConfig {
menu?: MenuConfig;
disableAutoFocus?: true;
override?: DependencyOverride;
/**
* The maximum count of rows triggering auto height. This is used to avoid performance issue.
* @default 1000
*/
maxAutoHeightCount?: number;
/**
* Whether to show the formula bar.
*/
formulaBar?: boolean;
/**
* The config of the footer.
* @default {}
*/
footer?: false | {
/**
* Sheet bar is the manager of sub sheets, including add/switch/delete sub sheets.
* @default true
*/
sheetBar?: boolean;
/**
* statistic bar including statistic info current selections, such as count, sum, average, etc.
* @default true
*/
statisticBar?: boolean;
/**
* Including the menus in the footer. such as highlight, gridlines, etc.
* @default true
*/
menus?: boolean;
/**
* Zoom slider is the zoom slider in the footer.
* @default true
*/
zoomSlider?: boolean;
/**
* The config of the add sheet button in the sheet bar.
*/
addSheetButtonConfig?: {
/**
* Whether to show the add sheet button in the sheet bar.
* When `footer` or `footer.sheetBar` is false, this config will not work because the sheet bar will not be rendered.
* @default true
*/
show?: boolean;
/**
* The default row count of the new sheet created by the add sheet button.
* @default 1000
*/
defaultRowCount?: number;
/**
* The default column count of the new sheet created by the add sheet button.
* @default 20
*/
defaultColumnCount?: number;
};
};
clipboardConfig?: {
hidePasteOptions?: boolean;
};
/** The config of the scroll bar. */
scrollConfig?: IScrollBarProps;
/**
* Strategy for showing the protected range shadow.
* - true or 'always': Show shadow for all protected ranges (default behavior)
* - 'non-editable': Only show shadow for ranges that cannot be edited (Edit permission is false)
* - 'non-viewable': Only show shadow for ranges that cannot be viewed (View permission is false)
* - false or 'none': Never show shadow for protected ranges
* @default true
*/
protectedRangeShadow?: boolean | 'always' | 'non-editable' | 'non-viewable' | 'none';
/**
* The custom component of the protected range user selector.
*/
protectedRangeUserSelector?: {
/**
* custom component, should implement the `IPermissionDetailUserPartProps` interface.
*/
component: ComponentType<IPermissionDetailUserPartProps>;
/**
* The framework of the component. Must be passed correctly.
*/
framework: string;
};
/**
* Whether to disable the force string alert.
* @default false
*/
disableForceStringAlert?: boolean;
/**
* Whether to disable the force string mark.
* @default false
*/
disableForceStringMark?: boolean;
/**
* Whether to disable the edit.
* @default false
*/
disableEdit?: boolean;
}
export const defaultPluginConfig: IUniverSheetsUIConfig = {
formulaBar: true,
protectedRangeShadow: true,
maxAutoHeightCount: 1000,
};
/**
* Convert the protectedRangeShadow config to shadow strategy
* - true -> 'always'
* - false -> 'none'
* - string values remain unchanged
* @param config The protectedRangeShadow config value
* @returns The shadow strategy
*/
export function convertToShadowStrategy(
config?: boolean | 'always' | 'non-editable' | 'non-viewable' | 'none'
): 'always' | 'non-editable' | 'non-viewable' | 'none' {
if (config === true) return 'always';
if (config === false) return 'none';
return config || 'always';
}

View File

@@ -0,0 +1,51 @@
/**
* 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.
*/
/**
* Context key indicating that pinch-to-zoom gesture is in progress on mobile.
* Used to prevent selection operations during pinch zoom.
*/
export const MOBILE_PINCH_ZOOMING = 'MOBILE_PINCH_ZOOMING';
/**
* Context key indicating that selection is being expanded on mobile (dragging fill controls).
* Used to prevent scroll/inertia/zoom operations during selection expansion.
*/
export const MOBILE_EXPANDING_SELECTION = 'MOBILE_EXPANDING_SELECTION';
/**
* Context key indicating that the next selection-end event was triggered by a mobile long press.
* Used to ensure the context menu only opens for long-press gestures, not normal taps.
*/
export const MOBILE_TRIGGER_CONTEXT_MENU = 'MOBILE_TRIGGER_CONTEXT_MENU';
/** Whether the software keyboard currently occupies the mobile visual viewport. */
export const MOBILE_KEYBOARD_VISIBLE = 'MOBILE_KEYBOARD_VISIBLE';
/** Whether the mobile formula editor currently occupies the immersive full-screen surface. */
export const MOBILE_FX_EDITOR_EXPANDED = 'MOBILE_FX_EDITOR_EXPANDED';
/** Whether the mobile formula function panel is open above the formula editor. */
export const MOBILE_FORMULA_FUNCTION_PANEL_OPEN = 'MOBILE_FORMULA_FUNCTION_PANEL_OPEN';
/** Whether the compact mobile formula operator strip is currently visible. */
export const MOBILE_FORMULA_OPERATORS_VISIBLE = 'MOBILE_FORMULA_OPERATORS_VISIBLE';
/** Height of the compact mobile formula operator strip in pixels. */
export const MOBILE_FORMULA_OPERATOR_BAR_HEIGHT = 36;
/** Submit the compact mobile formula bar without unmounting its input. */
export const MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID = 'sheet.command.mobile-formula-bar-submit';

View File

@@ -0,0 +1,61 @@
/**
* 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 { UnitAction } from '@univerjs/protocol';
export const UNIVER_SHEET_PERMISSION_PLUGIN_NAME = 'UNIVER_SHEET_PERMISSION_PLUGIN';
export const UNIVER_SHEET_PERMISSION_PANEL = 'UNIVER_SHEET_PERMISSION_PANEL';
export const UNIVER_SHEET_PERMISSION_USER_PART = 'UNIVER_SHEET_PERMISSION_USER_PART';
export const UNIVER_SHEET_PERMISSION_PANEL_FOOTER = 'UNIVER_SHEET_PERMISSION_PANEL_FOOTER';
export const UNIVER_SHEET_PERMISSION_USER_DIALOG = 'UNIVER_SHEET_PERMISSION_USER_DIALOG';
export const UNIVER_SHEET_PERMISSION_DIALOG = 'UNIVER_SHEET_PERMISSION_DIALOG';
export const UNIVER_SHEET_PERMISSION_USER_DIALOG_ID = 'UNIVER_SHEET_PERMISSION_USER_DIALOG_ID';
export const UNIVER_SHEET_PERMISSION_DIALOG_ID = 'UNIVER_SHEET_PERMISSION_DIALOG_ID';
export const subUnitPermissionTypeMap: Partial<Record<UnitAction, string>> = {
[UnitAction.Copy]: 'Copy',
[UnitAction.SetCellStyle]: 'SetCellStyle',
[UnitAction.SetCellValue]: 'SetCellValue',
[UnitAction.SetRowStyle]: 'SetRowStyle',
[UnitAction.SetColumnStyle]: 'SetColumnStyle',
[UnitAction.InsertRow]: 'InsertRow',
[UnitAction.InsertColumn]: 'InsertColumn',
[UnitAction.InsertHyperlink]: 'InsertHyperlink',
[UnitAction.DeleteRow]: 'DeleteRow',
[UnitAction.DeleteColumn]: 'DeleteColumn',
[UnitAction.Sort]: 'Sort',
[UnitAction.Filter]: 'Filter',
[UnitAction.PivotTable]: 'PivotTable',
[UnitAction.EditExtraObject]: 'EditExtraObject',
};
export const defaultWorksheetUnitActionList: UnitAction[] = [
UnitAction.Copy,
UnitAction.SetCellStyle,
UnitAction.SetCellValue,
UnitAction.SetRowStyle,
UnitAction.SetColumnStyle,
UnitAction.InsertRow,
UnitAction.InsertColumn,
UnitAction.InsertHyperlink,
UnitAction.DeleteRow,
UnitAction.DeleteColumn,
UnitAction.Sort,
UnitAction.Filter,
UnitAction.PivotTable,
UnitAction.EditExtraObject,
UnitAction.View,
];

View File

@@ -0,0 +1,17 @@
/**
* 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.
*/
export const SHEET_UI_PLUGIN_NAME = 'SheetUI';

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
export enum SheetsUIPart {
FILTER_PANEL_EMBED_POINT = 'filter-panel-embed-point',
SHEETS_FOOTER = 'sheets-footer',
FORMULA_AUX = 'formula-aux',
}

View File

@@ -0,0 +1,268 @@
/**
* 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 { IRange, IWorkbookData, Workbook } from '@univerjs/core';
import {
BooleanNumber,
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
RedoCommand,
Tools,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { CanvasColorService, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
CancelMarkDirtyRowAutoHeightOperation,
MarkDirtyRowAutoHeightOperation,
SetColWidthCommand,
SetWorksheetColWidthMutation,
SetWorksheetRowAutoHeightMutation,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service';
import { AutoHeightController } from '../auto-height.controller';
const RANGE: IRange = { startRow: 1, endRow: 3, startColumn: 0, endColumn: 2 };
describe('AutoHeightController', () => {
let univer: Univer;
beforeEach(() => {
// Happy DOM has no canvas backend; the render and skeleton services remain real.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) {
return { canvas: this, setTransform: vi.fn(), clearRect: vi.fn() } as unknown as CanvasRenderingContext2D;
});
univer = new Univer();
const injector = univer.__getInjector();
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([SheetSkeletonService]);
injector.add([SheetInterceptorService]);
injector.add([SheetsSelectionsService]);
injector.add([AutoHeightController]);
injector.get(LocaleService).setLocale(LocaleType.EN_US);
injector.get(SheetSkeletonService);
injector.get(SheetInterceptorService);
injector.get(IRenderManagerService).registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetSkeletonManagerService]);
injector.get(ICommandService).registerCommand(SetWorksheetRowAutoHeightMutation);
injector.get(ICommandService).registerCommand(SetColWidthCommand);
injector.get(ICommandService).registerCommand(SetWorksheetColWidthMutation);
});
afterEach(() => {
univer.dispose();
vi.restoreAllMocks();
});
for (const current of ['none', 'other-workbook', 'other-worksheet'] as const) {
it(`updates only the explicit child worksheet with ${current} current and restores its height on undo`, () => {
const injector = univer.__getInjector();
const instances = injector.get(IUniverInstanceService);
const renderManager = injector.get(IRenderManagerService);
const peer = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('peer'),
{ makeCurrent: false }
);
const child = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('child'),
{ makeCurrent: false }
);
renderManager.createRender('peer');
renderManager.createRender('child');
if (current !== 'none') {
instances.setCurrentUnitForType(current === 'other-workbook' ? 'peer' : 'child');
}
injector.get(AutoHeightController);
const worksheet = child.getSheetBySheetId('sheet2')!;
const peerBefore = Tools.deepClone(peer.getSnapshot());
const otherSheetBefore = Tools.deepClone(child.getSheetBySheetId('sheet1')!.getSnapshot());
const rowsBefore = Tools.deepClone(worksheet.getSnapshot().rowData);
const result = injector.get(SheetInterceptorService).generateMutationsOfAutoHeight({
unitId: 'child',
subUnitId: 'sheet2',
ranges: [RANGE],
});
expect(result.redos).toEqual([{
id: SetWorksheetRowAutoHeightMutation.id,
params: { unitId: 'child', subUnitId: 'sheet2', rowsAutoHeightInfo: [{ row: 1, autoHeight: 20 }] },
}]);
const commands = injector.get(ICommandService);
for (const mutation of result.redos) {
expect(commands.syncExecuteCommand(mutation.id, mutation.params)).toBe(true);
}
expect(worksheet.getRowHeight(1)).toBe(20);
expect(worksheet.getRowHeight(3)).toBe(50);
for (const mutation of result.undos) {
expect(commands.syncExecuteCommand(mutation.id, mutation.params)).toBe(true);
}
expect(worksheet.getSnapshot().rowData).toEqual(rowsBefore);
for (const mutation of result.redos) {
expect(commands.syncExecuteCommand(mutation.id, mutation.params)).toBe(true);
}
expect(worksheet.getRowHeight(1)).toBe(20);
expect(peer.getSnapshot()).toEqual(peerBefore);
expect(child.getSheetBySheetId('sheet1')!.getSnapshot()).toEqual(otherSheetBefore);
});
}
for (const current of ['none', 'peer'] as const) {
it(`completes explicit column resizing and its undo/redo with ${current} current`, async () => {
const injector = univer.__getInjector();
const instances = injector.get(IUniverInstanceService);
const peer = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('peer'),
{ makeCurrent: false }
);
const child = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('child'),
{ makeCurrent: false }
);
injector.get(IRenderManagerService).createRender('peer');
injector.get(IRenderManagerService).createRender('child');
if (current === 'peer') {
instances.setCurrentUnitForType('peer');
instances.focusUnit('peer');
}
injector.get(AutoHeightController);
const target = child.getSheetBySheetId('sheet2')!;
const before = Tools.deepClone(target.getSnapshot());
const peerBefore = Tools.deepClone(peer.getSnapshot());
const commands = injector.get(ICommandService);
expect(await commands.executeCommand(SetColWidthCommand.id, {
unitId: 'child',
subUnitId: 'sheet2',
ranges: [RANGE],
value: 120,
})).toBe(true);
expect(target.getColumnWidth(1)).toBe(120);
expect(target.getRowHeight(1)).toBe(20);
expect(peer.getSnapshot()).toEqual(peerBefore);
const resized = Tools.deepClone(target.getSnapshot());
instances.focusUnit('child');
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
expect(target.getColumnWidth(1)).toBe(before.defaultColumnWidth);
expect(target.getSnapshot().rowData).toEqual(before.rowData);
expect(await commands.executeCommand(RedoCommand.id)).toBe(true);
expect(target.getSnapshot()).toEqual(resized);
expect(peer.getSnapshot()).toEqual(peerBefore);
});
}
for (const missing of ['unit', 'worksheet', 'render'] as const) {
it(`does not fall back to the current Sheet when the explicit target ${missing} is missing`, () => {
const injector = univer.__getInjector();
const peer = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookSnapshot('peer'));
injector.get(IRenderManagerService).createRender('peer');
injector.get(IUniverInstanceService).createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('child'),
{ makeCurrent: false }
);
if (missing !== 'render') {
injector.get(IRenderManagerService).createRender('child');
}
injector.get(AutoHeightController);
const before = Tools.deepClone(peer.getSnapshot());
const result = injector.get(SheetInterceptorService).generateMutationsOfAutoHeight({
unitId: missing === 'unit' ? 'missing' : 'child',
subUnitId: missing === 'worksheet' ? 'missing' : 'sheet2',
ranges: [RANGE],
});
expect(result).toEqual({ redos: [], undos: [], preRedos: [], preUndos: [] });
expect(peer.getSnapshot()).toEqual(before);
});
}
it('preserves implicit current-worksheet calls and filters unchanged or manually sized rows', () => {
const injector = univer.__getInjector();
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookSnapshot('current'));
workbook.setActiveSheet(workbook.getSheetBySheetId('sheet2')!);
injector.get(IRenderManagerService).createRender('current');
const result = injector.get(AutoHeightController).getUndoRedoParamsOfAutoHeight([RANGE]);
expect(result.redos).toEqual([{
id: SetWorksheetRowAutoHeightMutation.id,
params: { unitId: 'current', subUnitId: 'sheet2', rowsAutoHeightInfo: [{ row: 1, autoHeight: 20 }] },
}]);
expect(result.undos).toEqual([{
id: SetWorksheetRowAutoHeightMutation.id,
params: { unitId: 'current', subUnitId: 'sheet2', rowsAutoHeightInfo: [{ row: 1, autoHeight: 40 }] },
}]);
});
it('keeps lazy dirty-row operations paired and unregisters the interceptor on disposal', () => {
const injector = univer.__getInjector();
univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookSnapshot('current'));
injector.get(IRenderManagerService).createRender('current');
const controller = injector.get(AutoHeightController);
const interceptor = injector.get(SheetInterceptorService);
const context = { unitId: 'current', subUnitId: 'sheet2', ranges: [], lazyAutoHeightRanges: [RANGE] };
const result = interceptor.generateMutationsOfAutoHeight(context);
expect(result.redos).toEqual([{
id: MarkDirtyRowAutoHeightOperation.id,
params: { unitId: 'current', subUnitId: 'sheet2', ranges: [RANGE], id: expect.any(String) },
options: { onlyLocal: true },
}]);
expect(result.undos).toEqual([{
id: CancelMarkDirtyRowAutoHeightOperation.id,
params: { unitId: 'current', subUnitId: 'sheet2', id: (result.redos[0].params as { id: string }).id },
options: { onlyLocal: true },
}]);
controller.dispose();
expect(interceptor.generateMutationsOfAutoHeight(context)).toEqual({ redos: [], undos: [], preRedos: [], preUndos: [] });
});
});
function workbookSnapshot(id: string): IWorkbookData {
return {
id,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: id,
styles: {},
sheetOrder: ['sheet1', 'sheet2'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Peer sheet',
rowCount: 5,
columnCount: 3,
defaultRowHeight: 30,
rowData: { 1: { ah: 60 } },
},
sheet2: {
id: 'sheet2',
name: 'Target sheet',
rowCount: 5,
columnCount: 3,
defaultRowHeight: 20,
rowData: { 1: { ah: 40 }, 2: { ah: 20 }, 3: { h: 50, ia: BooleanNumber.FALSE } },
},
},
};
}

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 { UnitAction } from '@univerjs/protocol';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { CellCustomRenderController } from '../cell-custom-render.controller';
function createEventSubject() {
const subscribers = new Set<(evt: any) => void>();
return {
subscribeEvent: vi.fn((handler: (evt: any) => void) => {
subscribers.add(handler);
const unsubscribe = vi.fn(() => subscribers.delete(handler));
return { dispose: unsubscribe, unsubscribe };
}),
emit: (evt: any) => subscribers.forEach((handler) => handler(evt)),
};
}
function createController(options?: { editable?: boolean; selectionEditable?: boolean }) {
const pointerDown$ = createEventSubject();
const pointerMove$ = createEventSubject();
const renderA = {
zIndex: 1,
isHit: vi.fn(() => true),
onPointerDown: vi.fn(),
onPointerEnter: vi.fn(),
onPointerLeave: vi.fn(),
};
const renderB = {
zIndex: 2,
isHit: vi.fn(() => false),
onPointerEnter: vi.fn(),
onPointerLeave: vi.fn(),
};
const cellData = {
customRender: [renderB, renderA],
selectionProtection: [{ [UnitAction.Edit]: options?.selectionEditable ?? true }],
};
const worksheet = {
getSheetId: vi.fn(() => 'sheet-1'),
getMergedCell: vi.fn(() => null),
getCell: vi.fn(() => cellData),
getComposedCellStyleByCellData: vi.fn(() => ({ fs: 12 })),
};
const skeleton = {
worksheet,
getCellIndexByOffset: vi.fn(() => ({ row: 1, column: 2 })),
getCellWithCoordByIndex: vi.fn(() => ({ startX: 10, startY: 20 })),
};
const currentSkeleton$ = new Subject<any>();
const spreadsheet = {
onPointerDown$: pointerDown$,
onPointerMove$: pointerMove$,
};
const scene = {
getActiveViewportByCoord: vi.fn(() => ({ viewportScrollX: 5, viewportScrollY: 6 })),
getAncestorScale: vi.fn(() => ({ scaleX: 2, scaleY: 2 })),
};
const controller = new CellCustomRenderController(
{
unitId: 'unit-1',
unit: { getActiveSheet: vi.fn(() => worksheet) },
} as any,
{
currentSkeleton$,
getCurrentParam: vi.fn(() => ({ skeleton })),
} as any,
{
getRenderUnitById: vi.fn(() => ({ mainComponent: spreadsheet, scene })),
} as any,
{
composePermission: vi.fn(() => [{ value: options?.editable ?? true }]),
} as any
);
return { controller, pointerDown$, pointerMove$, renderA, renderB, currentSkeleton$, skeleton, worksheet };
}
describe('CellCustomRenderController', () => {
it('dispatches pointer events to hit custom renderers when sheet and cell are editable', async () => {
const { controller, pointerDown$, pointerMove$, renderA, renderB } = createController();
pointerDown$.emit({ offsetX: 20, offsetY: 30 });
pointerMove$.emit({ offsetX: 20, offsetY: 30 });
await new Promise((resolve) => setTimeout(resolve, 35));
renderA.isHit.mockReturnValue(false);
pointerMove$.emit({ offsetX: 200, offsetY: 300 });
await new Promise((resolve) => setTimeout(resolve, 35));
expect(renderA.onPointerDown).toHaveBeenCalled();
expect(renderA.onPointerEnter).toHaveBeenCalled();
expect(renderA.onPointerLeave).toHaveBeenCalled();
expect(renderB.onPointerEnter).not.toHaveBeenCalled();
controller.dispose();
});
it('does not dispatch pointer down when workbook or protected cell is not editable', () => {
const nonEditable = createController({ editable: false });
nonEditable.pointerDown$.emit({ offsetX: 20, offsetY: 30 });
expect(nonEditable.renderA.onPointerDown).not.toHaveBeenCalled();
nonEditable.controller.dispose();
const protectedCell = createController({ selectionEditable: false });
protectedCell.pointerDown$.emit({ offsetX: 20, offsetY: 30 });
expect(protectedCell.renderA.onPointerDown).not.toHaveBeenCalled();
protectedCell.controller.dispose();
});
});

View File

@@ -0,0 +1,349 @@
/**
* 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 { CellValueType } from '@univerjs/core';
import { CURSOR_TYPE, getCurrentTypeOfRenderer } from '@univerjs/engine-render';
import { INTERCEPTOR_POINT, SetWorksheetColWidthMutation, TextToNumberCommand, ToggleCellCheckboxCommand } from '@univerjs/sheets';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { CellAlertType } from '../../services/cell-alert-manager.service';
import { AutoWidthController, createAutoColWidthUndoMutationsByRedos } from '../auto-width.controller';
import { SheetCheckboxController } from '../checkbox.controller';
import { ForceStringAlertRenderController } from '../force-string-alert-render.controller';
import { ForceStringRenderController } from '../force-string-render.controller';
const mainComponentMock = vi.hoisted(() => ({
setCursor: vi.fn(),
}));
vi.mock('@univerjs/engine-render', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/engine-render')>();
return {
...actual,
getCurrentTypeOfRenderer: vi.fn(() => ({
mainComponent: mainComponentMock,
})),
};
});
describe('SheetCheckboxController', () => {
it('uses pointer cursor for checkbox bullets and toggles the clicked checkbox paragraph', () => {
const currentRichText$ = new Subject<{ bullet?: { startIndex: number } } | null>();
const currentClickedCell$ = new Subject<{
location: { unitId: string; subUnitId: string; row: number; col: number };
bullet?: { startIndex: number };
}>();
const commandService = { executeCommand: vi.fn() };
const controller = new SheetCheckboxController(
{ currentRichText$, currentClickedCell$ } as never,
{} as never,
commandService as never,
{} as never
);
currentRichText$.next({ bullet: { startIndex: 3 } });
expect(mainComponentMock.setCursor).toHaveBeenCalledWith(CURSOR_TYPE.POINTER);
expect(getCurrentTypeOfRenderer).toHaveBeenCalled();
currentRichText$.next(null);
expect(mainComponentMock.setCursor).toHaveBeenCalledWith(CURSOR_TYPE.AUTO);
currentClickedCell$.next({
location: { unitId: 'unit-1', subUnitId: 'sheet-1', row: 2, col: 4 },
bullet: { startIndex: 7 },
});
currentClickedCell$.next({
location: { unitId: 'unit-1', subUnitId: 'sheet-1', row: 3, col: 5 },
});
expect(commandService.executeCommand).toHaveBeenCalledTimes(1);
expect(commandService.executeCommand).toHaveBeenCalledWith(ToggleCellCheckboxCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 4,
paragraphIndex: 7,
});
controller.dispose();
});
});
describe('ForceStringRenderController', () => {
it('marks numeric-looking string cells unless disabled or formatted as text', () => {
let interceptor: typeof INTERCEPTOR_POINT.CELL_CONTENT | undefined;
const controller = new ForceStringRenderController(
{ unitId: 'unit-1' } as never,
{ getCurrentParam: vi.fn(() => ({ skeleton: {} })) } as never,
{
intercept: vi.fn((point, config) => {
expect(point).toBe(INTERCEPTOR_POINT.CELL_CONTENT);
interceptor = config;
return { dispose: vi.fn() };
}),
} as never,
{ getConfig: vi.fn(() => ({ disableForceStringMark: false })) } as never
);
const workbook = {
getStyles: vi.fn(() => ({
get: vi.fn((styleId: string) => styleId === 'text' ? { n: { pattern: '@' } } : undefined),
})),
};
const rawCell = { v: '123', t: CellValueType.STRING };
if (!interceptor) {
throw new Error('CELL_CONTENT interceptor was not registered');
}
const result = interceptor.handler(rawCell, {
rawData: rawCell,
workbook,
} as never, (cell) => cell);
expect(result).not.toBe(rawCell);
expect(result?.markers?.tl).toEqual({ size: 6, color: '#409f11' });
expect(interceptor.handler({ ...rawCell, s: 'text' }, {
rawData: { ...rawCell, s: 'text' },
workbook,
} as never, (cell) => cell)).not.toHaveProperty('markers');
controller.dispose();
});
});
describe('ForceStringAlertRenderController', () => {
it('shows the alert only for a numeric-text primary cell in a single selection', () => {
const selectionChanged$ = new Subject<Array<{
range: { startRow: number; endRow: number; startColumn: number; endColumn: number };
primary: { actualRow: number; actualColumn: number };
}>>();
const cellAlertManagerService = {
currentAlert: new Map(),
showAlert: vi.fn((alert) => {
cellAlertManagerService.currentAlert.set(alert.key, { alert });
}),
removeAlert: vi.fn((key) => cellAlertManagerService.currentAlert.delete(key)),
};
const worksheet = {
getSheetId: vi.fn(() => 'sheet-1'),
getCell: vi.fn((row: number) => row === 1
? { v: '123', t: CellValueType.STRING }
: { v: '123', t: CellValueType.STRING, s: 'text' }),
};
const workbook = {
activeSheet$: new BehaviorSubject(worksheet),
getUnitId: vi.fn(() => 'unit-1'),
getActiveSheet: vi.fn(() => worksheet),
getStyles: vi.fn(() => ({
get: vi.fn((styleId: string) => styleId === 'text' ? { n: { pattern: '@' } } : undefined),
})),
};
let disableForceStringAlert = false;
const commandService = {
syncExecuteCommand: vi.fn(() => true),
};
const currentUnit$ = new BehaviorSubject(workbook);
const controller = new ForceStringAlertRenderController(
{ unit: workbook } as never,
{
selectionChanged$,
currentSelectionParam: { unitId: 'unit-1', sheetId: 'sheet-1' },
getCurrentSelections: vi.fn(() => []),
} as never,
cellAlertManagerService as never,
{ t: vi.fn((key: string) => key) } as never,
{ getConfig: vi.fn(() => ({ disableForceStringAlert })) } as never,
commandService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => currentUnit$) } as never
);
const selection = {
range: { startRow: 1, endRow: 3, startColumn: 2, endColumn: 4 },
primary: { actualRow: 1, actualColumn: 2 },
};
selectionChanged$.next([selection]);
expect(cellAlertManagerService.showAlert).toHaveBeenCalledWith(expect.objectContaining({
type: CellAlertType.WARNING,
title: 'sheets-ui.info.error',
message: 'sheets-ui.info.forceStringInfo',
key: 'SHEET_FORCE_STRING_ALERT:unit-1',
location: { unitId: 'unit-1', subUnitId: 'sheet-1', row: 1, col: 2 },
menu: [expect.objectContaining({ label: 'sheets-ui.info.convertToNumber' })],
}));
const alert = cellAlertManagerService.showAlert.mock.calls[0][0];
alert.menu[0].onSelect();
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(TextToNumberCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [selection.range],
});
selectionChanged$.next([selection, { ...selection }]);
expect(cellAlertManagerService.removeAlert).toHaveBeenCalledWith('SHEET_FORCE_STRING_ALERT:unit-1');
selectionChanged$.next([{
range: { startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 },
primary: { actualRow: 2, actualColumn: 2 },
}]);
expect(cellAlertManagerService.showAlert).toHaveBeenCalledTimes(1);
disableForceStringAlert = true;
selectionChanged$.next([selection]);
expect(cellAlertManagerService.showAlert).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('scopes alerts to the current workbook and refreshes them when the active sheet changes', () => {
const selection = {
range: { startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 },
primary: { actualRow: 1, actualColumn: 2 },
};
const selectionChanged$ = new Subject<typeof selection[]>();
const selectionService = {
selectionChanged$,
currentSelectionParam: { unitId: 'unit-2', sheetId: 'sheet-1' },
getCurrentSelections: vi.fn(() => [selection]),
};
const sheet1 = {
getSheetId: vi.fn(() => 'sheet-1'),
getCell: vi.fn(() => ({ v: '123', t: CellValueType.STRING })),
};
const sheet2 = {
getSheetId: vi.fn(() => 'sheet-2'),
getCell: vi.fn(() => ({ v: 'abc', t: CellValueType.STRING })),
};
let activeSheet = sheet1;
const activeSheet$ = new BehaviorSubject(activeSheet);
const workbook = {
activeSheet$,
getUnitId: vi.fn(() => 'unit-1'),
getActiveSheet: vi.fn(() => activeSheet),
getStyles: vi.fn(() => ({ get: vi.fn() })),
};
const cellAlertManagerService = {
currentAlert: new Map(),
showAlert: vi.fn((alert) => cellAlertManagerService.currentAlert.set(alert.key, { alert })),
removeAlert: vi.fn((key) => cellAlertManagerService.currentAlert.delete(key)),
};
const currentUnit$ = new BehaviorSubject<object>({ getUnitId: () => 'unit-2' });
const controller = new ForceStringAlertRenderController(
{ unit: workbook } as never,
selectionService as never,
cellAlertManagerService as never,
{ t: vi.fn((key: string) => key) } as never,
{ getConfig: vi.fn(() => ({})) } as never,
{ syncExecuteCommand: vi.fn(() => true) } as never,
{ getCurrentTypeOfUnit$: vi.fn(() => currentUnit$) } as never
);
expect(cellAlertManagerService.showAlert).not.toHaveBeenCalled();
selectionService.currentSelectionParam = { unitId: 'unit-1', sheetId: 'sheet-1' };
currentUnit$.next(workbook);
expect(cellAlertManagerService.showAlert).toHaveBeenCalledTimes(1);
selectionService.currentSelectionParam = { unitId: 'unit-2', sheetId: 'sheet-1' };
currentUnit$.next({ getUnitId: () => 'unit-2' });
expect(cellAlertManagerService.currentAlert.size).toBe(0);
activeSheet = sheet2;
selectionService.currentSelectionParam = { unitId: 'unit-1', sheetId: 'sheet-2' };
currentUnit$.next(workbook);
activeSheet$.next(sheet2);
expect(cellAlertManagerService.currentAlert.size).toBe(0);
controller.dispose();
});
});
describe('AutoWidthController', () => {
it('creates undo params from existing column widths', () => {
expect(createAutoColWidthUndoMutationsByRedos({
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [{ startColumn: 1, endColumn: 3, startRow: 0, endRow: 0 }],
colWidth: { 1: 80, 2: 90, 3: 100 },
}, {
getColumnManager: () => ({
getColumnWidth: (col: number) => col * 10,
}),
} as never)).toEqual({
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [{ startColumn: 1, endColumn: 3, startRow: 0, endRow: 0 }],
colWidth: { 1: 10, 2: 20, 3: 30 },
});
});
it('builds auto-width undo and redo mutations from calculated skeleton widths', () => {
const worksheet = {
getSheetId: () => 'sheet-1',
getColumnManager: () => ({
getColumnWidth: (col: number) => col === 2 ? 88 : 66,
}),
};
const controller = new AutoWidthController(
{
getRenderUnitById: vi.fn(() => ({
with: vi.fn(() => ({
getCurrentParam: vi.fn(() => ({
skeleton: {
calculateAutoWidthInRange: vi.fn(() => [
{ col: 2, width: 120 },
{ col: 3, width: 140 },
]),
},
})),
})),
})),
} as never,
{
getUnit: vi.fn(() => ({
getUnitId: () => 'unit-1',
getSheetBySheetId: vi.fn(() => worksheet),
})),
} as never
);
expect(controller.getUndoRedoParamsOfColWidth({
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [{ startColumn: 2, endColumn: 3, startRow: 0, endRow: 0 }],
})).toEqual({
undos: [{
id: SetWorksheetColWidthMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [{ startColumn: 2, endColumn: 3, startRow: 0, endRow: 0 }],
colWidth: { 2: 88, 3: 66 },
},
}],
redos: [{
id: SetWorksheetColWidthMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
ranges: [{ startColumn: 2, endColumn: 3, startRow: 0, endRow: 0 }],
colWidth: { 2: 120, 3: 140 },
},
}],
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* 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 { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SHEET_VIEW_KEY } from '../../common/keys';
import { HoverRenderController } from '../hover-render.controller';
function createEventSubject() {
const handlers = new Set<(evt: any) => void>();
return {
subscribeEvent: vi.fn((handler: (evt: any) => void) => {
handlers.add(handler);
return { dispose: vi.fn(() => handlers.delete(handler)) };
}),
emit: (evt: any) => handlers.forEach((handler) => handler(evt)),
};
}
describe('HoverRenderController', () => {
it('forwards sheet body and header pointer events to hover manager services', () => {
const currentSkeleton$ = new Subject<any>();
const validViewportScrollInfo$ = new Subject<void>();
const mainComponent = {
onPointerEnter$: createEventSubject(),
onPointerMove$: createEventSubject(),
onPointerDown$: createEventSubject(),
onPointerUp$: createEventSubject(),
onClick$: createEventSubject(),
onDblclick$: createEventSubject(),
onPointerLeave$: createEventSubject(),
};
const rowHeader = {
onPointerMove$: createEventSubject(),
onPointerDown$: createEventSubject(),
onPointerUp$: createEventSubject(),
onDblclick$: createEventSubject(),
};
const colHeader = {
onPointerMove$: createEventSubject(),
onPointerDown$: createEventSubject(),
onPointerUp$: createEventSubject(),
onDblclick$: createEventSubject(),
};
const hoverManagerService = {
triggerMouseMove: vi.fn(),
triggerPointerDown: vi.fn(),
triggerPointerUp: vi.fn(),
triggerClick: vi.fn(),
triggerDbClick: vi.fn(),
triggerRowHeaderMouseMove: vi.fn(),
triggerRowHeaderPoniterDown: vi.fn(),
triggerRowHeaderPoniterUp: vi.fn(),
triggerRowHeaderClick: vi.fn(),
triggerRowHeaderDbClick: vi.fn(),
triggerColHeaderMouseMove: vi.fn(),
triggerColHeaderPoniterDown: vi.fn(),
triggerColHeaderPoniterUp: vi.fn(),
triggerColHeaderClick: vi.fn(),
triggerColHeaderDbClick: vi.fn(),
triggerScroll: vi.fn(),
};
const controller = new HoverRenderController(
{
unitId: 'unit-1',
mainComponent,
components: new Map([
[SHEET_VIEW_KEY.ROW, rowHeader],
[SHEET_VIEW_KEY.COLUMN, colHeader],
]),
} as never,
hoverManagerService as never,
{
getCurrentParam: vi.fn(() => ({ skeleton: {} })),
currentSkeleton$,
} as never,
{ validViewportScrollInfo$ } as never
);
mainComponent.onPointerEnter$.emit({});
expect(controller.active).toBe(true);
mainComponent.onPointerMove$.emit({ offsetX: 10, offsetY: 20 });
mainComponent.onPointerDown$.emit({ offsetX: 11, offsetY: 21 });
mainComponent.onPointerUp$.emit({ offsetX: 12, offsetY: 22 });
mainComponent.onClick$.emit({ offsetX: 13, offsetY: 23 });
mainComponent.onDblclick$.emit({ offsetX: 14, offsetY: 24 });
mainComponent.onPointerLeave$.emit({});
rowHeader.onPointerUp$.emit({ offsetX: 1, offsetY: 2 });
colHeader.onDblclick$.emit({ offsetX: 3, offsetY: 4 });
validViewportScrollInfo$.next();
expect(controller.active).toBe(false);
expect(hoverManagerService.triggerMouseMove).toHaveBeenCalledWith('unit-1', { offsetX: 10, offsetY: 20 });
expect(hoverManagerService.triggerPointerDown).toHaveBeenCalledWith('unit-1', { offsetX: 11, offsetY: 21 });
expect(hoverManagerService.triggerPointerUp).toHaveBeenCalledWith('unit-1', { offsetX: 12, offsetY: 22 });
expect(hoverManagerService.triggerClick).toHaveBeenCalledWith('unit-1', 13, 23);
expect(hoverManagerService.triggerDbClick).toHaveBeenCalledWith('unit-1', 14, 24);
expect(hoverManagerService.triggerRowHeaderPoniterUp).toHaveBeenCalledWith('unit-1', 1, 2);
expect(hoverManagerService.triggerRowHeaderClick).toHaveBeenCalledWith('unit-1', 1, 2);
expect(hoverManagerService.triggerColHeaderDbClick).toHaveBeenCalledWith('unit-1', 3, 4);
expect(hoverManagerService.triggerScroll).toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,92 @@
/**
* 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 { ISelectionWithStyle } from '@univerjs/sheets';
import { ICommandService, RANGE_TYPE } from '@univerjs/core';
import { SelectionMoveType, SheetsSelectionsService } from '@univerjs/sheets';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { MoveRangeConfirmCommand } from '../../commands/commands/move-range-confirm.command';
import { ISheetSelectionRenderService } from '../../services/selection/base-selection-render.service';
import { MoveRangeRenderController } from '../move-range.controller';
import { createRenderTestBed } from '../render-controllers/__tests__/render-test-bed';
describe('MoveRangeRenderController', () => {
it('executes the confirmation command after dragging a selection to a valid different range', () => {
const controlMoveEnd$ = new Subject<{ startRow: number; startColumn: number; endRow: number; endColumn: number }>();
const testBed = createRenderTestBed({
dependencies: [
[ISheetSelectionRenderService, {
useValue: {
getSelectionControls: () => [{
model: {
getRange: () => ({
startRow: 1,
startColumn: 2,
endRow: 3,
endColumn: 4,
rangeType: RANGE_TYPE.NORMAL,
}),
},
selectionMoveEnd$: controlMoveEnd$,
}],
},
}],
],
});
const commandService = testBed.injector.get(ICommandService);
const executeCommand = vi.spyOn(commandService, 'executeCommand').mockResolvedValue(true);
const controller = testBed.injector.createInstance(MoveRangeRenderController, testBed.context);
const selectionManagerService = testBed.injector.get(SheetsSelectionsService);
const selection: ISelectionWithStyle = {
range: {
startRow: 1,
startColumn: 2,
endRow: 3,
endColumn: 4,
rangeType: RANGE_TYPE.NORMAL,
},
primary: null,
style: null,
};
selectionManagerService.setSelections([selection], SelectionMoveType.MOVE_END);
controlMoveEnd$.next({ startRow: 5, startColumn: 6, endRow: 7, endColumn: 8 });
controlMoveEnd$.next({ startRow: 1, startColumn: 2, endRow: 3, endColumn: 4 });
controlMoveEnd$.next({ startRow: -1, startColumn: 2, endRow: 3, endColumn: 4 });
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(MoveRangeConfirmCommand.id, {
fromRange: {
startRow: 1,
startColumn: 2,
endRow: 3,
endColumn: 4,
rangeType: RANGE_TYPE.NORMAL,
},
toRange: {
startRow: 5,
startColumn: 6,
endRow: 7,
endColumn: 8,
rangeType: RANGE_TYPE.NORMAL,
},
});
controller.dispose();
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,205 @@
/**
* 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, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import {
CellValueType,
ICommandService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
LogLevel,
Plugin,
RANGE_TYPE,
touchDependencies,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FormulaDataModel, FUNCTION_NAMES_MATH, FUNCTION_NAMES_STATISTICAL } from '@univerjs/engine-formula';
import {
INumfmtService,
SetRangeValuesMutation,
SetSelectionsOperation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IStatusBarService, StatusBarService } from '../../services/status-bar.service';
import { StatusBarController } from '../status-bar.controller';
const unitId = 'test';
const sheetId = 'sheet1';
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: unitId,
sheetOrder: [sheetId],
name: '',
appVersion: '0.15.4',
locale: LocaleType.EN_US,
styles: {},
sheets: {
[sheetId]: {
id: sheetId,
name: 'Sheet1',
rowCount: 10,
columnCount: 10,
cellData: {
0: {
0: { t: CellValueType.NUMBER, v: 1 },
},
1: {
0: { t: CellValueType.NUMBER, v: 2 },
},
},
},
},
};
function createTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
([
[SheetsSelectionsService],
[IStatusBarService, { useClass: StatusBarService }],
[INumfmtService, { useValue: { getValue: () => ({ pattern: '0.00' }) } }],
[FormulaDataModel, { useValue: { getArrayFormulaCellData: () => undefined } }],
[StatusBarController],
] as Dependency[]).forEach((d) => this._injector.add(d));
touchDependencies(this._injector, [
[StatusBarController],
]);
}
}
univer.registerPlugin(TestPlugin);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, TEST_WORKBOOK_DATA);
get(SheetsSelectionsService);
get(StatusBarController);
// Trigger SheetsSelectionsService's current workbook stream.
get(IUniverInstanceService).focusUnit(unitId);
get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = get(ICommandService);
commandService.registerCommand(SetSelectionsOperation);
commandService.registerCommand(SetRangeValuesMutation);
return {
univer,
get,
commandService,
};
}
describe('StatusBarController', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
beforeEach(() => {
vi.useFakeTimers();
const testBed = createTestBed();
univer = testBed.univer;
get = testBed.get;
commandService = testBed.commandService;
});
afterEach(() => {
vi.useRealTimers();
univer.dispose();
});
it('updates status bar values on selection changes and cell updates within selection', () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet()!;
const statusBarService = get(IStatusBarService);
const selection: ISelectionWithStyle = {
range: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 0, rangeType: RANGE_TYPE.NORMAL },
primary: {
startRow: 0,
endRow: 2,
startColumn: 0,
endColumn: 1,
actualRow: 0,
actualColumn: 0,
isMerged: false,
isMergedMainCell: false,
},
style: null,
};
expect(commandService.syncExecuteCommand(SetSelectionsOperation.id, {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
selections: [selection],
})).toBeTruthy();
vi.advanceTimersByTime(150);
vi.runOnlyPendingTimers();
const state1 = statusBarService.getState();
expect(state1?.pattern).toBe('0.00');
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_MATH.SUM)?.value).toBe(3);
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.MAX)?.value).toBe(2);
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.MIN)?.value).toBe(1);
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.COUNT)?.value).toBe(2);
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.COUNTA)?.value).toBe(2);
expect(state1?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.AVERAGE)?.value).toBe(1.5);
// Update a cell inside the current selection; controller should recalc via commandExecuted hook.
expect(commandService.syncExecuteCommand(SetRangeValuesMutation.id, {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
cellValue: {
0: {
0: { t: CellValueType.NUMBER, v: 10 },
},
},
})).toBeTruthy();
vi.advanceTimersByTime(150);
vi.runOnlyPendingTimers();
const state2 = statusBarService.getState();
expect(state2?.values.find((v) => v.func === FUNCTION_NAMES_MATH.SUM)?.value).toBe(12);
expect(state2?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.MAX)?.value).toBe(10);
expect(state2?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.MIN)?.value).toBe(2);
expect(state2?.values.find((v) => v.func === FUNCTION_NAMES_STATISTICAL.AVERAGE)?.value).toBe(6);
// Business sanity: workbook data actually updated.
expect(workbook.getActiveSheet()!.getCellRaw(0, 0)?.v).toBe(10);
});
});

View File

@@ -0,0 +1,287 @@
/**
* 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, IExecutionOptions, IMutationCommonParams, IRange, Nullable, UnitModel, Workbook, Worksheet } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IAutoFillLocation, IRemoveSheetMutationParams } from '@univerjs/sheets';
import {
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
} from '@univerjs/core';
import { DeviceInputEventType } from '@univerjs/engine-render';
import {
AUTO_FILL_HOOK_TYPE,
AutoClearContentCommand,
AutoFillCommand,
AutoFillController,
IAutoFillService,
InsertColMutation,
InsertRowMutation,
MoveColsMutation,
MoveRangeMutation,
MoveRowsMutation,
RefillCommand,
RemoveColMutation,
RemoveRowMutation,
RemoveSheetMutation,
SetRangeValuesCommand,
SetRangeValuesMutation,
SetWorksheetActiveOperation,
SetWorksheetColWidthMutation,
SetWorksheetRowHeightMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { SetCellEditVisibleOperation } from '../commands/operations/cell-edit.operation';
import { SetZoomRatioOperation } from '../commands/operations/set-zoom-ratio.operation';
import { IEditorBridgeService } from '../services/editor-bridge.service';
import { ISheetSelectionRenderService } from '../services/selection/base-selection-render.service';
import { SheetsRenderService } from '../services/sheets-render.service';
export class AutoFillUIController extends Disposable {
private _currentLocation: Nullable<IAutoFillLocation> = null;
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IAutoFillService private readonly _autoFillService: IAutoFillService,
@Inject(AutoFillController) private _autoFillController: AutoFillController,
@Inject(SheetsRenderService) private _sheetsRenderService: SheetsRenderService
) {
super();
this._init();
}
private _init() {
this._initDefaultHook();
this._initQuitListener();
this._initSkeletonChange();
}
private _initSkeletonChange() {
this.disposeWithMe(this._sheetsRenderService.registerSkeletonChangingMutations(AutoFillCommand.id));
}
private _initDefaultHook() {
this.disposeWithMe(this._autoFillService.addHook({
id: 'default-ui',
type: AUTO_FILL_HOOK_TYPE.DEFAULT,
priority: 0,
onBeforeFillData: (location: IAutoFillLocation) => {
this._currentLocation = location;
},
}));
}
private _initQuitListener() {
const quitCommands = [
SetCellEditVisibleOperation.id,
AutoClearContentCommand.id,
SetRangeValuesCommand.id,
SetZoomRatioOperation.id,
SetWorksheetActiveOperation.id,
MoveRangeMutation.id,
RemoveRowMutation.id,
RemoveColMutation.id,
InsertRowMutation.id,
InsertColMutation.id,
MoveRowsMutation.id,
MoveColsMutation.id,
SetWorksheetColWidthMutation.id,
SetWorksheetRowHeightMutation.id,
SetRangeValuesMutation.id,
];
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
const fromCollab = options?.fromCollab;
const fromSync = options?.fromSync;
const fromFormula = options?.fromFormula;
if (
quitCommands.includes(command.id)
&& !fromCollab
&& !fromSync
&& !fromFormula
&& (command.params as IMutationCommonParams).trigger !== AutoFillCommand.id
&& (command.params as IMutationCommonParams).trigger !== RefillCommand.id
) {
this._quit();
}
if (command.id === RemoveSheetMutation.id) {
if ((command.params as IRemoveSheetMutationParams).unitId === this._currentLocation?.unitId &&
(command.params as IRemoveSheetMutationParams).subUnitId === this._currentLocation?.subUnitId) {
this._quit();
}
}
}));
this.disposeWithMe(this._univerInstanceService.unitDisposed$.subscribe((unit: UnitModel) => {
if (unit.getUnitId() === this._currentLocation?.unitId) {
this._quit();
}
}));
}
private _quit() {
this._currentLocation = null;
this._autoFillController.quit();
this._autoFillService.setShowMenu(false);
}
}
export class AutoFillRenderController extends Disposable implements IRenderModule {
private readonly _selectionControlDisposables = new DisposableCollection();
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(ISheetSelectionRenderService) private readonly _selectionRenderService: ISheetSelectionRenderService,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService
) {
super();
this._initSelectionControlFillChanged();
}
override dispose(): void {
this._selectionControlDisposables.dispose();
super.dispose();
}
private _initSelectionControlFillChanged(): void {
this._updateSelectionControlListeners();
this.disposeWithMe(this._selectionManagerService.selectionChanged$.subscribe(() => {
this._updateSelectionControlListeners();
}));
}
private _updateSelectionControlListeners(): void {
// Selection controls are recreated when the selected ranges change.
this._selectionControlDisposables.dispose();
this._selectionRenderService.getSelectionControls().forEach((controlSelection) => {
this._selectionControlDisposables.add(controlSelection.selectionFilled$.subscribe((filled) => {
if (
filled == null ||
filled.startColumn === -1 ||
filled.startRow === -1 ||
filled.endColumn === -1 ||
filled.endRow === -1
) {
return;
}
const sourceRange: IRange = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
const targetRange: IRange = {
startColumn: filled.startColumn,
endColumn: filled.endColumn,
startRow: filled.startRow,
endRow: filled.endRow,
};
this._executeAutoFill(sourceRange, targetRange);
}));
// Double click has the same effect as dragging the fill control, but its target range is detected automatically.
this._selectionControlDisposables.add(controlSelection.fillControl.onDblclick$.subscribeEvent(() => {
const sourceRange: IRange = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
this._handleDbClickFill(sourceRange);
}));
this._selectionControlDisposables.add(controlSelection.fillControl.onPointerDown$.subscribeEvent(() => {
const visibleState = this._editorBridgeService.isVisible();
if (visibleState.visible) {
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: this._context.unitId,
});
}
}));
});
}
private _executeAutoFill(sourceRange: IRange, targetRange: IRange): void {
const subUnitId = this._context.unit.getActiveSheet().getSheetId();
this._commandService.executeCommand(AutoFillCommand.id, {
sourceRange,
targetRange,
unitId: this._context.unitId,
subUnitId,
});
}
private _handleDbClickFill(source: IRange) {
const worksheet = this._context.unit.getActiveSheet();
const selection = detectAutoFillRange(source, worksheet);
// double click only works when dest range is longer than source range
if (selection.endRow <= source.endRow) {
return;
}
// Double click uses the same fill command as dragging, with a target range calculated from adjacent data.
this._executeAutoFill(source, selection);
}
}
export function detectAutoFillRange(source: IRange, worksheet: Worksheet): IRange {
const { startRow, endRow, startColumn, endColumn } = source;
const matrix = worksheet.getCellMatrix();
const maxRow = worksheet.getMaxRows();
const maxColumn = worksheet.getMaxColumns();
let detectEndRow = endRow + 1;
// left column first, or consider right column.
if (startColumn > 0 && matrix.getValue(detectEndRow, startColumn - 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, startColumn - 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else if (endColumn < maxColumn - 1 && matrix.getValue(detectEndRow, endColumn + 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, endColumn + 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else {
detectEndRow = endRow;
}
// If the fill range contains data, stop filling at the first row of data.
for (let i = endRow + 1; i <= detectEndRow; i++) {
for (let j = startColumn; j <= endColumn; j++) {
if (matrix.getValue(i, j)?.v != null) {
detectEndRow = i - 1;
break;
}
}
}
return {
startColumn,
endColumn,
startRow,
endRow: detectEndRow,
};
}

View File

@@ -0,0 +1,149 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutationInfo, IRange, ObjectMatrix } from '@univerjs/core';
import type { ISetWorksheetRowAutoHeightMutationParams } from '@univerjs/sheets';
import { Disposable, generateRandomId, Inject, IUniverInstanceService } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
CancelMarkDirtyRowAutoHeightOperation,
getSheetCommandTarget,
MarkDirtyRowAutoHeightOperation,
SetWorksheetRowAutoHeightMutation,
SetWorksheetRowAutoHeightMutationFactory,
SheetInterceptorService,
} from '@univerjs/sheets';
import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service';
export class AutoHeightController extends Disposable {
constructor(
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initialize();
}
private _processLazyAutoHeight(redoUndoItem: { redos: IMutationInfo[]; undos: IMutationInfo[] }, unitId: string, subUnitId: string, lazyAutoHeightRanges?: IRange[]) {
if (lazyAutoHeightRanges?.length) {
const redo = {
id: MarkDirtyRowAutoHeightOperation.id,
params: {
unitId,
subUnitId,
ranges: lazyAutoHeightRanges,
id: generateRandomId(),
},
options: {
onlyLocal: true,
},
};
const undo = {
id: CancelMarkDirtyRowAutoHeightOperation.id,
params: {
unitId,
subUnitId,
id: redo.params.id,
},
options: {
onlyLocal: true,
},
};
return {
redos: [...redoUndoItem.redos, redo],
undos: [...redoUndoItem.undos, undo],
};
}
return redoUndoItem;
}
getUndoRedoParamsOfAutoHeight(
ranges: IRange[],
subUnitIdParam?: string,
currentCellHeights?: ObjectMatrix<number>,
unitIdParam?: string
): { redos: IMutationInfo[]; undos: IMutationInfo[] } {
const target = getSheetCommandTarget(this._univerInstanceService, { unitId: unitIdParam, subUnitId: subUnitIdParam });
if (!target) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId, worksheet } = target;
const render = this._renderManagerService.getRenderUnitById(unitId);
const skeleton = render?.with<SheetSkeletonManagerService>(SheetSkeletonManagerService).ensureSkeleton(subUnitId);
if (!skeleton) {
return {
redos: [],
undos: [],
};
}
const rowsAutoHeightInfo = skeleton.calculateAutoHeightInRange(ranges, currentCellHeights);
const updatedRowsAutoHeightInfo = rowsAutoHeightInfo.filter((info) => {
const { row, autoHeight } = info;
if (!autoHeight) {
return false;
}
const currentRowHeight = worksheet.getRowHeight(row);
if (currentRowHeight === autoHeight) {
return false;
}
return true;
});
if (updatedRowsAutoHeightInfo.length === 0) {
return {
redos: [],
undos: [],
};
}
const redoParams: ISetWorksheetRowAutoHeightMutationParams = {
subUnitId,
unitId,
rowsAutoHeightInfo: updatedRowsAutoHeightInfo,
};
const undoParams: ISetWorksheetRowAutoHeightMutationParams = SetWorksheetRowAutoHeightMutationFactory(redoParams, worksheet);
return {
undos: [
{
id: SetWorksheetRowAutoHeightMutation.id,
params: undoParams,
},
],
redos: [
{
id: SetWorksheetRowAutoHeightMutation.id,
params: redoParams,
},
],
};
}
private _initialize() {
this.disposeWithMe(this._sheetInterceptorService.interceptAutoHeight({
getMutations: (info) => {
const { unitId, subUnitId, ranges, autoHeightRanges, lazyAutoHeightRanges, cellHeights } = info;
const undoRedoItem = this.getUndoRedoParamsOfAutoHeight(autoHeightRanges ?? ranges, subUnitId, cellHeights, unitId);
return this._processLazyAutoHeight(undoRedoItem, unitId, subUnitId, lazyAutoHeightRanges);
},
}));
}
}

View File

@@ -0,0 +1,103 @@
/**
* 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 { IColAutoWidthInfo, IObjectArrayPrimitiveType, Nullable, Worksheet } from '@univerjs/core';
import type { RenderManagerService } from '@univerjs/engine-render';
import type {
ISetWorksheetColWidthMutationParams,
} from '@univerjs/sheets';
import type { ISetWorksheetColIsAutoWidthCommandParams } from '../commands/commands/set-worksheet-auto-col-width.command';
import { Disposable, Inject, IUniverInstanceService } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
getSheetCommandTarget,
SetWorksheetColWidthMutation,
} from '@univerjs/sheets';
import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service';
export const createAutoColWidthUndoMutationsByRedos = (
params: ISetWorksheetColWidthMutationParams,
worksheet: Worksheet
): ISetWorksheetColWidthMutationParams => {
const { unitId, subUnitId, ranges } = params;
const colWidthObj: IObjectArrayPrimitiveType<Nullable<number>> = {};
const manager = worksheet.getColumnManager();
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
for (let j = range.startColumn; j < range.endColumn + 1; j++) {
colWidthObj[j] = manager.getColumnWidth(j);
}
}
return {
unitId,
subUnitId,
ranges,
colWidth: colWidthObj,
};
};
export class AutoWidthController extends Disposable {
constructor(
@IRenderManagerService private readonly _renderManagerService: RenderManagerService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService
) {
super();
}
getUndoRedoParamsOfColWidth(params: Required<ISetWorksheetColIsAutoWidthCommandParams>) {
const defaultValue = { redos: [], undos: [] };
const { _univerInstanceService: univerInstanceService } = this;
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return defaultValue;
const { unitId, subUnitId, worksheet } = target;
const sheetSkeletonService = this._renderManagerService.getRenderUnitById(unitId)!.with<SheetSkeletonManagerService>(SheetSkeletonManagerService);
if (!sheetSkeletonService.getCurrentParam()) return defaultValue;
const { skeleton } = sheetSkeletonService.getCurrentParam()!;
const colsAutoWidthInfo: IColAutoWidthInfo[] = skeleton.calculateAutoWidthInRange(params.ranges);
const colWidthObject: IObjectArrayPrimitiveType<Nullable<number>> = {};
for (const { col, width } of colsAutoWidthInfo) {
colWidthObject[col] = width;
}
const redoParams: ISetWorksheetColWidthMutationParams = {
subUnitId,
unitId,
ranges: params.ranges,
colWidth: colWidthObject,
};
const undoParams: ISetWorksheetColWidthMutationParams = createAutoColWidthUndoMutationsByRedos(redoParams, worksheet);
return {
undos: [
{
id: SetWorksheetColWidthMutation.id,
params: undoParams,
},
],
redos: [
{
id: SetWorksheetColWidthMutation.id,
params: redoParams,
},
],
};
}
}

View File

@@ -0,0 +1,223 @@
/**
* 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 {
ICellCustomRender,
ICellDataForSheetInterceptor,
ICellRenderContext,
Nullable,
Workbook,
} from '@univerjs/core';
import type {
IMouseEvent,
IPointerEvent,
IRenderContext,
IRenderModule,
RenderManagerService,
Spreadsheet,
} from '@univerjs/engine-render';
import type { ICellPermission, ISheetSkeletonManagerParam } from '@univerjs/sheets';
import {
Disposable,
DisposableCollection,
fromEventSubject,
Inject,
IPermissionService,
sortRules,
} from '@univerjs/core';
import { IRenderManagerService, Vector2 } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { WorkbookEditablePermission, WorksheetEditPermission } from '@univerjs/sheets';
import { throttleTime } from 'rxjs';
import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service';
export class CellCustomRenderController extends Disposable implements IRenderModule {
private _enterActiveRender: Nullable<{
render: ICellCustomRender;
cellContext: ICellRenderContext;
}>;
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService,
@IRenderManagerService private readonly _renderManagerService: RenderManagerService,
@IPermissionService private readonly _permissionService: IPermissionService
) {
super();
this._initEventBinding();
}
// eslint-disable-next-line max-lines-per-function
private _initEventBinding() {
const disposableCollection = new DisposableCollection();
const workbook = this._context.unit;
// eslint-disable-next-line max-lines-per-function
const handleSkeletonChange = (skeletonParam: Nullable<ISheetSkeletonManagerParam>) => {
disposableCollection.dispose();
if (!skeletonParam) {
return;
}
const unitId = this._context.unitId;
const { skeleton } = skeletonParam;
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
if (currentRender && currentRender.mainComponent) {
const spreadsheet = currentRender.mainComponent as Spreadsheet;
// eslint-disable-next-line max-lines-per-function
const getActiveRender = (evt: IPointerEvent | IMouseEvent) => {
const { offsetX, offsetY } = evt;
const scene = currentRender.scene;
const worksheet = workbook.getActiveSheet();
if (!worksheet) {
return;
}
const activeViewport = scene.getActiveViewportByCoord(
Vector2.FromArray([offsetX, offsetY])
);
if (!activeViewport) {
return;
}
const { scaleX, scaleY } = scene.getAncestorScale();
const scrollXY = {
x: activeViewport.viewportScrollX,
y: activeViewport.viewportScrollY,
};
const cellPos = skeleton.getCellIndexByOffset(offsetX, offsetY, scaleX, scaleY, scrollXY);
// const mergeCell = skeleton.mergeData.find((range) => {
// const { startColumn, startRow, endColumn, endRow } = range;
// return cellPos.row >= startRow && cellPos.column >= startColumn && cellPos.row <= endRow && cellPos.column <= endColumn;
// });
const mergeCell = skeleton.worksheet.getMergedCell(cellPos.row, cellPos.column);
const cellIndex = {
actualRow: mergeCell ? mergeCell.startRow : cellPos.row,
actualCol: mergeCell ? mergeCell.startColumn : cellPos.column,
mergeCell,
row: cellPos.row,
col: cellPos.column,
};
if (!cellIndex || !skeleton) {
return;
}
const cellData = worksheet.getCell(cellIndex.actualRow, cellIndex.actualCol);
if (!cellData) {
return;
}
const renders = cellData.customRender;
if (!renders?.length) {
return;
}
const row = cellIndex.actualRow;
const col = cellIndex.actualCol;
const sortedRenders = renders.sort(sortRules);
const subUnitId = worksheet.getSheetId();
const info: ICellRenderContext = {
data: cellData,
style: worksheet.getComposedCellStyleByCellData(row, col, cellData),
primaryWithCoord: skeleton.getCellWithCoordByIndex(cellIndex.actualRow, cellIndex.actualCol),
unitId,
subUnitId,
row,
col,
workbook,
worksheet,
};
const position = {
x: scrollXY.x + (offsetX / scaleX),
y: scrollXY.y + (offsetY / scaleY),
};
const activeRender = sortedRenders.find((render) => render.isHit?.(position, info));
if (!activeRender) {
return;
}
return [activeRender, info] as const;
};
const disposable = spreadsheet.onPointerDown$.subscribeEvent((evt) => {
const activeRenderInfo = getActiveRender(evt);
if (activeRenderInfo) {
const [activeRender, cellContext] = activeRenderInfo;
const { row, col, worksheet, unitId, subUnitId } = cellContext;
const sheetEditable = this._permissionService.composePermission(
[new WorkbookEditablePermission(unitId).id, new WorksheetEditPermission(unitId, subUnitId).id]
)?.every((permission) => permission.value);
if (!sheetEditable) {
return false;
}
const permission = (worksheet.getCell(row, col) as (ICellDataForSheetInterceptor & { selectionProtection: ICellPermission[] }))?.selectionProtection?.[0];
if (permission?.[UnitAction.Edit] === false) {
return false;
}
activeRender.onPointerDown?.(cellContext, evt);
}
});
const moveDisposable = fromEventSubject(spreadsheet.onPointerMove$).pipe(throttleTime(30)).subscribe((evt) => {
const activeRenderInfo = getActiveRender(evt);
if (activeRenderInfo) {
const [activeRender, cellContext] = activeRenderInfo;
if (this._enterActiveRender) {
if (this._enterActiveRender.render !== activeRender) {
this._enterActiveRender.render.onPointerLeave?.(this._enterActiveRender.cellContext, evt);
this._enterActiveRender = {
render: activeRender,
cellContext,
};
activeRender.onPointerEnter?.(cellContext, evt);
}
} else {
this._enterActiveRender = {
render: activeRender,
cellContext,
};
activeRender.onPointerEnter?.(cellContext, evt);
}
} else {
if (this._enterActiveRender) {
this._enterActiveRender.render.onPointerLeave?.(this._enterActiveRender.cellContext, evt);
this._enterActiveRender = null;
}
}
});
disposable && disposableCollection.add(disposable);
moveDisposable && disposableCollection.add(moveDisposable);
}
};
this.disposeWithMe(this._sheetSkeletonManagerService.currentSkeleton$.subscribe(handleSkeletonChange));
handleSkeletonChange(this._sheetSkeletonManagerService.getCurrentParam());
this.disposeWithMe(disposableCollection);
}
}

View File

@@ -0,0 +1,55 @@
/**
* 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 { Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, Inject } from '@univerjs/core';
import { filter } from 'rxjs';
import { CellPopupManagerService } from '../services/cell-popup-manager.service';
import { IEditorBridgeService } from '../services/editor-bridge.service';
/**
* Controller to hide cell popups when entering edit mode.
* This ensures that popups don't overlap with the cell editor.
*/
export class CellPopupEditorController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(CellPopupManagerService) private readonly _cellPopupManagerService: CellPopupManagerService
) {
super();
this._initEditorVisibleListener();
}
private _initEditorVisibleListener(): void {
this.disposeWithMe(
this._editorBridgeService.visible$.pipe(
filter((params) => params.visible)
).subscribe(() => {
const editCellState = this._editorBridgeService.getEditLocation();
if (editCellState) {
this._cellPopupManagerService.hidePopup(
editCellState.unitId,
editCellState.sheetId,
editCellState.row,
editCellState.column
);
}
})
);
}
}

View File

@@ -0,0 +1,70 @@
/**
* 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, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { CURSOR_TYPE, getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { ToggleCellCheckboxCommand } from '@univerjs/sheets';
import { HoverManagerService } from '../services/hover-manager.service';
export class SheetCheckboxController extends Disposable {
private _isPointer = false;
constructor(
@Inject(HoverManagerService) private _hoverManagerService: HoverManagerService,
@IUniverInstanceService private _instanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
this._initPointerEvent();
this._initHover();
}
private get _mainComponent() {
return getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._instanceService, this._renderManagerService)?.mainComponent;
}
private _initHover() {
this.disposeWithMe(this._hoverManagerService.currentRichText$.subscribe((richText) => {
if (richText?.bullet) {
if (!this._isPointer) {
this._mainComponent?.setCursor(CURSOR_TYPE.POINTER);
}
this._isPointer = true;
} else {
if (this._isPointer) {
this._mainComponent?.setCursor(CURSOR_TYPE.AUTO);
}
this._isPointer = false;
}
}));
}
private _initPointerEvent() {
this.disposeWithMe(this._hoverManagerService.currentClickedCell$.subscribe((cell) => {
const { location, bullet } = cell;
if (!bullet) {
return;
}
this._commandService.executeCommand(ToggleCellCheckboxCommand.id, {
unitId: location.unitId,
subUnitId: location.subUnitId,
row: location.row,
col: location.col,
paragraphIndex: bullet.startIndex,
});
}));
}
}

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 { IUniverSheetsUIConfig } from '../../config/config';
import { Disposable, IConfigService, Inject, Injector } from '@univerjs/core';
import { BuiltInUIPart, connectInjector, IUIPartsService } from '@univerjs/ui';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { ClipboardPopupMenu } from '../../views/clipboard/ClipboardPopupMenu';
export class SheetClipboardUIController extends Disposable {
constructor(
@Inject(Injector) injector: Injector,
@IConfigService configService: IConfigService,
@IUIPartsService uiPartsService: IUIPartsService
) {
super();
const config = configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY);
if (!config?.clipboardConfig?.hidePasteOptions) {
this.disposeWithMe(uiPartsService.registerComponent(
BuiltInUIPart.CONTENT,
() => connectInjector(ClipboardPopupMenu, injector)
));
}
}
}

View File

@@ -0,0 +1,916 @@
/**
* 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 {
ICellData,
ICommandInfo,
IDocumentData,
IMutationInfo,
IObjectArrayPrimitiveType,
IObjectMatrixPrimitiveType,
IRange,
Workbook,
Worksheet,
} from '@univerjs/core';
import type {
IInsertColMutationParams,
IInsertRowMutationParams,
ISetRangeValuesMutationParams,
ISetWorksheetColWidthMutationParams,
} from '@univerjs/sheets';
import type { IUniverSheetsUIConfig } from '../../config/config';
import type { LocaleKey } from '../../locale/types';
import type {
ICellDataWithSpanInfo,
IClipboardPropertyItem,
ICopyPastePayload,
ISheetClipboardHook,
ISheetDiscreteRangeLocation,
} from '../../services/clipboard/type';
import type { IScrollStateWithSearchParam } from '../../services/scroll-manager.service';
import {
createDocumentModelWithStyle,
DEFAULT_WORKSHEET_COLUMN_WIDTH,
DEFAULT_WORKSHEET_COLUMN_WIDTH_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
EDITOR_ACTIVATED,
extractPureTextFromCell,
getNumfmtLocaleTag,
getNumfmtParseValueFilter,
handleStyleToString,
ICommandService,
IConfigService,
IContextService,
Inject,
Injector,
isFormulaString,
IUniverInstanceService,
LocaleService,
ObjectMatrix,
Optional,
RxDisposable,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { convertBodyToHtml, DocSelectionRenderService } from '@univerjs/docs-ui';
import { IRenderManagerService, withCurrentTypeOfRenderer } from '@univerjs/engine-render';
import {
AddWorksheetMergeCommand,
InsertColMutation,
InsertRowMutation,
MAX_CELL_PER_SHEET_KEY,
MoveColsMutation,
MoveRangeMutation,
MoveRowsMutation,
RemoveColMutation,
RemoveRowMutation,
SetRangeValuesMutation,
SetRangeValuesUndoMutationFactory,
SetWorksheetColWidthMutation,
SetWorksheetColWidthMutationFactory,
} from '@univerjs/sheets';
import { IClipboardInterfaceService, IMessageService } from '@univerjs/ui';
import { Subject, takeUntil } from 'rxjs';
import {
SheetCopyCommand,
SheetCutCommand,
SheetOptionalPasteCommand,
SheetPasteBesidesBorderCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteFormatCommand,
SheetPasteShortKeyCommand,
SheetPasteValueCommand,
} from '../../commands/commands/clipboard.command';
import { SetScrollOperation } from '../../commands/operations/scroll.operation';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import {
escapeSpecialCode,
FORMULA_CLIPBOARD_MIME_TYPE,
ISheetClipboardService,
PREDEFINED_HOOK_NAME_COPY,
PREDEFINED_HOOK_NAME_PASTE,
} from '../../services/clipboard/clipboard.service';
import { ISheetEmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service';
import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service';
import { whenSheetEditorFocused } from '../shortcuts/utils';
import { RemovePasteMenuCommands } from './const';
import {
generateBody,
getClearAndSetMergeMutations,
getDefaultOnPasteCellMutations,
getSetCellStyleMutations,
getSetCellValueMutations,
} from './utils';
/**
* This controller add basic clipboard logic for basic features such as text color / BISU / row widths to the clipboard
* service. You can create a similar clipboard controller to add logic for your own features.
*/
const shouldRemoveShapeIds = [
InsertColMutation.id,
InsertRowMutation.id,
RemoveColMutation.id,
RemoveRowMutation.id,
MoveRangeMutation.id,
MoveRowsMutation.id,
MoveColsMutation.id,
];
export class SheetClipboardController extends RxDisposable {
private _refreshOptionalPaste$ = new Subject();
refreshOptionalPaste$ = this._refreshOptionalPaste$.asObservable();
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@ICommandService private readonly _commandService: ICommandService,
@IContextService private readonly _contextService: IContextService,
@IConfigService private readonly _configService: IConfigService,
@ISheetClipboardService private readonly _sheetClipboardService: ISheetClipboardService,
@IClipboardInterfaceService private readonly _clipboardInterfaceService: IClipboardInterfaceService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localService: LocaleService,
@Optional(ISheetEmbedRuntimeFocusCoordinator)
private readonly _embedRuntimeFocusCoordinator?: ISheetEmbedRuntimeFocusCoordinator
) {
super();
this._init();
this._initCommandListener();
this._pasteWithDoc();
}
refreshOptionalPaste() {
this._refreshOptionalPaste$.next(Math.random());
}
private _pasteWithDoc() {
const sheetPasteShortKeyFn = (docSelectionRenderService: DocSelectionRenderService) => {
docSelectionRenderService.onPaste$.pipe(takeUntil(this.dispose$)).subscribe(async (config) => {
if (!this._isSheetGridPasteTarget()) {
return;
}
// editor's value should not change and avoid triggering input event
config!.event.preventDefault();
const clipboardEvent = config!.event as ClipboardEvent;
const htmlContent = clipboardEvent.clipboardData?.getData('text/html');
const textContent = clipboardEvent.clipboardData?.getData('text/plain');
const files = this._resolveClipboardFiles(clipboardEvent.clipboardData);
const target = this._sheetClipboardService.capturePasteTarget();
if (!target) {
return;
}
const formulaClipboardPayload = await this._readFormulaClipboardPayload();
await this._commandService.executeCommand(SheetPasteShortKeyCommand.id, {
htmlContent,
textContent,
files,
formulaClipboardPayload,
target,
});
});
docSelectionRenderService?.onKeydown$.subscribe((event) => {
if ((event.event as KeyboardEvent).key === 'Escape') {
this._sheetClipboardService.removeMarkSelection();
}
});
};
// docSelectionRenderService would init before clipboardService controller when creating a univer.
// But when creating a sheet unit again after the previous sheet unit has been disposed, clipboard controller would init before docSelectionRenderService.
// In this case, DocSelectionRenderService isn't ready when clipboardService controller init.
// So better listening to the created$ event of the renderManagerService to get the DocSelectionRenderService instance.
let docSelectionRenderService = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)?.with(DocSelectionRenderService);
if (docSelectionRenderService) {
sheetPasteShortKeyFn(docSelectionRenderService);
}
this._renderManagerService.created$.subscribe((renderer) => {
if (renderer.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
docSelectionRenderService = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)?.with(DocSelectionRenderService);
if (docSelectionRenderService) {
sheetPasteShortKeyFn(docSelectionRenderService);
}
}
});
}
private _isSheetGridPasteTarget(): boolean {
if (whenSheetEditorFocused(this._contextService)) {
return true;
}
const activeRuntime = this._embedRuntimeFocusCoordinator?.resolveActiveChildSessionRuntimeScope();
if (
activeRuntime?.childType !== UniverInstanceType.UNIVER_SHEET ||
!activeRuntime.childUnitId ||
this._contextService.getContextValue(EDITOR_ACTIVATED)
) {
return false;
}
return this._instanceService
.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)
?.getUnitId() === activeRuntime.childUnitId;
}
private _resolveClipboardFiles(clipboardData: DataTransfer | null) {
if (!clipboardData) {
return;
}
const files = Array.from(clipboardData.items)
.map((item) => item.kind === 'file' ? item.getAsFile() : undefined)
.filter(Boolean) as File[];
return files.length > 0 ? files : undefined;
}
private async _readFormulaClipboardPayload(): Promise<string | undefined> {
if (!this._clipboardInterfaceService.supportClipboard) {
return undefined;
}
try {
const items = await this._clipboardInterfaceService.read();
const item = items.find((clipboardItem) => clipboardItem.types.includes(FORMULA_CLIPBOARD_MIME_TYPE));
if (!item) {
return undefined;
}
const blob = await item.getType(FORMULA_CLIPBOARD_MIME_TYPE);
return blob.text();
} catch {
return undefined;
}
}
private _init(): void {
// register sheet clipboard commands
[SheetCopyCommand, SheetCutCommand, SheetPasteCommand].forEach((command) =>
this.disposeWithMe(this._commandService.registerMultipleCommand(command))
);
[
SheetPasteValueCommand,
SheetPasteFormatCommand,
SheetPasteColWidthCommand,
SheetPasteBesidesBorderCommand,
SheetPasteShortKeyCommand,
SheetOptionalPasteCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
// register basic sheet clipboard hooks
this.disposeWithMe(this._sheetClipboardService.addClipboardHook(this._initCopyingHooks()));
this.disposeWithMe(this._sheetClipboardService.addClipboardHook(this._initPastingRowsHook()));
this.disposeWithMe(this._sheetClipboardService.addClipboardHook(this._initPastingColumnsHook()));
this.disposeWithMe(this._sheetClipboardService.addClipboardHook(this._initPastingHook()));
const disposables = this._initSpecialPasteHooks().map((hook) =>
this._sheetClipboardService.addClipboardHook(hook)
);
this.disposeWithMe({ dispose: () => disposables.forEach((d) => d.dispose()) });
}
// eslint-disable-next-line max-lines-per-function
private _initCopyingHooks(): ISheetClipboardHook {
const self = this;
let currentSheet: Worksheet | null = null;
return {
id: PREDEFINED_HOOK_NAME_COPY.DEFAULT_COPY,
isDefaultHook: true,
onBeforeCopy(unitId, subUnitId) {
currentSheet = self._getWorksheet(unitId, subUnitId);
},
onCopyCellContent(row: number, col: number): string {
const cell = currentSheet!.getCell(row, col);
if (cell?.p?.body?.paragraphs || cell?.p?.body?.textRuns) {
return convertBodyToHtml(cell.p);
}
const content = extractPureTextFromCell(cell);
if (content.trim() === '') {
return content;
}
/**
* Used for generating the copied HTML, so we need to escape special code to avoid breaking the HTML structure.
* For example, if the cell value contains <, > or &, it would break the HTML structure and cause the copied content to be incorrect.
*/
return escapeSpecialCode(content);
},
onCopyCellStyle: (row: number, col: number, rowSpan?: number, colSpan?: number) => {
const properties: IClipboardPropertyItem = {};
if (rowSpan || colSpan) {
properties.rowspan = `${rowSpan || 1}`;
properties.colspan = `${colSpan || 1}`;
}
const mergedCellByRowCol = currentSheet!.getMergedCell(row, col);
/**
* This is for generating the copied HTML, which requires all the styles applied to the cell, including conditional formatting, data validation, number format, etc.
* So here we use `getComposedCellStyle` to get the final style of the cell.
*/
const textStyle = currentSheet!.getComposedCellStyle(row, col);
let style = '';
if (textStyle) {
style = handleStyleToString(textStyle);
}
if (mergedCellByRowCol) {
const endRow = mergedCellByRowCol.endRow;
const endColumn = mergedCellByRowCol.endColumn;
const lastRange = currentSheet!.getRange(endRow, endColumn);
const lastTextStyle = lastRange.getTextStyle();
if (lastTextStyle) {
const lastStyle = handleStyleToString(lastTextStyle);
if (style) {
style += lastStyle ? `;${lastStyle}` : '';
} else {
style = lastStyle;
}
}
}
if (style) {
properties.style = style;
}
return Object.keys(properties).length ? properties : null;
},
onCopyColumn(col: number) {
const sheet = currentSheet!;
const width = sheet.getColumnWidth(col);
return {
width: `${width}`,
};
},
onCopyRow(row: number) {
const sheet = currentSheet!;
const height = sheet.getRowHeight(row);
return {
style: `height: ${height}px;`,
};
},
onAfterCopy() {
currentSheet = null;
},
getFilteredOutRows(unitId: string, subUnitId: string, range: IRange) {
const worksheet = self._getWorksheet(unitId, subUnitId);
if (!worksheet) return [];
const { startRow, endRow } = range;
const res: number[] = [];
for (let r = startRow; r <= endRow; r++) {
if (worksheet.getRowFiltered(r)) {
res.push(r);
}
}
return res;
},
};
}
private _initPastingHook(): ISheetClipboardHook {
const self = this;
return {
id: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE,
isDefaultHook: true,
onBeforePaste({ range }) {
// examine if pasting would cause number of cells to exceed the upper limit
// this is not implemented yet
const maxConfig = self._configService.getConfig<number>(MAX_CELL_PER_SHEET_KEY);
const endRow = range.rows[range.rows.length - 1];
const endColumn = range.cols[range.cols.length - 1];
if (maxConfig && endRow * endColumn > maxConfig) {
self._messageService.show({
type: MessageType.Error,
content: self._localService.t<LocaleKey>('sheets-ui.clipboard.paste.exceedMaxCells'),
}); // TODO: show error info
return false;
}
return true;
},
onPastePlainText(pasteTo: ISheetDiscreteRangeLocation, text: string, payload: ICopyPastePayload) {
return self._onPastePlainText(pasteTo, text, payload);
},
onPasteCells(
pasteFrom: ISheetDiscreteRangeLocation,
pasteTo: ISheetDiscreteRangeLocation,
data: ObjectMatrix<ICellDataWithSpanInfo>,
payload: ICopyPastePayload
) {
return self._onPasteCells(pasteFrom, pasteTo, data, payload);
},
};
}
private _initPastingRowsHook(): ISheetClipboardHook {
const self = this;
return {
id: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE_ROWS,
isDefaultHook: true,
onPasteRows(pasteTo) {
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const { range, unitId, subUnitId } = pasteTo;
const { rows, cols } = range;
if (!rows || !cols || rows.length === 0 || cols.length === 0) {
return { redos, undos };
}
const worksheet = self._getWorksheet(unitId, subUnitId);
const worksheetLastRow = worksheet.getMaxRows() - 1;
const pasteRangeEndRow = rows[rows.length - 1];
const pasteRangeStartColumn = cols[0];
const pasteRangeEndColumn = cols[cols.length - 1];
// If the range is outside ot the worksheet's boundary, we should add rows
const addingRowsCount = pasteRangeEndRow - worksheetLastRow;
if (addingRowsCount > 0) {
const insertRowMutationParams: IInsertRowMutationParams = {
unitId,
subUnitId,
range: {
startRow: worksheetLastRow + 1,
endRow: pasteRangeEndRow,
startColumn: pasteRangeStartColumn,
endColumn: pasteRangeEndColumn,
},
};
redos.push({
id: InsertRowMutation.id,
params: insertRowMutationParams,
});
undos.push({
id: RemoveRowMutation.id,
params: insertRowMutationParams,
});
}
return {
redos,
undos,
};
},
};
}
private _initPastingColumnsHook(): ISheetClipboardHook {
const self = this;
return {
id: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE_COLUMNS,
isDefaultHook: true,
onPasteColumns(pasteTo) {
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const { range, unitId, subUnitId } = pasteTo;
const { rows, cols } = range;
if (!rows || !cols || rows.length === 0 || cols.length === 0) {
return { redos, undos };
}
const worksheet = self._getWorksheet(unitId, subUnitId);
const worksheetLastColumn = worksheet.getMaxColumns() - 1;
const pasteRangeStartRow = rows[0];
const pasteRangeEndRow = rows[rows.length - 1];
const pasteRangeEndColumn = cols[cols.length - 1];
// If the range is outside ot the worksheet's boundary, we should add columns
const addingColsCount = pasteRangeEndColumn - worksheetLastColumn;
if (addingColsCount > 0) {
const InsertColMutationParams: IInsertColMutationParams = {
unitId,
subUnitId,
range: {
startRow: pasteRangeStartRow,
endRow: pasteRangeEndRow,
startColumn: worksheetLastColumn + 1,
endColumn: pasteRangeEndColumn,
},
};
redos.push({
id: InsertColMutation.id,
params: InsertColMutationParams,
});
undos.push({
id: RemoveColMutation.id,
params: InsertColMutationParams,
});
}
return {
redos,
undos,
};
},
};
}
private _generateDocumentDataModelSnapshot(snapshot: Partial<IDocumentData>) {
const currentSkeleton = withCurrentTypeOfRenderer(
UniverInstanceType.UNIVER_SHEET,
SheetSkeletonManagerService,
this._instanceService,
this._renderManagerService
)?.getCurrentParam();
if (currentSkeleton == null) {
return null;
}
const documentModel = createDocumentModelWithStyle('', {});
const p = documentModel?.getSnapshot();
const documentData = { ...p, ...snapshot };
documentModel?.reset(documentData);
return documentModel?.getSnapshot();
}
private _onPastePlainText(pasteTo: ISheetDiscreteRangeLocation, text: string, payload: ICopyPastePayload) {
const { range, unitId, subUnitId } = pasteTo;
const workbook = this._instanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const parseOptions = workbook
? { locale: getNumfmtLocaleTag(workbook.getSnapshot().locale), dateSystem: workbook.getDateSystem() }
: undefined;
let cellValue: IObjectMatrixPrimitiveType<ICellData>;
if (/\r|\n/.test(text) || Tools.isLegalUrl(text)) {
const body = generateBody(text);
const p = this._generateDocumentDataModelSnapshot({ body });
cellValue = {
[range.rows[0]]: {
[range.cols[0]]: {
p,
v: null,
f: null,
},
},
};
} else {
if (isFormulaString(text)) {
cellValue = {
[range.rows[0]]: {
[range.cols[0]]: {
f: text,
v: null,
p: null,
},
},
};
} else {
const pattern = getNumfmtParseValueFilter(text, parseOptions);
if (pattern?.z) {
cellValue = {
[range.rows[0]]: {
[range.cols[0]]: {
v: pattern.v,
s: {
n: {
pattern: pattern.z,
},
},
f: null,
},
},
};
} else {
cellValue = {
[range.rows[0]]: {
[range.cols[0]]: {
v: text,
f: null,
},
},
};
}
}
}
const setRangeValuesParams: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue,
};
return {
redos: [
{
id: SetRangeValuesMutation.id,
params: setRangeValuesParams,
},
],
undos: [
{
id: SetRangeValuesMutation.id,
params: SetRangeValuesUndoMutationFactory(this._injector, setRangeValuesParams),
},
],
};
}
private _onPasteCells(
pasteFrom: ISheetDiscreteRangeLocation,
pasteTo: ISheetDiscreteRangeLocation,
data: ObjectMatrix<ICellDataWithSpanInfo>,
payload: ICopyPastePayload
): {
redos: IMutationInfo[];
undos: IMutationInfo[];
} {
return this._injector.invoke((accessor) => {
return getDefaultOnPasteCellMutations(pasteFrom, pasteTo, data, payload, accessor);
});
}
// eslint-disable-next-line max-lines-per-function
private _initSpecialPasteHooks() {
const self = this;
const specialPasteValueHook: ISheetClipboardHook = {
id: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
specialPasteInfo: {
label: 'specialPaste.value',
},
onPasteCells: (pasteFrom, pasteTo, data) => {
return this._injector.invoke((accessor) => {
const { redo, undo } = getSetCellValueMutations(pasteTo, pasteFrom, data, accessor);
return {
redos: [redo],
undos: [undo],
};
});
},
};
const specialPasteFormatHook: ISheetClipboardHook = {
id: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
specialPasteInfo: {
label: 'specialPaste.format',
},
onPasteCells: (pasteFrom, pasteTo, matrix) => {
const redoMutationsInfo: IMutationInfo[] = [];
const undoMutationsInfo: IMutationInfo[] = [];
// Set cell style
const { redo, undo } = this._injector.invoke((accessor) => {
return getSetCellStyleMutations(
pasteTo,
pasteFrom,
matrix,
accessor
);
});
redoMutationsInfo.push(redo);
undoMutationsInfo.push(undo);
// clear and set merge
const { undos: mergeUndos, redos: mergeRedos } = this._injector.invoke((accessor) => {
return getClearAndSetMergeMutations(
pasteTo,
matrix,
accessor
);
});
redoMutationsInfo.push(...mergeRedos);
undoMutationsInfo.push(...mergeUndos);
return {
undos: undoMutationsInfo,
redos: redoMutationsInfo,
};
},
};
const specialPasteColWidthHook: ISheetClipboardHook = {
id: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
specialPasteInfo: {
label: 'specialPaste.colWidth',
},
onPasteCells() {
return {
undos: [],
redos: [],
};
},
onPasteColumns(pasteTo, colProperties, payload) {
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
if (payload.pasteType !== PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH) {
return { redos, undos };
}
const { range, unitId, subUnitId } = pasteTo;
const { rows, cols } = range;
if (!rows || !cols || rows.length === 0 || cols.length === 0) {
return { redos, undos };
}
const worksheet = self._getWorksheet(unitId, subUnitId);
const defaultColumnWidth = self._configService.getConfig<number>(DEFAULT_WORKSHEET_COLUMN_WIDTH_KEY) ?? DEFAULT_WORKSHEET_COLUMN_WIDTH;
const pasteRangeStartRow = rows[0];
const pasteRangeEndRow = rows[rows.length - 1];
const pasteRangeStartColumn = cols[0];
const pasteRangeEndColumn = cols[cols.length - 1];
const colPropertiesLength = colProperties.length;
if (colPropertiesLength > 0) {
const colWidth: IObjectArrayPrimitiveType<number> = {};
for (let c = pasteRangeStartColumn; c <= pasteRangeEndColumn; c++) {
const index = (c - pasteRangeStartColumn) % colPropertiesLength;
const property = colProperties[index];
if (property.width && Number(property.width) !== defaultColumnWidth) {
colWidth[c] = Number(property.width);
}
}
const setWorksheetColWidthMutationParams: ISetWorksheetColWidthMutationParams = {
unitId,
subUnitId,
ranges: [
{
startRow: pasteRangeStartRow,
endRow: pasteRangeEndRow,
startColumn: pasteRangeStartColumn,
endColumn: pasteRangeEndColumn,
},
],
colWidth,
};
redos.push({
id: SetWorksheetColWidthMutation.id,
params: setWorksheetColWidthMutationParams,
});
undos.unshift({
id: SetWorksheetColWidthMutation.id,
params: SetWorksheetColWidthMutationFactory(setWorksheetColWidthMutationParams, worksheet),
});
}
return {
redos,
undos,
};
},
};
const specialPasteBesidesBorder: ISheetClipboardHook = {
id: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_BESIDES_BORDER,
specialPasteInfo: {
label: 'specialPaste.besidesBorder',
},
onPasteCells: (pasteFrom, pasteTo, matrix, payload) => {
const workbook = self._instanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const redoMutationsInfo: IMutationInfo[] = [];
const undoMutationsInfo: IMutationInfo[] = [];
const { range, unitId, subUnitId } = pasteTo;
const valueMatrix = new ObjectMatrix<ICellData>();
// TODO@Dushusir: undo selection
matrix.forValue((row, col, value) => {
const style = value.s;
if (typeof style === 'object') {
const newValue = Tools.deepClone(value);
if (newValue.s) {
newValue.s = {
...style,
bd: null,
};
}
valueMatrix.setValue(range.rows[row], range.cols[col], newValue);
}
});
// set cell value and style
const setValuesMutation: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: valueMatrix.clone(),
};
redoMutationsInfo.push({
id: SetRangeValuesMutation.id,
params: setValuesMutation,
});
// undo
const undoSetValuesMutation: ISetRangeValuesMutationParams = this._injector.invoke(
SetRangeValuesUndoMutationFactory,
setValuesMutation
);
undoMutationsInfo.push({
id: SetRangeValuesMutation.id,
params: undoSetValuesMutation,
});
const { undos, redos } = this._injector.invoke((accessor) => {
return getClearAndSetMergeMutations(pasteTo, matrix, accessor);
});
undoMutationsInfo.push(...undos);
redoMutationsInfo.push(...redos);
return {
redos: redoMutationsInfo,
undos: undoMutationsInfo,
};
},
};
return [specialPasteValueHook, specialPasteFormatHook, specialPasteColWidthHook, specialPasteBesidesBorder];
}
private _getWorksheet(unitId: string, subUnitId: string): Worksheet {
const worksheet = this._instanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)?.getSheetBySheetId(subUnitId);
if (!worksheet) {
throw new Error(
`[SheetClipboardController]: cannot find a worksheet with unitId ${unitId} and subUnitId ${subUnitId}.`
);
}
return worksheet;
}
private _initCommandListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === AddWorksheetMergeCommand.id) {
this._sheetClipboardService.removeMarkSelection();
} else if (shouldRemoveShapeIds.includes(command.id)) {
this._sheetClipboardService.removeMarkSelection();
}
})
);
const sheetsUIConfig = this._configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY);
if (sheetsUIConfig?.clipboardConfig?.hidePasteOptions) {
return;
}
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (RemovePasteMenuCommands.includes(command.id)) {
this._sheetClipboardService.disposePasteOptionsCache();
}
})
);
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetScrollOperation.id) {
if (!this._sheetClipboardService.getPasteMenuVisible()) {
return;
}
const params = command.params as IScrollStateWithSearchParam;
const scrollUnitId = params.unitId;
const pasteOptionsCache = this._sheetClipboardService.getPasteOptionsCache();
const menuUnitId = pasteOptionsCache?.target.unitId;
if (scrollUnitId === menuUnitId) {
this._refreshOptionalPaste$.next(Math.random());
}
}
})
);
}
}

View File

@@ -0,0 +1,37 @@
/**
* 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 { UndoCommandId } from '@univerjs/core';
import { InsertColMutation, InsertRowMutation, MoveColsMutation, MoveRangeMutation, MoveRowsMutation, RemoveColMutation, RemoveRowMutation, SetRangeValuesCommand, SetSelectionsOperation, SetWorksheetColWidthMutation, SetWorksheetRowHeightMutation } from '@univerjs/sheets';
import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation';
import { SetZoomRatioOperation } from '../../commands/operations/set-zoom-ratio.operation';
export const RemovePasteMenuCommands = [
SetCellEditVisibleOperation.id,
SetRangeValuesCommand.id,
SetZoomRatioOperation.id,
MoveRangeMutation.id,
RemoveRowMutation.id,
RemoveColMutation.id,
InsertRowMutation.id,
InsertColMutation.id,
MoveRowsMutation.id,
MoveColsMutation.id,
SetWorksheetColWidthMutation.id,
SetWorksheetRowHeightMutation.id,
SetSelectionsOperation.id,
UndoCommandId,
];

View File

@@ -0,0 +1,575 @@
/**
* 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.
*/
/* eslint-disable max-lines-per-function */
import type { IAccessor, ICellData, ICustomRange, IDocumentBody, IMutationInfo, IParagraph, IRange, IStyleData, Nullable } from '@univerjs/core';
import type {
IAddWorksheetMergeMutationParams,
IDiscreteRange,
IRemoveWorksheetMergeMutationParams,
ISetRangeValuesMutationParams,
} from '@univerjs/sheets';
import type { ICellDataWithSpanInfo, ICopyPastePayload, ISheetDiscreteRangeLocation } from '../../services/clipboard/type';
import {
CellValueType,
cloneCellDataMatrix,
createParagraphId,
createSectionId,
CustomRangeType,
generateRandomId,
getNumfmtLocaleTag,
getNumfmtParseValueFilter,
isTextFormat,
IUniverInstanceService,
ObjectMatrix,
Rectangle,
Tools,
willLoseNumericPrecision,
} from '@univerjs/core';
import {
AddMergeUndoMutationFactory,
AddWorksheetMergeMutation,
discreteRangeToRange,
getMoveRangeCommandMutations,
getSheetCommandTarget,
RemoveMergeUndoMutationFactory,
RemoveWorksheetMergeMutation,
SetRangeValuesMutation,
SetRangeValuesUndoMutationFactory,
} from '@univerjs/sheets';
import { COPY_TYPE } from '../../services/clipboard/type';
import { isRichText } from '../editor/editing.render-controller';
import { virtualizeDiscreteRanges } from '../utils/range-tools';
interface IExternalPasteNumberFormatResult {
n?: IStyleData['n'];
t?: CellValueType;
v?: ICellData['v'];
}
// if special paste need append mutations instead of replace the default, it can use this function to generate default mutations.
/**
*
* @param pasteFrom
* @param pasteTo
* @param data
* @param payload
* @param accessor
*/
export function getDefaultOnPasteCellMutations(
pasteFrom: ISheetDiscreteRangeLocation,
pasteTo: ISheetDiscreteRangeLocation,
data: ObjectMatrix<ICellDataWithSpanInfo>,
payload: ICopyPastePayload,
accessor: IAccessor
) {
if (payload.copyType === COPY_TYPE.CUT) {
const { undos, redos } = getMoveRangeMutations(pasteFrom, pasteTo, accessor);
return { undos, redos };
}
const redoMutationsInfo: IMutationInfo[] = [];
const undoMutationsInfo: IMutationInfo[] = [];
// Set cell value and style
const { redo, undo } = getSetCellDataMutations(
pasteTo,
pasteFrom,
data,
accessor
);
redoMutationsInfo.push(redo);
undoMutationsInfo.push(undo);
// clear and add merge
const { undos: clearMergeUndos, redos: clearMergeRedos } = getClearAndSetMergeMutations(
pasteTo,
data,
accessor
);
redoMutationsInfo.push(...clearMergeRedos);
undoMutationsInfo.push(...clearMergeUndos);
return {
undos: undoMutationsInfo,
redos: redoMutationsInfo,
};
}
export function getMoveRangeMutations(
from: {
unitId: string;
subUnitId: string;
range?: IDiscreteRange;
},
to: {
unitId: string;
subUnitId: string;
range?: IDiscreteRange;
},
accessor: IAccessor
) {
const { range: fromDiscreteRange, subUnitId: fromSubUnitId, unitId } = from;
const { range: toDiscreteRange, subUnitId: toSubUnitId } = to;
const toRange = toDiscreteRange ? discreteRangeToRange(toDiscreteRange) : null;
const fromRange = fromDiscreteRange ? discreteRangeToRange(fromDiscreteRange) : null;
if (!fromRange || !toRange) {
return {
undos: [],
redos: [],
};
}
const moveRangeMutations = getMoveRangeCommandMutations(accessor, {
fromRange,
toRange,
fromUnitId: unitId,
fromSubUnitId,
toUnitId: to.unitId,
toSubUnitId,
}, {
includeAutoHeight: false,
});
return moveRangeMutations
? {
undos: moveRangeMutations.undos,
redos: moveRangeMutations.redos,
}
: {
undos: [],
redos: [],
};
}
function getExternalPasteNumberFormatResult(
value: ICellData['v'],
currentCellStyle: Nullable<IStyleData> | undefined,
parseOptions?: Parameters<typeof getNumfmtParseValueFilter>[1]
): IExternalPasteNumberFormatResult {
if (isTextFormat(currentCellStyle?.n?.pattern)) {
return {
n: currentCellStyle?.n,
t: CellValueType.STRING,
};
}
const content = String(value).trim();
const numfmtValue = getNumfmtParseValueFilter(content, parseOptions);
const result: IExternalPasteNumberFormatResult = {};
if (numfmtValue?.v !== undefined && typeof numfmtValue.v === 'number') {
// If the numeric string will lose precision when converted to a number, set the cell type to force string
// e.g. 123456789123456789
// e.g. 1212121212121212.2345
if (!numfmtValue.z && willLoseNumericPrecision(content)) {
result.t = CellValueType.FORCE_STRING;
} else {
result.v = numfmtValue.v;
}
}
if (numfmtValue?.z) {
result.n = { pattern: numfmtValue.z };
}
return result;
}
export function getSetCellDataMutations(
pasteTo: ISheetDiscreteRangeLocation,
pasteFrom: Nullable<ISheetDiscreteRangeLocation>,
matrix: ObjectMatrix<ICellDataWithSpanInfo>,
accessor: IAccessor
): {
redo: IMutationInfo;
undo: IMutationInfo;
} {
const { unitId, subUnitId, range } = pasteTo;
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), { unitId, subUnitId });
const worksheet = target?.worksheet;
const parseOptions = target
? { locale: getNumfmtLocaleTag(target.workbook.getSnapshot().locale), dateSystem: target.workbook.getDateSystem() }
: undefined;
const { mapFunc } = virtualizeDiscreteRanges([range]);
const cellValueMatrix = new ObjectMatrix<ICellData>();
matrix.forValue((rowIndex, columnIndex, value) => {
const { row, col } = mapFunc(rowIndex, columnIndex);
const newStyle = { ...(value.s ?? {}) as IStyleData };
if (newStyle.bd && Object.keys(newStyle.bd).length === 0) {
newStyle.bd = {
t: null,
r: null,
b: null,
l: null,
};
}
let cellValue: ICellData = {
p: null,
v: value.v,
t: value.t,
};
if (value.p?.body && isRichText(value.p.body)) {
cellValue = {
p: value.p,
v: value.v,
};
} else if (!pasteFrom && value.v && !newStyle.n?.pattern) {
/**
* The `pasteFrom` is null, means the data is pasted from outside.
* If the value's style has no number format, handle it as follows:
* 1. The paste to cell has a number format, google sheets will apply the number format, but excel does not.
* Here we only handle the text format, other number format are left unhandled now.
* 2. If the above case does not apply, parse the value to check whether it has a number format.
*/
const currentCellStyle = worksheet?.getCellStyle(row, col);
const numfmtResult = getExternalPasteNumberFormatResult(value.v, currentCellStyle, parseOptions);
if (numfmtResult.n) {
newStyle.n = numfmtResult.n;
}
if (numfmtResult.t !== undefined) {
cellValue.t = numfmtResult.t;
}
if (numfmtResult.v !== undefined) {
cellValue.v = numfmtResult.v;
}
}
cellValue.s = Object.keys(newStyle).length > 0 ? newStyle : null;
cellValueMatrix.setValue(row, col, cellValue);
});
const redoParams: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: cloneCellDataMatrix(cellValueMatrix.getMatrix()),
isOverrideStyle: true,
};
const undoParams: ISetRangeValuesMutationParams = SetRangeValuesUndoMutationFactory(
accessor,
redoParams
);
return {
redo: {
id: SetRangeValuesMutation.id,
params: redoParams,
},
undo: {
id: SetRangeValuesMutation.id,
params: undoParams,
},
};
}
export function getSetCellValueMutations(
pasteTo: ISheetDiscreteRangeLocation,
pasteFrom: Nullable<ISheetDiscreteRangeLocation>,
matrix: ObjectMatrix<ICellDataWithSpanInfo>,
accessor: IAccessor
): {
redo: IMutationInfo;
undo: IMutationInfo;
} {
const { unitId, subUnitId, range } = pasteTo;
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), { unitId, subUnitId });
const worksheet = target?.worksheet;
const parseOptions = target
? { locale: getNumfmtLocaleTag(target.workbook.getSnapshot().locale), dateSystem: target.workbook.getDateSystem() }
: undefined;
const { mapFunc } = virtualizeDiscreteRanges([range]);
const cellValueMatrix = new ObjectMatrix<ICellData>();
matrix.forValue((rowIndex, columnIndex, value) => {
const { row, col } = mapFunc(rowIndex, columnIndex);
let cellValue: ICellData = {
p: null,
v: value.v,
t: value.t,
};
if (value.p?.body && isRichText(value.p.body)) {
cellValue = {
p: value.p,
v: value.v,
};
} else if (value.v && !pasteFrom && !(value.s as IStyleData)?.n?.pattern) {
/**
* The `pasteFrom` is null, means the data is pasted from outside.
* If the value's style has no number format, handle it as follows:
* 1. The paste to cell has a number format, google sheets will apply the number format, but excel does not.
* Here we only handle the text format, other number format are left unhandled now.
* 2. If the above case does not apply, parse the value to check whether it has a number format.
*/
const currentCellStyle = worksheet?.getCellStyle(row, col);
const numfmtResult = getExternalPasteNumberFormatResult(value.v, currentCellStyle, parseOptions);
if (numfmtResult.t !== undefined) {
cellValue.t = numfmtResult.t;
}
if (numfmtResult.v !== undefined) {
cellValue.v = numfmtResult.v;
}
}
cellValueMatrix.setValue(row, col, cellValue);
});
const redoParams: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: cloneCellDataMatrix(cellValueMatrix.getMatrix()),
};
const undoParams: ISetRangeValuesMutationParams = SetRangeValuesUndoMutationFactory(
accessor,
redoParams
);
return {
redo: {
id: SetRangeValuesMutation.id,
params: redoParams,
},
undo: {
id: SetRangeValuesMutation.id,
params: undoParams,
},
};
}
export function getSetCellStyleMutations(
pasteTo: ISheetDiscreteRangeLocation,
pasteFrom: Nullable<ISheetDiscreteRangeLocation>,
matrix: ObjectMatrix<ICellDataWithSpanInfo>,
accessor: IAccessor
): {
redo: IMutationInfo;
undo: IMutationInfo;
} {
const { unitId, subUnitId, range } = pasteTo;
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), { unitId, subUnitId });
const worksheet = target?.worksheet;
const parseOptions = target
? { locale: getNumfmtLocaleTag(target.workbook.getSnapshot().locale), dateSystem: target.workbook.getDateSystem() }
: undefined;
const { mapFunc } = virtualizeDiscreteRanges([range]);
const cellValueMatrix = new ObjectMatrix<ICellData>();
matrix.forValue((rowIndex, columnIndex, value) => {
const { row, col } = mapFunc(rowIndex, columnIndex);
const newStyle = { ...(value.s ?? {}) as IStyleData };
if (newStyle.bd && Object.keys(newStyle.bd).length === 0) {
newStyle.bd = {
t: null,
r: null,
b: null,
l: null,
};
}
if (!pasteFrom && value.v && !newStyle.n?.pattern) {
/**
* The `pasteFrom` is null, means the data is pasted from outside.
* If the value's style has no number format, handle it as follows:
* 1. The paste to cell has a number format, google sheets will apply the number format, but excel does not.
* Here we only handle the text format, other number format are left unhandled now.
* 2. If the above case does not apply, parse the value to check whether it has a number format.
*/
const currentCellStyle = worksheet?.getCellStyle(row, col);
const numfmtResult = getExternalPasteNumberFormatResult(value.v, currentCellStyle, parseOptions);
if (numfmtResult.n) {
newStyle.n = numfmtResult.n;
}
}
cellValueMatrix.setValue(row, col, {
s: Object.keys(newStyle).length > 0 ? newStyle : null,
});
});
const redoParams: ISetRangeValuesMutationParams = {
unitId,
subUnitId,
cellValue: cloneCellDataMatrix(cellValueMatrix.getMatrix()),
isOverrideStyle: true,
};
const undoParams: ISetRangeValuesMutationParams = SetRangeValuesUndoMutationFactory(
accessor,
redoParams
);
return {
redo: {
id: SetRangeValuesMutation.id,
params: redoParams,
},
undo: {
id: SetRangeValuesMutation.id,
params: undoParams,
},
};
}
export function getClearAndSetMergeMutations(
pasteTo: ISheetDiscreteRangeLocation,
matrix: ObjectMatrix<ICellDataWithSpanInfo>,
accessor: IAccessor
) {
const redoMutationsInfo: IMutationInfo[] = [];
const undoMutationsInfo: IMutationInfo[] = [];
const { unitId, subUnitId, range } = pasteTo;
const { startColumn, startRow, endColumn, endRow } = discreteRangeToRange(range);
const mergeRangeData: IRange[] = [];
matrix.forValue((row, col, value) => {
if (value.rowSpan && value.rowSpan > 1) {
const colSpan = value.colSpan || 1;
const mergeRange = {
startRow: startRow + row,
endRow: startRow + row + value.rowSpan - 1,
startColumn: startColumn + col,
endColumn: startColumn + col + colSpan - 1,
};
mergeRangeData.push(mergeRange);
} else if (value.colSpan && value.colSpan > 1) {
const rowSpan = value.rowSpan || 1;
const mergeRange = {
startRow: startRow + row,
endRow: startRow + row + rowSpan - 1,
startColumn: startColumn + col,
endColumn: startColumn + col + value.colSpan - 1,
};
mergeRangeData.push(mergeRange);
}
});
// clear merge
// remove current range's all merged Cell
// get all merged cells
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), { unitId, subUnitId });
if (target && target.worksheet) {
const mergeData = target.worksheet.getMergeData();
const mergedCellsInRange = mergeData.filter((rect) =>
Rectangle.intersects({ startRow, startColumn, endRow, endColumn }, rect)
);
if (mergedCellsInRange.length > 0) {
const removeMergeMutationParams: IRemoveWorksheetMergeMutationParams = {
unitId,
subUnitId,
ranges: mergedCellsInRange,
};
redoMutationsInfo.push({
id: RemoveWorksheetMergeMutation.id,
params: removeMergeMutationParams,
});
const undoRemoveMergeMutationParams: IAddWorksheetMergeMutationParams = RemoveMergeUndoMutationFactory(
accessor,
removeMergeMutationParams
);
undoMutationsInfo.push({
id: AddWorksheetMergeMutation.id,
params: undoRemoveMergeMutationParams,
});
}
}
if (mergeRangeData.length > 0) {
// set merged cell info
const addMergeMutationParams: IAddWorksheetMergeMutationParams = {
unitId,
subUnitId,
ranges: mergeRangeData,
};
redoMutationsInfo.push({
id: AddWorksheetMergeMutation.id,
params: addMergeMutationParams,
});
// undo
const undoAddMergeMutation: IRemoveWorksheetMergeMutationParams = AddMergeUndoMutationFactory(
accessor,
addMergeMutationParams
);
undoMutationsInfo.unshift({
id: RemoveWorksheetMergeMutation.id,
params: undoAddMergeMutation,
});
}
return { redos: redoMutationsInfo, undos: undoMutationsInfo };
}
export function generateBody(text: string): IDocumentBody {
if (!text.includes('\r') && Tools.isLegalUrl(text)) {
const id = generateRandomId();
const urlText = `${text}`;
const range: ICustomRange = {
startIndex: 0,
endIndex: urlText.length - 1,
rangeId: id,
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: text,
},
};
return {
dataStream: `${urlText}\r\n`,
paragraphs: [{
startIndex: urlText.length,
paragraphId: createParagraphId(new Set()),
}],
customRanges: [range],
};
}
// Convert all \n to \r, because we use \r to indicate paragraph break.
let dataStream = text.replace(/(\r\n|\n)/g, '\r');
if (!dataStream.endsWith('\r\n')) {
dataStream += '\r\n';
}
const paragraphs: IParagraph[] = [];
const existingParagraphIds = new Set<string>();
for (let i = 0; i < dataStream.length; i++) {
if (dataStream[i] === '\r') {
paragraphs.push({ startIndex: i, paragraphId: createParagraphId(existingParagraphIds) });
}
}
return {
dataStream,
paragraphs,
sectionBreaks: [{ sectionId: createSectionId(new Set()), startIndex: dataStream.indexOf('\n') }],
};
}

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 type { IUniverSheetsUIConfig } from '../config/config';
import { Disposable, IConfigService, Inject, Injector } from '@univerjs/core';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import {
UNIVER_SHEET_PERMISSION_DIALOG,
UNIVER_SHEET_PERMISSION_PANEL,
UNIVER_SHEET_PERMISSION_USER_DIALOG,
UNIVER_SHEET_PERMISSION_USER_PART,
} from '../consts/permission';
import { BorderPanel } from '../views/border-panel/BorderPanel';
import { BORDER_PANEL_COMPONENT } from '../views/border-panel/interface';
import { CELL_ALERT_KEY } from '../views/cell-alert';
import { CellAlert } from '../views/cell-alert/CellAlertPopup';
import { CELL_POPUP_COMPONENT_KEY } from '../views/cell-popup/config';
import { CellPopup } from '../views/CellPopup';
import { DEFINED_NAME_CONTAINER } from '../views/defined-name/component-name';
import { DefinedNameContainer } from '../views/defined-name/DefinedNameContainer';
import { dropdownMap } from '../views/dropdown';
import { MENU_ITEM_FROZEN_COMPONENT, MenuItemFrozen } from '../views/menu-item-frozen/index';
import { MENU_ITEM_INPUT_COMPONENT, MenuItemInput } from '../views/menu-item-input/index';
import { SheetPermissionDialog, SheetPermissionPanel, SheetPermissionUserDialog } from '../views/permission';
import { AlertDialog } from '../views/permission/AlertDialog';
import { UNIVER_SHEET_PERMISSION_ALERT_DIALOG } from '../views/permission/error-msg-dialog/interface';
import { SHEET_ZOOM_INPUT_COMPONENT, SheetZoomInput } from '../views/sheet-slider/SheetZoomInput';
import { sheetsUIIcons } from './sheets-ui-icons';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._registerComponents();
this._registerParts();
this._registerIcons();
}
private _registerComponents(): void {
this.disposeWithMe(
this._componentManager.register(CELL_ALERT_KEY, CellAlert)
);
([
[UNIVER_SHEET_PERMISSION_PANEL, SheetPermissionPanel],
[UNIVER_SHEET_PERMISSION_USER_DIALOG, SheetPermissionUserDialog],
[UNIVER_SHEET_PERMISSION_DIALOG, SheetPermissionDialog],
[UNIVER_SHEET_PERMISSION_ALERT_DIALOG, AlertDialog],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(this._componentManager.register(
key,
comp
));
});
const configService = this._injector.get(IConfigService);
const config = configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY);
if (config?.protectedRangeUserSelector) {
const { component, framework } = config.protectedRangeUserSelector;
this.disposeWithMe(
this._componentManager.register(UNIVER_SHEET_PERMISSION_USER_PART, component, {
framework,
})
);
}
}
private _registerParts(): void {
const componentManager = this._componentManager;
// init custom components
this.disposeWithMe(componentManager.register(MENU_ITEM_INPUT_COMPONENT, MenuItemInput));
this.disposeWithMe(componentManager.register(MENU_ITEM_FROZEN_COMPONENT, MenuItemFrozen));
this.disposeWithMe(componentManager.register(SHEET_ZOOM_INPUT_COMPONENT, SheetZoomInput));
this.disposeWithMe(componentManager.register(BORDER_PANEL_COMPONENT, BorderPanel));
this.disposeWithMe(componentManager.register(DEFINED_NAME_CONTAINER, DefinedNameContainer));
this.disposeWithMe(componentManager.register(CELL_POPUP_COMPONENT_KEY, CellPopup));
Object.values(dropdownMap).forEach((component) => {
this.disposeWithMe(componentManager.register(component.componentKey, component));
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register(sheetsUIIcons));
}
}

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 { Workbook } from '@univerjs/core';
import type { ISelectionWithStyle, ISetWorksheetActivateCommandParams } from '@univerjs/sheets';
import { Disposable, ICommandService, Inject, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { deserializeRangeWithSheet, IDefinedNamesService, isReferenceStrings, operatorToken } from '@univerjs/engine-formula';
import { getPrimaryForRange, ScrollToCellOperation, SetWorksheetActivateCommand, SheetsSelectionsService } from '@univerjs/sheets';
import { filter, merge } from 'rxjs';
import { genNormalSelectionStyle } from '../../services/selection/const';
export class SheetsDefinedNameController extends Disposable {
constructor(
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@Inject(ThemeService) private readonly _themeService: ThemeService,
@IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService,
@ICommandService private readonly _cmdSrv: ICommandService,
@IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService
) {
super();
this._init();
}
private _init(): void {
this.disposeWithMe(merge(
this._selectionManagerService.selectionMoveStart$,
this._selectionManagerService.selectionMoving$,
this._selectionManagerService.selectionMoveEnd$,
this._selectionManagerService.selectionSet$
)
.pipe(filter((params) => !!params))
.subscribe((params) => {
this._syncDefinedNameRange(params as ISelectionWithStyle[]);
}));
this.disposeWithMe(this._definedNamesService.focusRange$.subscribe(async (item) => {
if (item == null) return;
const { unitId } = item;
let { formulaOrRefString } = item;
if (formulaOrRefString.substring(0, 1) === operatorToken.EQUALS) {
formulaOrRefString = formulaOrRefString.substring(1);
}
const result = isReferenceStrings(formulaOrRefString);
if (!result) {
return;
}
const workbook = this._instanceSrv.getUnit<Workbook>(unitId)!;
const selections = await this._getSelections(workbook, unitId, formulaOrRefString);
this._selectionManagerService.setSelections(selections);
this._cmdSrv.executeCommand(ScrollToCellOperation.id, {
unitId,
range: selections[0].range,
});
}));
}
private _syncDefinedNameRange(params: ISelectionWithStyle[]) {
if (params.length === 0) {
return;
}
const lastSelection = params[params.length - 1];
const workbook = this._instanceSrv.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getActiveSheet();
if (!worksheet) {
return;
}
this._definedNamesService.setCurrentRange({
range: lastSelection.range,
unitId: workbook!.getUnitId(),
sheetId: worksheet.getSheetId(),
});
}
private async _getSelections(workbook: Workbook, unitId: string, formulaOrRefString: string) {
const valueArray = formulaOrRefString.split(',');
let worksheet = workbook.getActiveSheet();
if (!worksheet) {
return [];
}
const selections = [];
for (let i = 0; i < valueArray.length; i++) {
const refString = valueArray[i].trim();
const unitRange = deserializeRangeWithSheet(refString.trim());
// WTF@DR-Univer: side effects in get methods
if (i === 0) {
const worksheetCache = workbook.getSheetBySheetName(unitRange.sheetName);
if (worksheetCache && worksheet.getSheetId() !== worksheetCache.getSheetId()) {
worksheet = worksheetCache;
await this._cmdSrv.executeCommand<ISetWorksheetActivateCommandParams>(SetWorksheetActivateCommand.id, {
subUnitId: worksheet.getSheetId(),
unitId,
});
}
}
if (worksheet.getName() !== unitRange.sheetName) {
continue;
}
let primary = null;
if (i === valueArray.length - 1) {
const range = unitRange.range;
const { startRow, startColumn, endRow, endColumn } = range;
primary = getPrimaryForRange({
startRow,
startColumn,
endRow,
endColumn,
}, worksheet);
}
selections.push({
range: unitRange.range,
style: genNormalSelectionStyle(this._themeService),
primary,
});
}
return selections;
}
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { ISheetSkeletonManagerParam } from '@univerjs/sheets';
import { Disposable, DisposableCollection, Inject } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DragManagerService } from '../services/drag-manager.service';
import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service';
export class DragRenderController extends Disposable implements IRenderModule, IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@IRenderManagerService private _renderManagerService: IRenderManagerService,
@Inject(DragManagerService) private _dragManagerService: DragManagerService,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService
) {
super();
this._initDragEvent();
}
private _initDragEvent() {
const disposeSet = new DisposableCollection();
const handleSkeletonChange = (skeletonParam: Nullable<ISheetSkeletonManagerParam>) => {
disposeSet.dispose();
if (!skeletonParam) {
return;
}
const { scene } = this._context;
const dragOverSub = scene.onDragOver$.subscribeEvent((evt) => {
this._dragManagerService.onDragOver(evt);
});
const dropSub = scene.onDrop$.subscribeEvent((evt) => {
this._dragManagerService.onDrop(evt);
});
disposeSet.add({
dispose() {
dragOverSub.unsubscribe();
dropSub.unsubscribe();
},
});
};
handleSkeletonChange(this._sheetSkeletonManagerService.getCurrentParam());
this.disposeWithMe(this._sheetSkeletonManagerService.currentSkeleton$.subscribe((skeletonParam) => {
handleSkeletonChange(skeletonParam);
}));
}
}

View File

@@ -0,0 +1,81 @@
/**
* 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, IWorkbookData, Workbook } from '@univerjs/core';
import { ILogService, Inject, Injector, IUniverInstanceService, LocaleType, LogLevel, Plugin, Univer, UniverInstanceType } from '@univerjs/core';
import { SheetInterceptorService, SheetsSelectionsService } from '@univerjs/sheets';
function getTestWorkbookDataDemo(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: Workbook;
}
export function createTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
injector.add([SheetsSelectionsService]);
injector.add([SheetInterceptorService]);
dependencies?.forEach((d) => injector.add(d));
}
}
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData ?? getTestWorkbookDataDemo());
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // change this to `LogLevel.VERBOSE` to debug tests via logs
return {
univer,
get: injector.get.bind(injector),
sheet,
};
}

View File

@@ -0,0 +1,365 @@
/**
* 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, IDocumentBody, IDocumentData, IDrawings, Nullable } from '@univerjs/core';
import {
BooleanNumber,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentDataModel,
FOCUSING_FX_BAR_EDITOR,
ICommandService,
IContextService,
Injector,
IUniverInstanceService,
ThemeService,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
MoveRangeMutation,
RangeProtectionRuleModel,
SetRangeValuesMutation,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import { BehaviorSubject, EMPTY } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import { IFormulaEditorManagerService } from '../../../services/editor/formula-editor-manager.service';
import { EditorDataSyncController } from '../data-sync.controller';
import { FormulaEditorController } from '../formula-editor.controller';
interface ITestableEditorDataSyncController {
_checkAndSetRenderStyleConfig: (documentDataModel: Pick<DocumentDataModel, 'getSnapshot'>) => void;
_syncContentAndRender: (
unitId: string,
body: IDocumentBody,
drawings: Nullable<IDrawings>,
drawingsOrder: Nullable<string[]>
) => void;
}
class TestCommandService {
private readonly _listeners = new Set<(command: ICommandInfo) => void>();
onCommandExecuted(listener: (command: ICommandInfo) => void) {
this._listeners.add(listener);
return {
dispose: () => this._listeners.delete(listener),
};
}
emit(command: ICommandInfo) {
this._listeners.forEach((listener) => listener(command));
}
}
describe('EditorDataSyncController', () => {
const controllers: EditorDataSyncController[] = [];
afterEach(() => {
controllers.splice(0).forEach((controller) => controller.dispose());
});
function createController(options: {
isFocusFxBar?: boolean;
themeTextColor?: string;
formulaBarPosition?: { width: number; height: number } | null;
documentDataModel?: DocumentDataModel;
} = {}) {
const commandService = new TestCommandService();
const editorBridgeService = {
currentEditCellState$: new BehaviorSubject(null),
getEditLocation: vi.fn(() => ({
row: 1,
column: 2,
})),
refreshEditCellState: vi.fn(),
isForceKeepVisible: vi.fn(() => false),
};
const contextService = {
getContextValue: vi.fn((key: string) => {
if (key === FOCUSING_FX_BAR_EDITOR) return options.isFocusFxBar ?? false;
return false;
}),
subscribeContextValue$: vi.fn(() => EMPTY),
};
const currentTheme$ = new BehaviorSubject({});
const skeleton = { calculate: vi.fn() };
const documentViewModel = { reset: vi.fn() };
const currentRender = {
mainComponent: { makeDirty: vi.fn() },
with: vi.fn(() => ({
getSkeleton: () => skeleton,
getViewModel: () => documentViewModel,
})),
};
const injector = new Injector();
injector.add([IUniverInstanceService, {
useValue: { getUnit: () => options.documentDataModel } as never,
}]);
injector.add([IRenderManagerService, {
useValue: { getRenderUnitById: () => options.documentDataModel ? currentRender : undefined } as never,
}]);
injector.add([IEditorBridgeService, { useValue: editorBridgeService as never }]);
injector.add([ICommandService, { useValue: commandService as never }]);
injector.add([RangeProtectionRuleModel, { useValue: { getRangeRuleInitState: () => true } as never }]);
injector.add([WorksheetProtectionRuleModel, { useValue: { getSheetRuleInitState: () => true } as never }]);
injector.add([FormulaEditorController, { useValue: { autoScroll: vi.fn() } as never }]);
injector.add([IFormulaEditorManagerService, {
useValue: { getPosition: () => options.formulaBarPosition ?? null } as never,
}]);
injector.add([IContextService, { useValue: contextService as never }]);
injector.add([ThemeService, {
useValue: {
currentTheme$,
getColorFromTheme: vi.fn((token: string) => token === 'gray.900'
? options.themeTextColor ?? '#1b1c1f'
: '#000000'),
} as never,
}]);
injector.add([EditorDataSyncController]);
const controller = injector.get(EditorDataSyncController);
controllers.push(controller);
return {
commandService,
controller,
editorBridgeService,
};
}
function checkAndSetRenderStyleConfig(controller: EditorDataSyncController, snapshot: IDocumentData) {
(controller as unknown as ITestableEditorDataSyncController)._checkAndSetRenderStyleConfig({
getSnapshot: vi.fn(() => snapshot),
});
}
function syncContentAndRender(
controller: EditorDataSyncController,
body: IDocumentBody
) {
const testableController = controller as unknown as ITestableEditorDataSyncController;
testableController._syncContentAndRender(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, body, null, null);
}
it('ignores a zero-width formula bar position', () => {
const { controller } = createController({ formulaBarPosition: { width: 0, height: 28 } });
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
documentStyle: {},
body: {
dataStream: 'text\r\n',
},
};
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(formulaBarSnapshot.documentStyle?.pageSize?.width).toBe(Number.POSITIVE_INFINITY);
});
it('applies a measured formula bar width', () => {
const { controller } = createController({ formulaBarPosition: { width: 320, height: 28 } });
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
documentStyle: {},
body: {
dataStream: 'text\r\n',
},
};
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(formulaBarSnapshot.documentStyle?.pageSize?.width).toBe(320);
});
it('keeps the previous formula bar width when the latest position has zero width', () => {
const formulaBarPosition = { width: 320, height: 28 };
const { controller } = createController({ formulaBarPosition });
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
documentStyle: {},
body: {
dataStream: 'text\r\n',
},
};
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
formulaBarPosition.width = 0;
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(formulaBarSnapshot.documentStyle.pageSize?.width).toBe(320);
});
it('notifies formula bar content observers when the selected cell changes', () => {
const documentDataModel = new DocumentDataModel({
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
documentStyle: {},
body: {
dataStream: '=SUM(A1)\r\n',
},
});
const { controller } = createController({ documentDataModel });
let observedDataStream = '';
const subscription = documentDataModel.change$.subscribe(() => {
observedDataStream = documentDataModel.getBody()?.dataStream ?? '';
});
syncContentAndRender(controller, { dataStream: '\r\n' });
expect(documentDataModel.getBody()?.dataStream).toBe('\r\n');
expect(observedDataStream).toBe('\r\n');
subscription.unsubscribe();
documentDataModel.dispose();
});
it('refreshes the current edit cell when set-values updates the edited cell', () => {
const { commandService, editorBridgeService } = createController();
commandService.emit({
id: SetRangeValuesMutation.id,
params: {
cellValue: {
1: {
2: { v: 'updated' },
},
},
},
} as ICommandInfo);
expect(editorBridgeService.refreshEditCellState).toHaveBeenCalledTimes(1);
});
it('refreshes the current edit cell when set-values clears the edited cell', () => {
const { commandService, editorBridgeService } = createController();
commandService.emit({
id: SetRangeValuesMutation.id,
params: {
cellValue: {
1: {
2: null,
},
},
},
} as ICommandInfo);
expect(editorBridgeService.refreshEditCellState).toHaveBeenCalledTimes(1);
});
it('refreshes the current edit cell when move-range changes the edited cell', () => {
const { commandService, editorBridgeService } = createController();
commandService.emit({
id: MoveRangeMutation.id,
params: {
from: {
subUnitId: 'sheet1',
value: {
1: {
2: null,
},
},
},
to: {
subUnitId: 'sheet1',
value: {},
},
},
} as ICommandInfo);
expect(editorBridgeService.refreshEditCellState).toHaveBeenCalledTimes(1);
});
it('renders formula reference styles in the cell editor when the cell editor is focused', () => {
const { controller } = createController();
const cellEditorSnapshot: IDocumentData = {
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: {
dataStream: '=G10+G12+G13\r\n',
textRuns: [
{ st: 1, ed: 4, ts: { cl: { rgb: '#8f7cf6' }, fs: 11 } },
],
},
documentStyle: {},
};
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: {
dataStream: '=G10+G12+G13\r\n',
textRuns: [
{ st: 1, ed: 4, ts: { cl: { rgb: '#8f7cf6' }, fs: 11 } },
],
},
documentStyle: {},
};
checkAndSetRenderStyleConfig(controller, cellEditorSnapshot);
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(cellEditorSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.TRUE);
expect(formulaBarSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.FALSE);
});
it('uses the resolved theme color for the formula bar default text', () => {
const { controller } = createController({ themeTextColor: '#f7f9fc' });
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: {
dataStream: 'A Schedule of Items\r\n',
},
documentStyle: {},
};
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(formulaBarSnapshot.documentStyle.textStyle?.cl?.rgb).toBe('#f7f9fc');
});
it('renders formula reference styles in the formula bar when the formula bar is focused', () => {
const { controller } = createController({ isFocusFxBar: true });
const cellEditorSnapshot: IDocumentData = {
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: {
dataStream: '=G10+G12+G13\r\n',
textRuns: [
{ st: 1, ed: 4, ts: { cl: { rgb: '#8f7cf6' }, fs: 11 } },
],
},
documentStyle: {},
};
const formulaBarSnapshot: IDocumentData = {
id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: {
dataStream: '=G10+G12+G13\r\n',
textRuns: [
{ st: 1, ed: 4, ts: { cl: { rgb: '#8f7cf6' }, fs: 11 } },
],
},
documentStyle: {},
};
checkAndSetRenderStyleConfig(controller, cellEditorSnapshot);
checkAndSetRenderStyleConfig(controller, formulaBarSnapshot);
expect(cellEditorSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.FALSE);
expect(formulaBarSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.TRUE);
});
});

View File

@@ -0,0 +1,599 @@
/**
* 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 { IDocumentBody } from '@univerjs/core';
import {
Direction,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
EDITOR_ACTIVATED,
FOCUSING_EDITOR_INPUT_FORMULA,
FOCUSING_FX_BAR_EDITOR,
LocaleType,
Styles,
UniverInstanceType,
} from '@univerjs/core';
import { InsertTextCommand } from '@univerjs/docs';
import { MoveCursorOperation, MoveSelectionOperation, SetDocInputStyleCommand, VIEWPORT_KEY } from '@univerjs/docs-ui';
import { LexerTreeBuilder } from '@univerjs/engine-formula';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { SetRangeValuesCommand } from '@univerjs/sheets';
import { KeyCode } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import {
MoveSelectionCommand,
MoveSelectionEnterAndTabCommand,
} from '../../../commands/commands/set-selection.command';
import { EditingRenderController, emptyBody } from '../editing.render-controller';
describe('emptyBody', () => {
it('keeps an empty text-run collection initialized', () => {
const body: IDocumentBody = { dataStream: 'value\r\n', textRuns: [] };
emptyBody(body);
expect(body.textRuns).toEqual([]);
});
it('keeps one inherited style when resetting the editor', () => {
const body: IDocumentBody = {
dataStream: 'value\r\n',
textRuns: [{ st: 0, ed: 5, ts: { fs: 11 } }],
};
emptyBody(body);
expect(body.textRuns).toEqual([{ st: 0, ed: 1, ts: { fs: 11 } }]);
});
it('removes an inherited style when style removal is requested', () => {
const body: IDocumentBody = {
dataStream: 'value\r\n',
textRuns: [{ st: 0, ed: 5, ts: { fs: 11 } }],
};
emptyBody(body, true);
expect(body.textRuns).toBeUndefined();
});
});
function createController(initialDataStream = 'new value\r\n', isPercentFormat = false, isInArrayFormulaRange = false) {
const worksheet = {
getSheetId: vi.fn(() => 'sheet-1'),
getCellRaw: vi.fn(() => ({ v: 'old' })),
getComposedCellStyleWithoutSelf: vi.fn(() => ({})),
};
const styles = new Styles();
const workbook = {
getUnitId: vi.fn(() => 'unit-1'),
getActiveSheet: vi.fn(() => worksheet),
getSheetBySheetId: vi.fn(() => worksheet),
getStyles: vi.fn(() => styles),
};
let normalSnapshot = {
body: { dataStream: initialDataStream, paragraphs: [{ startIndex: 9, paragraphId: 'normal-para' }] },
documentStyle: {},
};
let formulaSnapshot = {
body: { dataStream: initialDataStream, paragraphs: [{ startIndex: 9, paragraphId: 'formula-para' }] },
documentStyle: {},
};
const docModel = {
getUnitId: vi.fn(() => DOCS_NORMAL_EDITOR_UNIT_ID_KEY),
getBody: vi.fn(() => normalSnapshot.body),
getSnapshot: vi.fn(() => normalSnapshot),
reset: vi.fn((snapshot) => {
normalSnapshot = snapshot;
}),
};
const formulaDocModel = {
getUnitId: vi.fn(() => DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY),
getSnapshot: vi.fn(() => formulaSnapshot),
reset: vi.fn((snapshot) => {
formulaSnapshot = snapshot;
}),
};
const documentModel = {
getBody: vi.fn((): IDocumentBody => ({ dataStream: 'new value\r\n' })),
getSnapshot: vi.fn(() => ({
body: { dataStream: 'new value\r\n' },
documentStyle: {},
})),
};
const controller = Object.create(EditingRenderController.prototype) as any;
const formulaBarEditor = {
setSelectionRanges: vi.fn(),
blur: vi.fn(),
};
controller._editingUnit = 'unit-1';
controller._lexerTreeBuilder = new LexerTreeBuilder();
controller._localService = { getCurrentLocale: vi.fn(() => LocaleType.EN_US) };
controller._functionService = { getDescriptions: vi.fn(() => ({})) };
controller._undoRedoService = {
rollback: vi.fn(),
clearUndoRedo: vi.fn(),
};
controller._docStateChangeManagerService = {
clearHistory: vi.fn(),
};
controller._contextService = {
setContextValue: vi.fn(),
getContextValue: vi.fn(() => false),
};
controller._cellEditorManagerService = { setState: vi.fn() };
controller._sheetCellEditorResizeService = {
fitTextSize: vi.fn((callback?: () => void) => callback?.()),
};
controller._editorService = {
isSheetEditor: vi.fn(() => true),
getEditor: vi.fn((editorId: string) => editorId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY ? formulaBarEditor : null),
};
controller._editorBridgeService = {
getEditCellState: vi.fn(() => ({
unitId: 'unit-1',
sheetId: 'sheet-1',
row: 2,
column: 3,
documentLayoutObject: { documentModel },
isInArrayFormulaRange,
isPercentFormat,
})),
getEditLocation: vi.fn(() => ({
unitId: 'unit-1',
sheetId: 'sheet-1',
row: 2,
column: 3,
documentLayoutObject: { documentModel },
isInArrayFormulaRange,
isPercentFormat,
})),
getCurrentEditorId: vi.fn(() => DOCS_NORMAL_EDITOR_UNIT_ID_KEY),
isVisible: vi.fn(() => ({ visible: true, eventType: DeviceInputEventType.Keyboard, unitId: 'unit-1' })),
getEditorDirty: vi.fn(() => true),
isForceKeepVisible: vi.fn(() => false),
disableForceKeepVisible: vi.fn(),
refreshEditCellState: vi.fn(),
refreshEditCellPosition: vi.fn(),
changeEditorDirty: vi.fn(),
};
controller._sheetInterceptorService = {
onWriteCell: vi.fn((_workbook, _worksheet, _row, _column, cellData) => cellData),
onValidateCell: vi.fn(() => true),
};
controller._commandService = {
syncExecuteCommand: vi.fn(() => true),
executeCommand: vi.fn(),
};
controller._univerInstanceService = {
getUnit: vi.fn((unitId: string) => {
if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return docModel;
}
if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
return formulaDocModel;
}
return workbook;
}),
getCurrentUnitOfType: vi.fn(() => workbook),
setCurrentUnitForType: vi.fn(),
};
controller._textSelectionManagerService = {
replaceDocRanges: vi.fn(),
refreshSelection: vi.fn(),
};
const workbookSelections = {
getCurrentLastSelection: vi.fn(() => ({ range: { startRow: 1, startColumn: 1, endRow: 4, endColumn: 4 } })),
getSelectionsOfWorksheet: vi.fn(() => [{ range: { startRow: 2, startColumn: 3, endRow: 2, endColumn: 3 } }]),
};
controller._selectionManagerService = {
getWorkbookSelections: vi.fn(() => workbookSelections),
};
controller._renderManagerService = {
getRenderUnitById: vi.fn(() => ({
scene: {
getViewport: vi.fn((key) => key === VIEWPORT_KEY.VIEW_MAIN
? { scrollToViewportPos: vi.fn() }
: null),
resetCursor: vi.fn(),
},
with: vi.fn(() => ({ resetInitialWidth: vi.fn() })),
})),
};
controller._getEditorObject = vi.fn(() => ({
document: {
makeDirty: vi.fn(),
},
scene: {
getViewport: vi.fn((key) => key === VIEWPORT_KEY.VIEW_MAIN
? { scrollToViewportPos: vi.fn() }
: null),
resetCursor: vi.fn(),
},
}));
controller._getEditorSkeleton = vi.fn(() => ({ calculate: vi.fn(), resetInitialWidth: vi.fn() }));
controller._getEditorViewModel = vi.fn(() => ({ reset: vi.fn() }));
return {
controller,
docModel,
formulaBarEditor,
documentModel,
getFormulaSnapshot: () => formulaSnapshot,
getNormalSnapshot: () => normalSnapshot,
workbook,
worksheet,
};
}
describe('EditingRenderController business methods', () => {
it('bypasses parsing, interceptors, and commands when the editor is clean', async () => {
const { controller, worksheet } = createController();
controller._editorBridgeService.getEditorDirty.mockReturnValue(false);
const result = await controller._submitEdit({
body: { dataStream: 'canonical display value\r\n' },
documentStyle: {},
});
expect(result).toBe(true);
expect(worksheet.getCellRaw).not.toHaveBeenCalled();
expect(controller._sheetInterceptorService.onWriteCell).not.toHaveBeenCalled();
expect(controller._commandService.syncExecuteCommand).not.toHaveBeenCalled();
});
it('reuses an empty cell style for the next editor input', () => {
const { controller, documentModel } = createController();
documentModel.getBody.mockReturnValue({
dataStream: '\r\n',
textRuns: [{ st: 0, ed: 0, ts: { fs: 20, cl: { rgb: '#f05252' } } }],
});
controller._cacheEmptyCellTextStyle();
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDocInputStyleCommand.id, {
style: {
fs: 20,
cl: { rgb: '#f05252' },
},
});
});
it('submits edited cell content and rolls back when validation rejects the value', async () => {
const { controller } = createController();
controller._sheetInterceptorService.onValidateCell.mockResolvedValue(false);
const result = await controller._submitEdit({
body: { dataStream: 'new value\r\n' },
documentStyle: {},
});
expect(result).toBe(false);
expect(controller._sheetInterceptorService.onWriteCell).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 2, 3, {
v: 'new value',
f: null,
si: null,
p: null,
});
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(SetRangeValuesCommand.id, expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { startRow: 2, startColumn: 3, endRow: 2, endColumn: 3 },
value: { v: 'new value', f: null, si: null, p: null },
}));
expect(controller._undoRedoService.rollback).toHaveBeenCalledWith(expect.any(String), 'unit-1');
});
it('captures rich-text cell data independently from the reusable editor snapshot', async () => {
const { controller } = createController();
const snapshot = {
body: {
dataStream: 'rich text\r\n',
textRuns: [{ st: 5, ed: 9, ts: { bl: 1 } }],
},
documentStyle: {},
};
await controller._submitEdit(snapshot);
const submitted = controller._commandService.syncExecuteCommand.mock.calls
.find(([id]: [string]) => id === SetRangeValuesCommand.id)?.[1];
snapshot.body.textRuns = [];
expect(submitted?.value.p).not.toBe(snapshot);
expect(submitted?.value.p?.body?.textRuns).toEqual([{ st: 5, ed: 9, ts: { bl: 1 } }]);
});
it('uses the whole current selection when committing an array edit', async () => {
const { controller } = createController();
await controller._submitEdit({
body: { dataStream: 'fill\r\n' },
documentStyle: {},
}, true);
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(SetRangeValuesCommand.id, expect.objectContaining({
range: { startRow: 1, startColumn: 1, endRow: 4, endColumn: 4 },
}));
expect(controller._undoRedoService.rollback).not.toHaveBeenCalled();
});
it('moves sheet selection after finishing edit and switches back to the edited workbook when needed', () => {
const { controller } = createController();
controller._univerInstanceService.getCurrentUnitOfType.mockReturnValue({ getUnitId: () => 'other-unit' });
controller._moveSelection(KeyCode.ENTER, 'unit-1', 'sheet-1');
controller._moveSelection(KeyCode.ARROW_LEFT, 'unit-1', 'sheet-1');
controller._moveSelection(undefined, 'unit-1', 'sheet-1');
expect(controller._univerInstanceService.setCurrentUnitForType).toHaveBeenCalledWith('unit-1');
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(MoveSelectionEnterAndTabCommand.id, {
keycode: KeyCode.ENTER,
direction: 2,
});
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(MoveSelectionCommand.id, {
direction: 3,
});
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
subUnitId: 'sheet-1',
}));
});
it.each([
['Enter', KeyCode.ENTER, Direction.UP],
['Tab', KeyCode.TAB, Direction.LEFT],
])('moves the sheet selection in reverse after finishing edit with Shift+%s', async (_name, keycode, direction) => {
const { controller } = createController();
controller._editorBridgeService.getEditorDirty.mockReturnValue(false);
await controller._handleEditorInvisible({
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId: 'unit-1',
keycode,
isShift: true,
});
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(MoveSelectionEnterAndTabCommand.id, {
keycode,
direction,
});
});
it('refreshes editor content when Esc cancels editing', async () => {
const { controller } = createController();
await controller._handleEditorInvisible({
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId: 'unit-1',
keycode: KeyCode.ESC,
});
expect(controller._editorBridgeService.refreshEditCellState).toHaveBeenCalledTimes(1);
});
it('moves the cursor inside the editor and resets editor state on exit', () => {
const { controller, formulaBarEditor } = createController();
controller._moveInEditor(KeyCode.ARROW_RIGHT, false);
controller._moveInEditor(KeyCode.ARROW_UP, true);
controller._exitInput({ visible: false });
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(MoveCursorOperation.id, { direction: 1 });
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(MoveSelectionOperation.id, { direction: 0 });
expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_EDITOR_INPUT_FORMULA, false);
expect(controller._contextService.setContextValue).toHaveBeenCalledWith(EDITOR_ACTIVATED, false);
expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, false);
expect(controller._cellEditorManagerService.setState).toHaveBeenCalledWith({ show: false });
expect(controller._undoRedoService.clearUndoRedo).toHaveBeenCalledWith(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
expect(controller._undoRedoService.clearUndoRedo).toHaveBeenCalledWith(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
expect(formulaBarEditor.setSelectionRanges).toHaveBeenCalledWith([], false);
expect(formulaBarEditor.blur).toHaveBeenCalled();
});
it('leaves initial keyboard input to the doc input pipeline when opening the cell editor', () => {
const { controller, getFormulaSnapshot, getNormalSnapshot } = createController();
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: 187,
initialValue: '=',
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
expect(getFormulaSnapshot().body.dataStream).toBe('\r\n');
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 0, endOffset: 0 }],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
expect(controller._editorBridgeService.changeEditorDirty).not.toHaveBeenCalled();
});
it.each(['3', '35', '0.35', '35%'])('preserves the percent suffix when input starts with a digit: %s', (initialValue) => {
const { controller, getFormulaSnapshot, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('25%\r\n');
expect(getFormulaSnapshot().body.dataStream).toBe('25%\r\n');
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 0, endOffset: 2, collapsed: false }],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
});
it.each(['$30', '=1', 'text', '+', '-', '.', ','])('replaces percent content when input does not start with a digit: %s', (initialValue) => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it.each([KeyCode.BACKSPACE, KeyCode.DELETE])('keeps full-clear behavior for keycode %s', (keycode) => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it('keeps full-clear behavior for array formula cells', () => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true, true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue: '3',
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it.each([
['F2', { eventType: DeviceInputEventType.Keyboard, keycode: KeyCode.F2 }],
['double-click', { eventType: DeviceInputEventType.Dblclick }],
])('places the caret before the percent suffix for %s editing', (_name, input) => {
const { controller } = createController('25%\r\n', true);
const selection = { startOffset: 2, endOffset: 2, collapsed: true };
controller._handleEditorVisible({
visible: true,
unitId: 'unit-1',
...input,
});
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenLastCalledWith(
[selection],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
});
it('overtypes a preserved percent suffix on subsequent percent input', () => {
const { controller } = createController('0.35%\r\n', true);
const beforeCommandListeners: Array<(command: { id: string; params: unknown }) => void> = [];
controller._commandService.beforeCommandExecuted = vi.fn((listener) => {
beforeCommandListeners.push(listener);
return { dispose: vi.fn() };
});
controller._commandService.onCommandExecuted = vi.fn(() => ({ dispose: vi.fn() }));
controller._commandExecutedListener({ add: vi.fn() });
const params = {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: { dataStream: '%' },
range: { startOffset: 4, endOffset: 4, collapsed: true },
};
expect(beforeCommandListeners).toHaveLength(1);
beforeCommandListeners[0]({ id: InsertTextCommand.id, params });
expect(params.range).toEqual({ startOffset: 4, endOffset: 5, collapsed: false });
});
it('overtypes the original percent value on initial digit input', () => {
const { controller } = createController('25%\r\n', true);
controller._editorBridgeService.getEditorDirty.mockReturnValue(false);
const beforeCommandListeners: Array<(command: { id: string; params: unknown }) => void> = [];
controller._editorBridgeService.isVisible.mockReturnValue({
visible: true,
eventType: DeviceInputEventType.Keyboard,
initialValue: '3',
unitId: 'unit-1',
});
controller._commandService.beforeCommandExecuted = vi.fn((listener) => {
beforeCommandListeners.push(listener);
return { dispose: vi.fn() };
});
controller._commandService.onCommandExecuted = vi.fn(() => ({ dispose: vi.fn() }));
controller._commandExecutedListener({ add: vi.fn() });
const params = {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: { dataStream: '3' },
range: { startOffset: 0, endOffset: 0, collapsed: true },
};
beforeCommandListeners[0]({ id: InsertTextCommand.id, params });
expect(params.range).toEqual({ startOffset: 0, endOffset: 2, collapsed: false });
});
it('syncs the active sheet editor selection instead of the host document selection on focus', () => {
const { controller, workbook } = createController();
const focus$ = new Subject<boolean>();
const hostSelectionSync = vi.fn();
const cellEditorSelectionSync = vi.fn();
const disposableCollection = { add: vi.fn() };
controller._cellEditorManagerService.focus$ = focus$;
controller._univerInstanceService.getCurrentUnitOfType.mockImplementation((type: UniverInstanceType) => {
if (type === UniverInstanceType.UNIVER_DOC) {
return { getUnitId: () => 'host-doc' };
}
return workbook;
});
controller._renderManagerService.getRenderUnitById.mockImplementation((unitId: string) => ({
with: vi.fn(() => {
if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return { sync: cellEditorSelectionSync };
}
if (unitId === 'host-doc') {
return { sync: hostSelectionSync };
}
return undefined;
}),
}));
controller._initialCursorSync(disposableCollection);
focus$.next(true);
expect(cellEditorSelectionSync).toHaveBeenCalledTimes(1);
expect(hostSelectionSync).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,58 @@
/**
* 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 { ICellData } from '@univerjs/core';
import { CellValueType, Styles, Tools } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getComparableCellData } from '../editing.render-controller';
describe('cell editor commit comparison', () => {
const unchanged: Array<{ name: string; previous: ICellData; incoming: ICellData }> = [
{ name: 'ordinary text after draft Undo', previous: { v: 'Committed', t: CellValueType.STRING }, incoming: { v: 'Committed' } },
{ name: 'number text', previous: { v: 42, t: CellValueType.NUMBER }, incoming: { v: '42' } },
{ name: 'zero', previous: { v: 0, t: CellValueType.NUMBER }, incoming: { v: '0' } },
{ name: 'true', previous: { v: 1, t: CellValueType.BOOLEAN }, incoming: { v: 'TRUE' } },
{ name: 'false', previous: { v: 0, t: CellValueType.BOOLEAN }, incoming: { v: 'FALSE' } },
{ name: 'forced text', previous: { v: '001', t: CellValueType.FORCE_STRING }, incoming: { v: '001', t: CellValueType.FORCE_STRING } },
{ name: 'inherited text format', previous: { v: '001', t: CellValueType.STRING, s: 'text' }, incoming: { v: '001', s: 'text' } },
{ name: 'resolved equivalent style', previous: { v: 'Styled', t: CellValueType.STRING, s: 'blue' }, incoming: { v: 'Styled', s: { cl: { rgb: '#0000ff' } } } },
];
it.each(unchanged)('recognizes unchanged $name without mutating the input or styles', ({ previous, incoming }) => {
const styles = new Styles({ text: { n: { pattern: '@' } }, blue: { cl: { rgb: '#0000ff' } } });
const previousBefore = Tools.deepClone(previous);
const incomingBefore = Tools.deepClone(incoming);
expect(getComparableCellData(incoming, styles, previous)).toEqual(getComparableCellData(previous, styles, previous));
expect(previous).toEqual(previousBefore);
expect(incoming).toEqual(incomingBefore);
expect(styles.get('text')).toEqual({ n: { pattern: '@' } });
expect(styles.get('blue')).toEqual({ cl: { rgb: '#0000ff' } });
});
const changed: Array<{ name: string; previous: ICellData; incoming: ICellData }> = [
{ name: 'actual text edit', previous: { v: 'old', t: CellValueType.STRING }, incoming: { v: 'new' } },
{ name: 'numeric conversion from text', previous: { v: '001', t: CellValueType.STRING }, incoming: { v: '001' } },
{ name: 'removing forced text', previous: { v: '001', t: CellValueType.FORCE_STRING }, incoming: { v: '001' } },
{ name: 'boolean conversion', previous: { v: 'TRUE', t: CellValueType.STRING }, incoming: { v: 'TRUE' } },
{ name: 'format-only edit', previous: { v: 'same', t: CellValueType.STRING, s: { bl: 0 } }, incoming: { v: 'same', s: { bl: 1 } } },
{ name: 'formula replacement', previous: { v: 2, t: CellValueType.NUMBER, f: '=1+1' }, incoming: { v: null, f: '=1+2' } },
];
it.each(changed)('preserves a meaningful $name', ({ previous, incoming }) => {
const styles = new Styles();
expect(getComparableCellData(incoming, styles, previous)).not.toEqual(getComparableCellData(previous, styles, previous));
});
});

View File

@@ -0,0 +1,31 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { EMBEDDING_FORMULA_EDITOR, isEmbeddingFormulaEditor } from '../utils/is-embedding-formula-editor';
import { isRangeSelector, RANGE_SELECTOR_SYMBOLS } from '../utils/is-range-selector';
describe('editor utils', () => {
it('should detect embedding formula editor unit ids', () => {
expect(isEmbeddingFormulaEditor(`abc_${EMBEDDING_FORMULA_EDITOR}_xyz`)).toBe(true);
expect(isEmbeddingFormulaEditor('normal-doc-id')).toBe(false);
});
it('should detect range selector unit ids', () => {
expect(isRangeSelector(`abc_${RANGE_SELECTOR_SYMBOLS}_xyz`)).toBe(true);
expect(isRangeSelector('normal-doc-id')).toBe(false);
});
});

View File

@@ -0,0 +1,197 @@
/**
* 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 {
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
EDITOR_ACTIVATED,
FOCUSING_FX_BAR_EDITOR,
FOCUSING_UNIVER_EDITOR,
} from '@univerjs/core';
import { CoverContentCommand, VIEWPORT_KEY } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation';
import { FormulaEditorController } from '../formula-editor.controller';
function createController() {
const fxBtnClick$ = new Subject<boolean>();
const position = { width: 240, height: 40 };
let focusEditorId = DOCS_NORMAL_EDITOR_UNIT_ID_KEY;
const contextValues = new Map<string, unknown>([
[EDITOR_ACTIVATED, false],
[FOCUSING_FX_BAR_EDITOR, false],
[FOCUSING_UNIVER_EDITOR, true],
]);
const scrollBar = { dispose: vi.fn() };
const viewport = {
getScrollBar: vi.fn(() => scrollBar),
scrollToViewportPos: vi.fn(),
resetCanvasSizeAndUpdateScroll: vi.fn(),
};
const scene = { transformByState: vi.fn(), getViewport: vi.fn((key) => key === VIEWPORT_KEY.VIEW_MAIN ? viewport : null) };
const mainComponent = { resize: vi.fn() };
const formulaDoc = {
getBody: vi.fn(() => ({ dataStream: 'SUM(A1:A3)\r\n' })),
getSnapshot: vi.fn(() => ({ documentStyle: { marginTop: 4, marginBottom: 6 } })),
updateDocumentDataPageSize: vi.fn(),
};
const render = {
scene,
mainComponent,
with: vi.fn(() => ({
getSkeleton: vi.fn(() => ({
getActualSize: vi.fn(() => ({ actualHeight: 24 })),
})),
})),
};
const controller = Object.create(FormulaEditorController.prototype) as any;
controller.dispose$ = new Subject<void>();
controller._formulaEditorManagerService = {
fxBtnClick$,
getPosition: vi.fn(() => position),
position$: new Subject(),
};
controller._renderManagerService = {
getRenderUnitById: vi.fn((unitId: string) => unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY ? render : null),
};
controller._univerInstanceService = {
setCurrentUnitForType: vi.fn(),
getUnit: vi.fn(() => formulaDoc),
};
controller._editorBridgeService = { isVisible: vi.fn(() => ({ visible: false })) };
controller._commandService = { syncExecuteCommand: vi.fn() };
controller._contextService = {
getContextValue: vi.fn((key: string) => contextValues.get(key)),
setContextValue: vi.fn((key: string, value: unknown) => contextValues.set(key, value)),
};
controller._textSelectionManagerService = { replaceDocRanges: vi.fn() };
controller._editorService = {
getFocusEditor: vi.fn(() => ({ getEditorId: () => focusEditorId })),
};
return {
controller,
contextValues,
fxBtnClick$,
formulaDoc,
mainComponent,
scene,
setFocusEditorId: (editorId: string) => {
focusEditorId = editorId;
},
scrollBar,
viewport,
};
}
describe('FormulaEditorController business methods', () => {
it('resizes the formula editor and resets scroll when content fits in the formula bar', () => {
const { controller, mainComponent, scene, scrollBar, viewport } = createController();
controller.autoScroll();
expect(scene.transformByState).toHaveBeenCalledWith({ width: 240, height: 34 });
expect(mainComponent.resize).toHaveBeenCalledWith(240, 34);
expect(viewport.scrollToViewportPos).toHaveBeenCalledWith({ viewportScrollX: 0, viewportScrollY: 0 });
expect(scrollBar.dispose).toHaveBeenCalled();
});
it('turns hidden formula-bar content into a formula when fx button is clicked', () => {
const { controller, fxBtnClick$ } = createController();
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const raf = vi.fn((cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal('requestAnimationFrame', raf);
controller._listenFxBtnClick();
fxBtnClick$.next(false);
expect(controller._univerInstanceService.setCurrentUnitForType).toHaveBeenCalledWith(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.PointerDown,
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
});
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(CoverContentCommand.id, {
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: { dataStream: '=SUM(A1:A3)' },
segmentId: '',
textRanges: [{ startOffset: 11, endOffset: 11 }],
});
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([], {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
});
expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, true);
vi.stubGlobal('requestAnimationFrame', originalRequestAnimationFrame);
});
it('enters formula mode from an active mobile formula bar', () => {
const { contextValues, controller, fxBtnClick$ } = createController();
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
contextValues.set(EDITOR_ACTIVATED, true);
controller._editorBridgeService.isVisible.mockReturnValue({ visible: true });
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
controller._listenFxBtnClick();
fxBtnClick$.next(true);
expect(controller._commandService.syncExecuteCommand).not.toHaveBeenCalledWith(
SetCellEditVisibleOperation.id,
expect.anything()
);
expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(CoverContentCommand.id, {
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: { dataStream: '=SUM(A1:A3)' },
segmentId: '',
textRanges: [{ startOffset: 11, endOffset: 11 }],
});
expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, true);
vi.stubGlobal('requestAnimationFrame', originalRequestAnimationFrame);
});
it('keeps formula bar focus while formula range picking is active', () => {
const { contextValues, controller, setFocusEditorId } = createController();
contextValues.set(FOCUSING_FX_BAR_EDITOR, true);
contextValues.set(EDITOR_ACTIVATED, true);
setFocusEditorId(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
controller._syncFxBarFocusContext();
expect(contextValues.get(FOCUSING_FX_BAR_EDITOR)).toBe(true);
expect(controller._contextService.setContextValue).not.toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, false);
});
it('clears formula bar focus when focus leaves outside formula range picking', () => {
const { contextValues, controller, setFocusEditorId } = createController();
contextValues.set(FOCUSING_FX_BAR_EDITOR, true);
contextValues.set(EDITOR_ACTIVATED, false);
setFocusEditorId(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
controller._syncFxBarFocusContext();
expect(contextValues.get(FOCUSING_FX_BAR_EDITOR)).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
/**
* 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 { IFunctionInfo, IFunctionNames } from '@univerjs/engine-formula';
import { createIdentifier } from '@univerjs/core';
import { FunctionType } from '@univerjs/engine-formula';
export class MockFunctionService {
getDescriptions(): Map<IFunctionNames, IFunctionInfo> {
const map = new Map<IFunctionNames, IFunctionInfo>();
map.set('IF', { functionName: 'IF', functionType: FunctionType.Logical, description: 'IF function', abstract: 'IF function', functionParameter: [] });
map.set('TAN', { functionName: 'TAN', functionType: FunctionType.Logical, description: 'TAN function', abstract: 'TAN function', functionParameter: [] });
return map;
}
}
export const IMockFunctionService = createIdentifier<MockFunctionService>('mock.univer.formula.function.service');

View File

@@ -0,0 +1,404 @@
/**
* 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 { DocumentDataModel, ICommandInfo, IDocumentBody, IDocumentStyle, IDrawings, IParagraph, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { DocumentViewModel } from '@univerjs/engine-render';
import type { IMoveRangeMutationParams, ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { ICellEditorState } from '../../services/editor-bridge.service';
import {
BooleanNumber,
createParagraphId,
Disposable,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentFlavor,
FOCUSING_FX_BAR_EDITOR,
HorizontalAlign,
ICommandService,
IContextService,
Inject,
IUniverInstanceService,
ThemeService,
Tools,
UniverInstanceType,
VerticalAlign,
WrapStrategy,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { ReplaceSnapshotCommand } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { MoveRangeMutation, RangeProtectionRuleModel, SetRangeValuesMutation, WorksheetProtectionRuleModel } from '@univerjs/sheets';
import { skip } from 'rxjs';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { IFormulaEditorManagerService } from '../../services/editor/formula-editor-manager.service';
import { FormulaEditorController } from './formula-editor.controller';
const formulaEditorStyle: IDocumentStyle = {
pageSize: {
width: Number.POSITIVE_INFINITY,
height: Number.POSITIVE_INFINITY,
},
documentFlavor: DocumentFlavor.UNSPECIFIED,
marginTop: 5,
marginBottom: 5,
marginRight: 0,
marginLeft: 0,
paragraphLineGapDefault: 0,
renderConfig: {
horizontalAlign: HorizontalAlign.UNSPECIFIED,
verticalAlign: VerticalAlign.TOP,
centerAngle: 0,
vertexAngle: 0,
wrapStrategy: WrapStrategy.WRAP,
isRenderStyle: BooleanNumber.FALSE,
},
};
/**
* sync data between cell editor and formula editor
*/
export class EditorDataSyncController extends Disposable {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(RangeProtectionRuleModel) private readonly _rangeProtectionRuleModel: RangeProtectionRuleModel,
@Inject(WorksheetProtectionRuleModel) private readonly _worksheetProtectionRuleModel: WorksheetProtectionRuleModel,
@Inject(FormulaEditorController) private readonly _formulaEditorController: FormulaEditorController,
@IFormulaEditorManagerService private readonly _formulaEditorManagerService: IFormulaEditorManagerService,
@IContextService private readonly _contextService: IContextService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._initialize();
}
private _initialize() {
this._syncFormulaEditorContent();
this._commandExecutedListener();
this._syncFormulaRefRenderStyleOnFocusChange();
this._syncFormulaBarTextColorOnThemeChange();
}
private _getEditorViewModel(unitId: string): Nullable<DocumentViewModel> {
return this._renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService).getViewModel();
}
// Sync cell content to formula editor bar when sheet selection changed.
private _syncFormulaEditorContent() {
this.disposeWithMe(this._editorBridgeService.currentEditCellState$.subscribe((editCellState) => {
if (
editCellState == null
|| this._editorBridgeService.isForceKeepVisible()
// If permissions are not initialized, data synchronization will not be performed.
|| !this._rangeProtectionRuleModel.getRangeRuleInitState()
|| !this._worksheetProtectionRuleModel.getSheetRuleInitState()
) {
return;
}
this._editorSyncHandler(editCellState);
}));
}
// Sync cell content to formula editor bar when sheet selection changed or visible changed.
private _editorSyncHandler(param: ICellEditorState) {
let body = Tools.deepClone(param.documentLayoutObject.documentModel?.getBody());
const drawings = Tools.deepClone(param.documentLayoutObject.documentModel?.drawings);
const drawingsOrder = Tools.deepClone(param.documentLayoutObject.documentModel?.getDrawingsOrder());
if (
!body ||
(param.isInArrayFormulaRange === true && this._editorBridgeService.isVisible().eventType === DeviceInputEventType.Dblclick)
) {
body = {
dataStream: '\r\n',
paragraphs: [
{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
},
],
textRuns: [],
};
}
this._syncContentAndRender(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, body, drawings, drawingsOrder);
}
private _commandExecutedListener() {
const INCLUDE_LIST = [DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY];
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === RichTextEditingMutation.id) {
const params = command.params as IRichTextEditingMutationParams;
const { unitId, trigger, isSync } = params;
if (isSync || trigger === ReplaceSnapshotCommand.id) {
return;
}
if (INCLUDE_LIST.includes(unitId)) {
// sync cell content to formula editor bar when edit cell editor and vice verse.
const editorDocDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const syncId =
unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY
? DOCS_NORMAL_EDITOR_UNIT_ID_KEY
: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY;
this._checkAndSetRenderStyleConfig(editorDocDataModel!);
this._syncActionsAndRender(syncId, params);
}
}
})
);
// Update formula bar content when you call SetRangeValuesMutation and MoveRangeMutation.
const needUpdateFormulaEditorContentCommandList = [SetRangeValuesMutation.id, MoveRangeMutation.id];
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (!needUpdateFormulaEditorContentCommandList.includes(command.id)) {
return;
}
const editCellState = this._editorBridgeService.getEditLocation();
if (editCellState == null) {
return;
}
const { row, column } = editCellState;
if (this._shouldRefreshCurrentEditCell(command, row, column)) {
/**
* Refresh the current edit-cell snapshot so both the hidden cell editor and
* the formula bar stay aligned with external cell mutations.
*/
this._editorBridgeService.refreshEditCellState();
}
})
);
}
private _shouldRefreshCurrentEditCell(command: ICommandInfo, row: number, column: number): boolean {
if (command.id === SetRangeValuesMutation.id && command.params) {
const params = command.params as ISetRangeValuesMutationParams;
return this._hasMatrixCell(params.cellValue, row, column);
}
if (command.id === MoveRangeMutation.id && command.params) {
const params = command.params as IMoveRangeMutationParams;
return this._hasMatrixCell(params.from.value, row, column) || this._hasMatrixCell(params.to.value, row, column);
}
return false;
}
private _hasMatrixCell(
matrix: ISetRangeValuesMutationParams['cellValue'] | IMoveRangeMutationParams['from']['value'],
row: number,
column: number
): boolean {
const rowData = matrix?.[row];
return rowData != null && Object.prototype.hasOwnProperty.call(rowData, column);
}
// Sync actions between cell editor and formula editor, and make `dataStream` and `paragraph` is the same.
private _syncActionsAndRender(
unitId: string,
parmas: IRichTextEditingMutationParams
) {
const INCLUDE_LIST = [DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY];
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
if (currentRender == null) {
return;
}
const skeleton = currentRender.with(DocSkeletonManagerService).getSkeleton();
const docDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const docViewModel = this._getEditorViewModel(unitId);
if (docDataModel == null || docViewModel == null) {
return;
}
this._commandService.syncExecuteCommand(RichTextEditingMutation.id, {
...parmas,
textRanges: null,
isSync: true,
unitId,
syncer: parmas.unitId,
});
this._checkAndSetRenderStyleConfig(docDataModel);
docViewModel.reset(docDataModel);
skeleton.calculate();
if (INCLUDE_LIST.includes(unitId)) {
currentRender.mainComponent?.makeDirty();
}
}
private _syncContentAndRender(
unitId: string,
body: IDocumentBody,
drawings: Nullable<IDrawings>,
drawingsOrder: Nullable<string[]>
) {
if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
if (body.paragraphs) {
body.paragraphs = this._clearParagraph(body.paragraphs);
}
}
const INCLUDE_LIST = [DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY];
const skeleton = this._renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService).getSkeleton();
const docDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const docViewModel = this._getEditorViewModel(unitId);
if (docDataModel == null || docViewModel == null || skeleton == null) {
return;
}
docDataModel.reset({
...docDataModel.getSnapshot(),
body,
drawings: drawings ?? {},
drawingsOrder: drawingsOrder ?? [],
});
this._checkAndSetRenderStyleConfig(docDataModel);
docViewModel.reset(docDataModel);
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
if (currentRender == null) {
return;
}
skeleton.calculate();
if (INCLUDE_LIST.includes(unitId)) {
currentRender.mainComponent?.makeDirty();
}
if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
this._formulaEditorController.autoScroll();
}
}
private _syncFormulaRefRenderStyleOnFocusChange() {
this.disposeWithMe(this._contextService.subscribeContextValue$(FOCUSING_FX_BAR_EDITOR).subscribe(() => {
this._refreshRenderStyleConfig(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
this._refreshRenderStyleConfig(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
}));
}
private _syncFormulaBarTextColorOnThemeChange() {
this.disposeWithMe(this._themeService.currentTheme$.pipe(skip(1)).subscribe(() => {
this._refreshRenderStyleConfig(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
}));
}
private _refreshRenderStyleConfig(unitId: string) {
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
const skeleton = currentRender?.with(DocSkeletonManagerService).getSkeleton();
const docDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const docViewModel = this._getEditorViewModel(unitId);
if (currentRender == null || skeleton == null || docDataModel == null || docViewModel == null) {
return;
}
this._checkAndSetRenderStyleConfig(docDataModel);
docViewModel.reset(docDataModel);
skeleton.calculate();
currentRender.mainComponent?.makeDirty();
if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
this._formulaEditorController.autoScroll();
}
}
private _checkAndSetRenderStyleConfig(documentDataModel: DocumentDataModel) {
const snapshot = documentDataModel.getSnapshot();
if (snapshot.id !== DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY && snapshot.id !== DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return;
}
const isFormulaBar = snapshot.id === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY;
const previousPageWidth = snapshot.documentStyle?.pageSize?.width;
if (isFormulaBar) {
snapshot.documentStyle = {
...formulaEditorStyle,
pageSize: { ...formulaEditorStyle.pageSize },
renderConfig: { ...formulaEditorStyle.renderConfig },
textStyle: {
cl: {
rgb: this._themeService.getColorFromTheme('gray.900'),
},
},
};
} else {
snapshot.documentStyle ??= {};
}
let renderConfig = snapshot.documentStyle.renderConfig;
if (renderConfig == null) {
renderConfig = {};
snapshot.documentStyle.renderConfig = renderConfig;
}
const position = isFormulaBar ? this._formulaEditorManagerService.getPosition() : null;
if (isFormulaBar && position) {
// A non-positive width means the formula bar is not laid out, so keep the last valid width.
const width = position.width > 0 ? position.width : previousPageWidth;
if (width != null && width > 0) {
snapshot.documentStyle.pageSize = {
width,
height: Infinity,
};
}
}
const isFormula = (snapshot.body?.dataStream ?? '').startsWith('=');
if (!isFormula) {
renderConfig.isRenderStyle = this._getSourceStyleRenderValue(isFormulaBar);
return;
}
const isFocusFxBar = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR);
renderConfig.isRenderStyle = isFormulaBar === isFocusFxBar ? BooleanNumber.TRUE : BooleanNumber.FALSE;
}
protected _getSourceStyleRenderValue(isFormulaBar: boolean): BooleanNumber {
return isFormulaBar ? BooleanNumber.FALSE : BooleanNumber.TRUE;
}
private _clearParagraph(paragraphs: IParagraph[]) {
const newParagraphs = Tools.deepClone(paragraphs);
for (const paragraph of newParagraphs) {
if (paragraph.paragraphStyle) {
paragraph.paragraphStyle.horizontalAlign = HorizontalAlign.UNSPECIFIED;
}
}
return newParagraphs;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
/**
* 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 { DocumentDataModel, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { RenderComponentType } from '@univerjs/engine-render';
import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import {
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
EDITOR_ACTIVATED,
FOCUSING_FX_BAR_EDITOR,
FOCUSING_UNIVER_EDITOR,
ICommandService,
IContextService,
Inject,
IUndoRedoService,
IUniverInstanceService,
RxDisposable,
UniverInstanceType,
} from '@univerjs/core';
import {
DocSelectionManagerService,
DocSkeletonManagerService,
RichTextEditingMutation,
} from '@univerjs/docs';
import { CoverContentCommand, IEditorService, VIEWPORT_KEY } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService, ScrollBar } from '@univerjs/engine-render';
import { combineLatest, filter, takeUntil } from 'rxjs';
import { getEditorObject } from '../../basics/editor/get-editor-object';
import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { IFormulaEditorManagerService } from '../../services/editor/formula-editor-manager.service';
export class FormulaEditorController extends RxDisposable {
private _loadedMap = new WeakSet<RenderComponentType>();
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@ICommandService private readonly _commandService: ICommandService,
@IContextService private readonly _contextService: IContextService,
@IFormulaEditorManagerService private readonly _formulaEditorManagerService: IFormulaEditorManagerService,
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@IEditorService private readonly _editorService: IEditorService
) {
super();
this._initialize();
}
private _initialize() {
this._syncEditorSize();
this._listenFxBtnClick();
this._handleContentChange();
this._univerInstanceService.focused$.pipe(takeUntil(this.dispose$)).subscribe((unitId) => {
this._create(unitId);
});
this._create(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
this.disposeWithMe(this._editorService.focus$.subscribe(() => {
this._syncFxBarFocusContext();
}));
}
private _syncFxBarFocusContext(): void {
const focusUnitId = this._editorService.getFocusEditor()?.getEditorId();
if (focusUnitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true);
return;
}
if (
this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) &&
this._contextService.getContextValue(EDITOR_ACTIVATED)
) {
return;
}
this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false);
}
private _handleContentChange() {
this.disposeWithMe(
this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === RichTextEditingMutation.id) {
const params = commandInfo.params as IRichTextEditingMutationParams;
const { unitId } = params;
if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
this.autoScroll();
}
}
})
);
}
private _create(unitId: Nullable<string>) {
if (unitId !== DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
return;
}
const formulaEditorDocObject = this._renderManagerService.getRenderUnitById(unitId);
if (formulaEditorDocObject == null) {
return;
}
const { mainComponent: documentComponent } = formulaEditorDocObject;
if (documentComponent == null) {
return;
}
if (!this._loadedMap.has(documentComponent)) {
this._loadedMap.add(documentComponent);
}
}
private _listenFxBtnClick() {
this._formulaEditorManagerService.fxBtnClick$.pipe(takeUntil(this.dispose$)).subscribe((forceFormulaMode) => {
const isFocusButHidden =
this._contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
!this._contextService.getContextValue(EDITOR_ACTIVATED);
if (isFocusButHidden || forceFormulaMode) {
this._univerInstanceService.setCurrentUnitForType(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
const formulaEditorDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
UniverInstanceType.UNIVER_DOC
);
const visibleState = this._editorBridgeService.isVisible();
if (visibleState.visible === false) {
this._commandService.syncExecuteCommand(
SetCellEditVisibleOperation.id,
{
visible: true,
eventType: DeviceInputEventType.PointerDown,
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
} as IEditorBridgeServiceVisibleParam
);
}
const content = formulaEditorDataModel?.getBody()?.dataStream;
if (content == null) {
return;
}
let newContent = content.startsWith('=') ? content : `=${content}`;
newContent = newContent.replace(/\r\n$/, '');
const textRanges = [
{
startOffset: newContent.length,
endOffset: newContent.length,
},
];
const coverContentParams = {
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
body: {
dataStream: newContent,
},
segmentId: '',
textRanges,
};
this._commandService.syncExecuteCommand(CoverContentCommand.id, coverContentParams);
requestAnimationFrame(() => {
this._textSelectionManagerService.replaceDocRanges([], {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
});
this._textSelectionManagerService.replaceDocRanges(textRanges, {
unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
});
this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true);
});
}
});
}
private _syncEditorSize() {
// this._univerInstanceService.
const addFormulaBar$ = this._univerInstanceService.unitAdded$.pipe(filter((event) => event.unit.getUnitId() === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY));
this.disposeWithMe(combineLatest([this._formulaEditorManagerService.position$, addFormulaBar$]).subscribe(([position]) => {
if (!position) return this._clearScheduledCallback();
const editorObject = getEditorObject(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, this._renderManagerService);
const formulaEditorDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
UniverInstanceType.UNIVER_DOC
);
if (editorObject == null || formulaEditorDataModel == null) return this._clearScheduledCallback();
const { width, height } = position;
if (width === 0 || height === 0) return this._clearScheduledCallback();
const { engine } = editorObject;
formulaEditorDataModel.updateDocumentDataPageSize(width);
this.autoScroll();
this._scheduledCallback = requestIdleCallback(() => engine.resizeBySize(width, height));
}));
}
private _scheduledCallback: number = -1;
private _clearScheduledCallback(): void {
if (this._scheduledCallback !== -1) cancelIdleCallback(this._scheduledCallback);
this._scheduledCallback = -1;
}
autoScroll() {
const position = this._formulaEditorManagerService.getPosition();
const skeleton = this._renderManagerService.getRenderUnitById(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY)?.with(DocSkeletonManagerService).getSkeleton();
const editorObject = this._renderManagerService.getRenderUnitById(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY);
const formulaEditorDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
UniverInstanceType.UNIVER_DOC
);
if (skeleton == null || position == null || editorObject == null || formulaEditorDataModel == null) {
return;
}
const { marginTop = 0, marginBottom = 0 } = formulaEditorDataModel.getSnapshot().documentStyle;
const { scene, mainComponent } = editorObject;
let { actualHeight } = skeleton.getActualSize();
// page actual height also need to include page margin top and margin bottom.
actualHeight += marginTop + marginBottom;
const { width, height } = position;
const viewportMain = scene.getViewport(VIEWPORT_KEY.VIEW_MAIN);
let scrollBar = viewportMain?.getScrollBar() as Nullable<ScrollBar>;
scene.transformByState({
width,
height: actualHeight,
});
mainComponent?.resize(width, actualHeight);
if (actualHeight > height) {
if (scrollBar == null) {
viewportMain && new ScrollBar(viewportMain, { enableHorizontal: false, barSize: 8 });
} else {
viewportMain?.resetCanvasSizeAndUpdateScroll();
}
} else {
scrollBar = null;
viewportMain?.scrollToViewportPos({ viewportScrollX: 0, viewportScrollY: 0 });
viewportMain?.getScrollBar()?.dispose();
}
}
}

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
export const EMBEDDING_FORMULA_EDITOR = 'embedding_formula_editor';
export const isEmbeddingFormulaEditor = (unitId: string) => {
return unitId.includes(EMBEDDING_FORMULA_EDITOR);
};

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
export const RANGE_SELECTOR_SYMBOLS = 'range_selector';
export const isRangeSelector = (unitId: string) => {
return unitId.includes(RANGE_SELECTOR_SYMBOLS);
};

View File

@@ -0,0 +1,153 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable, Workbook, Worksheet } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { IUniverSheetsUIConfig } from '../config/config';
import type { LocaleKey } from '../locale/types';
import {
CellValueType,
Disposable,
getNumfmtParseValueFilter,
ICommandService,
IConfigService,
Inject,
isRealNum,
isTextFormat,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { SheetsSelectionsService, TextToNumberCommand } from '@univerjs/sheets';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import { CellAlertManagerService, CellAlertType } from '../services/cell-alert-manager.service';
const ALERT_KEY = 'SHEET_FORCE_STRING_ALERT';
export class ForceStringAlertRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@Inject(CellAlertManagerService) private readonly _cellAlertManagerService: CellAlertManagerService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IConfigService private readonly _configService: IConfigService,
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._init();
}
private _init() {
this._initCellAlertPopup();
}
private _initCellAlertPopup() {
this.disposeWithMe(this._selectionManagerService.selectionChanged$.subscribe((selections) => this._updateAlert(selections)));
this.disposeWithMe(this._context.unit.activeSheet$.subscribe(() => this._updateAlert(this._selectionManagerService.getCurrentSelections())));
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).subscribe(() =>
this._updateAlert(this._selectionManagerService.getCurrentSelections())
)
);
}
private _isCurrentSheet(worksheet: Nullable<Worksheet>): worksheet is Worksheet {
const selectionParam = this._selectionManagerService.currentSelectionParam;
return Boolean(
worksheet &&
selectionParam?.unitId === this._context.unit.getUnitId() &&
selectionParam.sheetId === worksheet.getSheetId()
);
}
private _updateAlert(selections: Nullable<Readonly<ISelectionWithStyle[]>>) {
this._hideAlert();
const workbook = this._context.unit;
const worksheet = workbook.getActiveSheet();
if (!this._isCurrentSheet(worksheet)) {
return;
}
if (selections?.length !== 1) {
return;
}
if (this._configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY)?.disableForceStringAlert) {
return;
}
const selection = selections[0];
const primary = selection.primary;
if (!primary) {
return;
}
const row = primary.actualRow;
const col = primary.actualColumn;
const cellData = worksheet.getCell(row, col);
if (!cellData || cellData.v === null || cellData.v === undefined) {
return;
}
const cellStyle = workbook.getStyles().get(cellData.s);
if (isTextFormat(cellStyle?.n?.pattern)) {
return;
}
if (
(cellData.t !== CellValueType.FORCE_STRING && cellData.t !== CellValueType.STRING) ||
(!isRealNum(cellData.v) && !(typeof cellData.v === 'string' && getNumfmtParseValueFilter(cellData.v)))
) {
return;
}
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const range = { ...selection.range };
this._cellAlertManagerService.showAlert({
type: CellAlertType.WARNING,
title: this._localeService.t<LocaleKey>('sheets-ui.info.error'),
message: this._localeService.t<LocaleKey>('sheets-ui.info.forceStringInfo'),
location: { unitId, subUnitId, row, col },
width: 200,
height: 74,
key: this._alertKey,
menu: [{
label: this._localeService.t<LocaleKey>('sheets-ui.info.convertToNumber'),
onSelect: () => {
const result = this._commandService.syncExecuteCommand(TextToNumberCommand.id, {
unitId,
subUnitId,
ranges: [range],
});
if (result) {
this._hideAlert();
}
},
}],
});
}
private get _alertKey() {
return `${ALERT_KEY}:${this._context.unit.getUnitId()}`;
}
private _hideAlert() {
this._cellAlertManagerService.removeAlert(this._alertKey);
}
}

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 { Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IUniverSheetsUIConfig } from '../config/config';
import { CellValueType, getNumfmtParseValueFilter, IConfigService, Inject, InterceptorEffectEnum, isRealNum, isTextFormat, RxDisposable } from '@univerjs/core';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service';
export class ForceStringRenderController extends RxDisposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@IConfigService private readonly _configService: IConfigService
) {
super();
this._initViewModelIntercept();
}
private _initViewModelIntercept() {
const FORCE_STRING_MARK = {
tl: {
size: 6,
color: '#409f11',
},
};
this.disposeWithMe(
this._sheetInterceptorService.intercept(
INTERCEPTOR_POINT.CELL_CONTENT,
{
priority: 10,
effect: InterceptorEffectEnum.Style,
handler: (cell, pos, next) => {
const skeleton = this._sheetSkeletonManagerService.getCurrentParam()?.skeleton;
if (!skeleton) {
return next(cell);
}
const cellRaw = pos.rawData;
if (!cellRaw || cellRaw.v === null || cellRaw.v === undefined) {
return next(cell);
}
/**
* If the cell type is string or force string, and the value is a pure number or a string that can be converted to a number, show the force string mark.
* '123 -> yes
* '20% -> yes
* '1,234.56 -> yes
* 'abc -> no
* '2025-09-17 -> no
*/
if (
(cell?.t === CellValueType.FORCE_STRING || cell?.t === CellValueType.STRING) &&
(isRealNum(cellRaw.v) || (typeof cellRaw.v === 'string' && getNumfmtParseValueFilter(cellRaw.v)))
) {
// If the cell is in text format, follow the logic of number format
const cellStyle = pos.workbook.getStyles().get(cellRaw.s);
if (isTextFormat(cellStyle?.n?.pattern)) {
return next(cell);
}
// If the user has disabled the force string mark, do not show it
if (this._configService.getConfig<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY)?.disableForceStringMark) {
return next(cell);
}
if (cell === cellRaw) {
cell = { ...cellRaw };
}
cell.markers = { ...cell?.markers, ...FORCE_STRING_MARK };
return next(cell);
}
return next(cell);
},
}
)
);
}
}

View File

@@ -0,0 +1,321 @@
/**
* 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 { ICellData, IMutationInfo, IRange, IStyleData, Workbook } from '@univerjs/core';
import type { IAddWorksheetMergeMutationParams, IRemoveWorksheetMergeMutationParams, ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { IFormatPainterHook, ISelectionFormatInfo } from '../../services/format-painter/format-painter.service';
import {
Disposable,
ICommandService,
Inject,
Injector,
IUniverInstanceService,
ObjectMatrix,
RANGE_TYPE,
Rectangle,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
AddMergeUndoMutationFactory,
AddWorksheetMergeMutation,
getAddMergeMutationRangeByType,
getClearContentMutationParamsForRanges,
RemoveMergeUndoMutationFactory,
RemoveWorksheetMergeMutation,
SelectionMoveType,
SetRangeValuesCommand,
SetRangeValuesMutation,
SetRangeValuesUndoMutationFactory,
SetSelectionsOperation,
SetStyleCommand,
SheetInterceptorService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { checkCellContentInRanges } from '../../common/utils';
import { FormatPainterStatus, IFormatPainterService } from '../../services/format-painter/format-painter.service';
export class FormatPainterController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IFormatPainterService private readonly _formatPainterService: IFormatPainterService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initialize();
}
private _initialize() {
this._addDefaultHook();
}
private _addDefaultHook() {
const defaultHook: IFormatPainterHook = {
id: 'default-format-painter',
priority: 0,
isDefaultHook: true,
onStatusChange: (status: FormatPainterStatus) => {
if (status !== FormatPainterStatus.OFF) {
const format = this._collectSelectionRangeFormat();
if (format) {
this._formatPainterService.setSelectionFormat(format);
}
}
},
onApply: (unitId, subUnitId, range, format) => {
return this._getUndoRedoMutationInfo(unitId, subUnitId, range, format);
},
};
this._formatPainterService.addHook(defaultHook);
}
private _collectSelectionRangeFormat() {
const selection = this._selectionManagerService.getCurrentLastSelection();
const range = selection?.range;
if (!range) return null;
const { startRow, endRow, startColumn, endColumn } = range;
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook?.getActiveSheet();
if (!worksheet) return null;
const cellData = worksheet.getCellMatrix();
const styles = workbook.getStyles();
const stylesMatrix = new ObjectMatrix<IStyleData>();
const merges = [];
for (let r = startRow; r <= endRow; r++) {
for (let c = startColumn; c <= endColumn; c++) {
const cell = cellData.getValue(r, c) as ICellData;
stylesMatrix.setValue(r, c, styles.getStyleByCell(cell) || {});
const { isMergedMainCell, ...mergeInfo } = worksheet.getCellInfoInMergeData(r, c);
if (isMergedMainCell) {
merges.push({
startRow: mergeInfo.startRow,
startColumn: mergeInfo.startColumn,
endRow: mergeInfo.endRow,
endColumn: mergeInfo.endColumn,
});
}
}
}
return {
styles: stylesMatrix,
merges,
};
}
// eslint-disable-next-line max-lines-per-function
private _getUndoRedoMutationInfo(unitId: string, subUnitId: string, originRange: IRange, format: ISelectionFormatInfo) {
const sheetInterceptorService = this._sheetInterceptorService;
const univerInstanceService = this._univerInstanceService;
const { merges, styles: stylesMatrix } = format;
if (!stylesMatrix) return { undos: [], redos: [] };
const { startRow, startColumn, endRow, endColumn } = stylesMatrix.getDataRange();
const styleRowsNum = endRow - startRow + 1;
const styleColsNum = endColumn - startColumn + 1;
const range = (originRange.startRow === originRange.endRow && originRange.startColumn === originRange.endColumn)
? {
startRow: originRange.startRow,
startColumn: originRange.startColumn,
endRow: originRange.startRow + styleRowsNum - 1,
endColumn: originRange.startColumn + styleColsNum - 1,
}
: originRange;
const styleValues: ICellData[][] = Array.from({ length: range.endRow - range.startRow + 1 }, () =>
Array.from({ length: range.endColumn - range.startColumn + 1 }, () => ({})));
const mergeRanges: IRange[] = [];
styleValues.forEach((row, rowIndex) => {
row.forEach((col, colIndex) => {
const mappedRowIndex = (rowIndex % styleRowsNum) + startRow;
const mappedColIndex = (colIndex % styleColsNum) + startColumn;
const style = stylesMatrix.getValue(mappedRowIndex, mappedColIndex);
if (style) {
styleValues[rowIndex][colIndex].s = Object.keys(style).length > 0 ? style : null;
}
});
});
// whether the primary cell is in a merge range
let primaryCellMergeRows = 0;
let primaryCellMergeCols = 0;
merges.forEach((merge) => {
if (merge.startRow === startRow && merge.startColumn === startColumn) {
primaryCellMergeRows = merge.endRow - merge.startRow;
primaryCellMergeCols = merge.endColumn - merge.startColumn;
}
const relatedRange: IRange = {
startRow: merge.startRow - startRow,
startColumn: merge.startColumn - startColumn,
endRow: merge.endRow - startRow,
endColumn: merge.endColumn - startColumn,
};
// merge will apply at least once
const rowRepeats = Math.max(1, Math.floor((range.endRow - range.startRow + 1) / styleRowsNum));
const colRepeats = Math.max(1, Math.floor((range.endColumn - range.startColumn + 1) / styleColsNum));
for (let i = 0; i < rowRepeats; i++) {
for (let j = 0; j < colRepeats; j++) {
mergeRanges.push({
startRow: relatedRange.startRow + i * styleRowsNum + range.startRow,
startColumn: relatedRange.startColumn + j * styleColsNum + range.startColumn,
endRow: relatedRange.endRow + i * styleRowsNum + range.startRow,
endColumn: relatedRange.endColumn + j * styleColsNum + range.startColumn,
});
}
}
});
const currentSelections = [range];
const clearCellValue = new ObjectMatrix<ICellData>();
const cellValue = new ObjectMatrix<ICellData>();
if (Tools.isArray(styleValues)) {
for (let i = 0; i < currentSelections.length; i++) {
const { startRow, startColumn, endRow, endColumn } = currentSelections[i];
for (let r = 0; r <= endRow - startRow; r++) {
for (let c = 0; c <= endColumn - startColumn; c++) {
clearCellValue.setValue(r + startRow, c + startColumn, { s: null });
cellValue.setValue(r + startRow, c + startColumn, styleValues[r][c]);
}
}
}
}
const clearStyleMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
trigger: SetStyleCommand.id,
cellValue: clearCellValue.getMatrix(),
};
const setRangeValuesMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
trigger: SetStyleCommand.id,
cellValue: cellValue.getMatrix(),
};
const undoSetRangeValuesMutationParams: ISetRangeValuesMutationParams = this._injector.invoke(
SetRangeValuesUndoMutationFactory,
clearStyleMutationParams
);
const { undos: interceptorUndos, redos: interceptorRedos } = sheetInterceptorService.onCommandExecute({
id: SetRangeValuesCommand.id,
params: { ...setRangeValuesMutationParams, range: currentSelections },
});
const mergeRedos: IMutationInfo[] = [];
const mergeUndos: IMutationInfo[] = [];
// handle merge
if (mergeRanges.length > 0) {
const ranges = getAddMergeMutationRangeByType(mergeRanges);
// First we should check if there are values in the going-to-be-merged cells.
const worksheet = (univerInstanceService.getUnit(unitId) as Workbook).getSheetBySheetId(subUnitId)!;
const willRemoveSomeCell = checkCellContentInRanges(worksheet, ranges);
// prepare redo mutations
const removeMergeMutationParams: IRemoveWorksheetMergeMutationParams = {
unitId,
subUnitId,
ranges,
};
const addMergeMutationParams: IAddWorksheetMergeMutationParams = {
unitId,
subUnitId,
ranges,
};
mergeRedos.push({ id: RemoveWorksheetMergeMutation.id, params: removeMergeMutationParams });
mergeRedos.push({ id: AddWorksheetMergeMutation.id, params: addMergeMutationParams });
// prepare undo mutations
const undoRemoveMergeMutationParams = this._injector.invoke(
RemoveMergeUndoMutationFactory,
removeMergeMutationParams
);
const undoMutationParams = this._injector.invoke(
AddMergeUndoMutationFactory,
addMergeMutationParams
);
mergeUndos.push({ id: RemoveWorksheetMergeMutation.id, params: undoMutationParams });
mergeUndos.push({ id: AddWorksheetMergeMutation.id, params: undoRemoveMergeMutationParams });
// add set range values mutations to undo redo mutations
if (willRemoveSomeCell) {
const data = this._injector.invoke((accessor) => getClearContentMutationParamsForRanges(accessor, unitId, worksheet, ranges));
mergeRedos.unshift(...data.redos);
mergeUndos.push(...data.undos);
}
}
// if the range changed, need to reset selections
if (!Rectangle.equals(range, originRange)) {
const primary = {
actualRow: range.startRow,
actualColumn: range.startColumn,
rangeType: RANGE_TYPE.NORMAL,
isMerged: primaryCellMergeRows > 0 || primaryCellMergeCols > 0,
isMergedMainCell: false,
startRow: range.startRow,
startColumn: range.startColumn,
endRow: range.startRow + primaryCellMergeRows,
endColumn: range.startColumn + primaryCellMergeCols,
};
mergeRedos.push({
id: SetSelectionsOperation.id,
params: {
unitId,
subUnitId,
type: SelectionMoveType.ONLY_SET,
selections: [{ range, primary, style: null }],
},
});
mergeUndos.unshift({
id: SetSelectionsOperation.id,
params: {
unitId,
subUnitId,
type: SelectionMoveType.ONLY_SET,
selections: [{ range: originRange, primary, style: null }],
},
});
}
return {
undos: [
{ id: SetRangeValuesMutation.id, params: clearStyleMutationParams },
{ id: SetRangeValuesMutation.id, params: undoSetRangeValuesMutationParams },
...interceptorUndos,
...mergeUndos,
],
redos: [
{ id: SetRangeValuesMutation.id, params: clearStyleMutationParams },
{ id: SetRangeValuesMutation.id, params: setRangeValuesMutationParams },
...interceptorRedos,
...mergeRedos,
],
};
}
}

Some files were not shown because too many files have changed in this diff Show More