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

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

View File

@@ -0,0 +1,41 @@
# @univerjs/sheets-drawing-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing-ui)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing-ui)
`@univerjs/sheets-drawing-ui` adds the UI for creating, selecting, editing, and exporting drawing objects in Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-drawing-ui` | `UniverSheetsDrawingUi` | Yes | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-drawing-ui
# or
npm install @univerjs/sheets-drawing-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/sheets-drawing-ui/lib/index.css';
import EnUS from '@univerjs/sheets-drawing-ui/locale/en-US';
import { UniverSheetsDrawingUIPlugin } from '@univerjs/sheets-drawing-ui';
univer.registerPlugin(UniverSheetsDrawingUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-drawing-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,110 @@
{
"name": "@univerjs/sheets-drawing-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Drawing UI integration 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",
"drawing",
"ui",
"plugin"
],
"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-drawing": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/drawing-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-drawing": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "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,188 @@
/**
* 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, IDisposable, IWorkbookData, Workbook } from '@univerjs/core';
import {
BooleanNumber,
Disposable,
ICommandService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
toDisposable,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsDrawingPlugin } from '@univerjs/sheets-drawing';
import enUS from '@univerjs/sheets/locale/en-US';
import { ISidebarService, UndoRedoGroupService } from '@univerjs/ui';
import { BehaviorSubject, Subject } from 'rxjs';
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { v: 'A1' },
},
},
hidden: BooleanNumber.FALSE,
},
},
};
class TestSidebarService extends Disposable {
private _sidebarOptions = { visible: false } as any;
readonly sidebarOptions$ = new BehaviorSubject(this._sidebarOptions);
readonly scrollEvent$ = new Subject<Event>();
private _container?: HTMLElement;
private _width?: number;
get visible(): boolean {
return Boolean(this._sidebarOptions.visible);
}
get options() {
return this._sidebarOptions;
}
get width(): number | undefined {
return this._width;
}
setWidth(value: number): void {
this._width = value;
}
open(params: any): IDisposable {
this._sidebarOptions = {
...params,
visible: true,
};
this.sidebarOptions$.next(this._sidebarOptions);
params.onOpen?.();
return toDisposable(() => this.close());
}
close(id?: string): void {
if (id && this._sidebarOptions.id !== id) {
return;
}
this._sidebarOptions = {
...this._sidebarOptions,
visible: false,
};
this.sidebarOptions$.next(this._sidebarOptions);
this._sidebarOptions.onClose?.();
}
getContainer(): HTMLElement | undefined {
return this._container;
}
setContainer(element?: HTMLElement): void {
this._container = element;
}
}
function hasDependency(dependencies: Dependency[] | undefined, token: unknown): boolean {
if (!dependencies) {
return false;
}
for (const dependency of dependencies) {
if (Array.isArray(dependency) && dependency[0] === token) {
return true;
}
}
return false;
}
export function createSheetsDrawingUiTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'sheets-drawing-ui-test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
if (!hasDependency(dependencies, IRenderManagerService)) {
this._injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
}
this._injector.add([ISidebarService, { useClass: TestSidebarService as never }]);
this._injector.add([UndoRedoGroupService]);
dependencies?.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverDrawingPlugin);
univer.registerPlugin(UniverSheetsDrawingPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
Tools.deepClone(workbookData ?? TEST_WORKBOOK_DATA)
);
get(IUniverInstanceService).focusUnit(workbook.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
const localeService = get(LocaleService);
localeService.load({ enUS });
localeService.setLocale(LocaleType.EN_US);
return {
univer,
injector,
get,
workbook,
commandService: get(ICommandService),
sidebarService: get(ISidebarService) as unknown as TestSidebarService,
unitId: 'test',
subUnitId: 'sheet1',
};
}

View File

@@ -0,0 +1,106 @@
/**
* 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 { ISheetDrawing } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum, ImageSourceType } from '@univerjs/core';
import { InsertSheetDrawingCommand, ISheetDrawingService } from '@univerjs/sheets-drawing';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { DeleteDrawingsCommand } from '../delete-drawings.command';
function createSheetDrawing(drawingId: string): ISheetDrawing {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: `https://example.com/${drawingId}.png`,
sheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}
describe('DeleteDrawingsCommand', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('removes the focused drawing from the real sheet drawing model', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(DeleteDrawingsCommand);
await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [createSheetDrawing('drawing-1')],
});
sheetDrawingService.focusDrawing([{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
}]);
expect(await testBed.commandService.executeCommand(DeleteDrawingsCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
})).toBeUndefined();
expect(sheetDrawingService.getDrawingOrder(testBed.unitId, testBed.subUnitId)).toEqual([]);
testBed.univer.dispose();
});
it('keeps the model unchanged when there is no focused drawing', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(DeleteDrawingsCommand);
await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [createSheetDrawing('drawing-2')],
});
expect(await testBed.commandService.executeCommand(DeleteDrawingsCommand.id)).toBe(false);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-2',
})).toMatchObject({
drawingId: 'drawing-2',
});
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,327 @@
/**
* 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 { ISheetDrawing } from '@univerjs/sheets-drawing';
import { Direction, DrawingTypeEnum, ImageSourceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { InsertSheetDrawingCommand, ISheetDrawingService } from '@univerjs/sheets-drawing';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { FlipSheetDrawingCommand } from '../flip-drawings.command';
import { GroupSheetDrawingCommand } from '../group-sheet-drawing.command';
import { MoveDrawingsCommand } from '../move-drawings.command';
import { UngroupSheetDrawingCommand } from '../ungroup-sheet-drawing.command';
function createSheetDrawing(drawingId: string, left: number, top: number): ISheetDrawing {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: `https://example.com/${drawingId}.png`,
transform: {
left,
top,
width: 100,
height: 80,
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
},
sheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}
async function insertDrawings(testBed: ReturnType<typeof createSheetsDrawingUiTestBed>, drawings: ISheetDrawing[]) {
await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings,
});
}
class TestRenderManagerService {
private _srcRect: { left?: number; top?: number; right?: number; bottom?: number } | undefined;
setImageSrcRect(srcRect: { left?: number; top?: number; right?: number; bottom?: number }): void {
this._srcRect = srcRect;
}
getRenderUnitById() {
return {
scene: {
getObject: () => ({
srcRect: this._srcRect,
}),
},
};
}
}
describe('sheet drawing UI commands', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('moves focused drawings by one pixel in the selected direction', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(MoveDrawingsCommand);
await insertDrawings(testBed, [createSheetDrawing('drawing-1', 10, 20)]);
sheetDrawingService.focusDrawing([{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
}]);
expect(await testBed.commandService.executeCommand(MoveDrawingsCommand.id, { direction: Direction.RIGHT })).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
})?.transform).toMatchObject({
left: 11,
top: 20,
});
testBed.univer.dispose();
});
it('keeps drawing positions unchanged when move is triggered without a focused drawing', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(MoveDrawingsCommand);
await insertDrawings(testBed, [createSheetDrawing('drawing-2', 30, 40)]);
expect(await testBed.commandService.executeCommand(MoveDrawingsCommand.id, { direction: Direction.UP })).toBe(false);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-2',
})?.transform).toMatchObject({
left: 30,
top: 40,
});
testBed.univer.dispose();
});
it('returns false when flip is triggered without command parameters', async () => {
const testBed = createSheetsDrawingUiTestBed();
testBed.commandService.registerCommand(FlipSheetDrawingCommand);
expect(await testBed.commandService.executeCommand(FlipSheetDrawingCommand.id)).toBe(false);
testBed.univer.dispose();
});
it('skips drawings that are missing from the drawing model or lack a sheet skeleton', async () => {
const testBed = createSheetsDrawingUiTestBed();
testBed.commandService.registerCommand(FlipSheetDrawingCommand);
await insertDrawings(testBed, [
{
...createSheetDrawing('detached-drawing', 5, 6),
unitId: 'detached-unit',
},
]);
expect(await testBed.commandService.executeCommand(FlipSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [
{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'missing-drawing',
},
{
unitId: 'detached-unit',
subUnitId: testBed.subUnitId,
drawingId: 'detached-drawing',
},
],
flipH: true,
})).toBe(false);
testBed.univer.dispose();
});
it('flips a drawing horizontally and vertically in the sheet drawing model', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(FlipSheetDrawingCommand);
await insertDrawings(testBed, [createSheetDrawing('drawing-3', 50, 60)]);
expect(await testBed.commandService.executeCommand(FlipSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-3',
}],
flipH: true,
flipV: true,
})).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-3',
})?.transform).toMatchObject({
flipX: true,
flipY: true,
});
testBed.univer.dispose();
});
it('updates the image crop rectangle when flipping a cropped drawing', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const renderManagerService = testBed.get(IRenderManagerService) as unknown as TestRenderManagerService;
const sheetDrawingService = testBed.get(ISheetDrawingService);
renderManagerService.setImageSrcRect({ right: 24, bottom: 36 });
testBed.commandService.registerCommand(FlipSheetDrawingCommand);
await insertDrawings(testBed, [createSheetDrawing('cropped-drawing', 50, 60)]);
expect(await testBed.commandService.executeCommand(FlipSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'cropped-drawing',
}],
flipH: true,
flipV: true,
})).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'cropped-drawing',
})).toMatchObject({
srcRect: {
left: 0,
top: 0,
right: 24,
bottom: 36,
},
transform: {
flipX: true,
flipY: true,
},
});
testBed.univer.dispose();
});
it('groups two drawings and then ungroups them back to standalone drawings', async () => {
const testBed = createSheetsDrawingUiTestBed();
const sheetDrawingService = testBed.get(ISheetDrawingService);
testBed.commandService.registerCommand(GroupSheetDrawingCommand);
testBed.commandService.registerCommand(UngroupSheetDrawingCommand);
await insertDrawings(testBed, [
createSheetDrawing('child-1', 10, 20),
createSheetDrawing('child-2', 40, 60),
]);
const parent = {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0, width: 140, height: 120 },
groupBaseBound: { left: 0, top: 0, width: 140, height: 120 },
};
const groupedChildren = [
{
...createSheetDrawing('child-1', 10, 20),
groupId: 'group-1',
},
{
...createSheetDrawing('child-2', 40, 60),
groupId: 'group-1',
},
];
expect(await testBed.commandService.executeCommand(GroupSheetDrawingCommand.id, [{
parent,
children: groupedChildren,
}])).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'group-1',
})).toMatchObject({
drawingType: DrawingTypeEnum.DRAWING_GROUP,
});
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'child-1',
})).toMatchObject({
groupId: 'group-1',
});
const ungroupedChildren = [
{
...createSheetDrawing('child-1', 10, 20),
groupId: undefined,
},
{
...createSheetDrawing('child-2', 40, 60),
groupId: undefined,
},
];
expect(await testBed.commandService.executeCommand(UngroupSheetDrawingCommand.id, [{
parent,
children: ungroupedChildren,
}])).toBe(true);
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'group-1',
})).toBeUndefined();
expect(sheetDrawingService.getDrawingByParam({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'child-1',
})?.groupId).toBeUndefined();
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,165 @@
/**
* 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 { Inject } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { SheetDrawingUpdateController } from '../../../controllers/sheet-drawing-update.controller';
import { InsertCellImageCommand, InsertFloatImageCommand } from '../insert-image.command';
class TestSheetDrawingUpdateController {
private _floatImages: string[] = [];
private _cellImageCount = 0;
private _filePickerCount = 0;
private _failedFileNames = new Set<string>();
failFile(name: string): void {
this._failedFileNames.add(name);
}
get floatImages(): string[] {
return this._floatImages;
}
get cellImageCount(): number {
return this._cellImageCount;
}
get filePickerCount(): number {
return this._filePickerCount;
}
async insertFloatImage(): Promise<boolean> {
this._filePickerCount += 1;
this._floatImages.push('picked-file');
return true;
}
async insertFloatImageByFile(file: File): Promise<boolean> {
if (this._failedFileNames.has(file.name)) {
return false;
}
this._floatImages.push(file.name);
return true;
}
async insertCellImage(): Promise<boolean> {
this._cellImageCount += 1;
return true;
}
}
class TestRenderManagerService {
constructor(
@Inject(TestSheetDrawingUpdateController) private readonly _controller: TestSheetDrawingUpdateController
) {}
get controller(): TestSheetDrawingUpdateController {
return this._controller;
}
getRenderUnitById() {
return {
with: (token: unknown) => {
if (token === SheetDrawingUpdateController) {
return this._controller;
}
return null;
},
};
}
}
function createImageFile(name: string): File {
return new File(['image'], name, { type: 'image/png' });
}
describe('insert image commands', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('inserts every selected file as a floating image', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[TestSheetDrawingUpdateController],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const renderManagerService = testBed.get(IRenderManagerService) as unknown as TestRenderManagerService;
testBed.commandService.registerCommand(InsertFloatImageCommand);
expect(await testBed.commandService.executeCommand(InsertFloatImageCommand.id, {
files: [
createImageFile('first.png'),
createImageFile('second.png'),
],
})).toBe(true);
expect(renderManagerService.controller.floatImages).toEqual(['first.png', 'second.png']);
testBed.univer.dispose();
});
it('returns false when one selected floating image fails to insert', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[TestSheetDrawingUpdateController],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const renderManagerService = testBed.get(IRenderManagerService) as unknown as TestRenderManagerService;
renderManagerService.controller.failFile('broken.png');
testBed.commandService.registerCommand(InsertFloatImageCommand);
expect(await testBed.commandService.executeCommand(InsertFloatImageCommand.id, {
files: [
createImageFile('ok.png'),
createImageFile('broken.png'),
],
})).toBe(false);
expect(renderManagerService.controller.floatImages).toEqual(['ok.png']);
testBed.univer.dispose();
});
it('opens the floating image picker when no files are provided', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[TestSheetDrawingUpdateController],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const renderManagerService = testBed.get(IRenderManagerService) as unknown as TestRenderManagerService;
testBed.commandService.registerCommand(InsertFloatImageCommand);
expect(await testBed.commandService.executeCommand(InsertFloatImageCommand.id, { files: null })).toBe(true);
expect(renderManagerService.controller.filePickerCount).toBe(1);
expect(renderManagerService.controller.floatImages).toEqual(['picked-file']);
testBed.univer.dispose();
});
it('inserts an image into the selected cell', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[TestSheetDrawingUpdateController],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const renderManagerService = testBed.get(IRenderManagerService) as unknown as TestRenderManagerService;
testBed.commandService.registerCommand(InsertCellImageCommand);
expect(await testBed.commandService.executeCommand(InsertCellImageCommand.id)).toBe(true);
expect(renderManagerService.controller.cellImageCount).toBe(1);
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,227 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IRange } from '@univerjs/core';
import type { IBatchSaveImagesConfig, ICellImageInfo } from '../../../services/batch-save-images.service';
import { Disposable, ImageSourceType, toDisposable } from '@univerjs/core';
import { IDialogService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { IBatchSaveImagesService } from '../../../services/batch-save-images.service';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from '../../../views/batch-save-images/component-name';
import { SaveCellImagesCommand } from '../save-cell-images.command';
class TestDialogService extends Disposable implements IDialogService {
private _dialogs: any[] = [];
private readonly _dialogs$ = new BehaviorSubject<any[]>([]);
open(params: any): IDisposable {
this._dialogs.push(params);
this._dialogs$.next(this._dialogs);
return toDisposable(() => this.close(params.id));
}
close(id: string): void {
const dialogs = [];
for (const dialog of this._dialogs) {
if (dialog.id !== id) {
dialogs.push(dialog);
}
}
this._dialogs = dialogs;
this._dialogs$.next(this._dialogs);
}
closeAll(expectIds?: string[]): void {
if (!expectIds) {
this._dialogs = [];
this._dialogs$.next([]);
return;
}
const dialogs = [];
for (const dialog of this._dialogs) {
if (expectIds.includes(dialog.id)) {
dialogs.push(dialog);
}
}
this._dialogs = dialogs;
this._dialogs$.next(this._dialogs);
}
getDialogs$() {
return this._dialogs$.asObservable();
}
get dialogs() {
return this._dialogs;
}
override dispose(): void {
super.dispose();
this._dialogs$.complete();
this._dialogs = [];
}
}
class TestBatchSaveImagesService implements IBatchSaveImagesService {
private _images: ICellImageInfo[] = [];
private _downloaded: ICellImageInfo[] = [];
private _downloadFails = false;
setImages(images: ICellImageInfo[]): void {
this._images = images;
}
failDownload(): void {
this._downloadFails = true;
}
get downloaded(): ICellImageInfo[] {
return this._downloaded;
}
getCellImagesInSelection(): ICellImageInfo[] {
return this._images;
}
getCellImagesFromRanges(_unitId: string, _subUnitId: string, _ranges: IRange[]): ICellImageInfo[] {
return this._images;
}
getDataColumns(): Array<{ index: number; label: string }> {
return [];
}
getDataColumnsForRanges(): Array<{ index: number; label: string }> {
return [];
}
getSelectionRangeNotation(): string {
return 'A1:B2';
}
generateFileName(imageInfo: ICellImageInfo): string {
return imageInfo.cellAddress;
}
generateFileNameWithContext(imageInfo: ICellImageInfo): string {
return imageInfo.cellAddress;
}
async saveImages(_images: ICellImageInfo[], _config: IBatchSaveImagesConfig): Promise<void> {
return undefined;
}
async saveImagesWithContext(_images: ICellImageInfo[], _config: IBatchSaveImagesConfig): Promise<void> {
return undefined;
}
async downloadSingleImage(imageInfo: ICellImageInfo): Promise<void> {
if (this._downloadFails) {
throw new Error('download failed');
}
this._downloaded.push(imageInfo);
}
getSelectionRowRange(): { startRow: number; endRow: number } | null {
return { startRow: 0, endRow: 1 };
}
getSelectionColumnIndices(): Set<number> {
return new Set([0, 1]);
}
}
function createImage(imageId: string, cellAddress: string): ICellImageInfo {
return {
row: 0,
col: 0,
cellAddress,
imageId,
source: `https://example.com/${imageId}.png`,
imageSourceType: ImageSourceType.URL,
};
}
describe('SaveCellImagesCommand', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('downloads a single selected cell image directly', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[IDialogService, { useClass: TestDialogService }],
[IBatchSaveImagesService, { useClass: TestBatchSaveImagesService }],
]);
const batchSaveService = testBed.get(IBatchSaveImagesService) as TestBatchSaveImagesService;
const dialogService = testBed.get(IDialogService) as unknown as TestDialogService;
batchSaveService.setImages([createImage('image-1', 'A1')]);
testBed.commandService.registerCommand(SaveCellImagesCommand);
expect(await testBed.commandService.executeCommand(SaveCellImagesCommand.id)).toBe(true);
expect(batchSaveService.downloaded).toEqual([expect.objectContaining({
imageId: 'image-1',
cellAddress: 'A1',
})]);
expect(dialogService.dialogs).toEqual([]);
testBed.univer.dispose();
});
it('opens the batch save dialog for multiple selected images', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[IDialogService, { useClass: TestDialogService }],
[IBatchSaveImagesService, { useClass: TestBatchSaveImagesService }],
]);
const batchSaveService = testBed.get(IBatchSaveImagesService) as TestBatchSaveImagesService;
const dialogService = testBed.get(IDialogService) as unknown as TestDialogService;
batchSaveService.setImages([
createImage('image-2', 'A1'),
createImage('image-3', 'B2'),
]);
testBed.commandService.registerCommand(SaveCellImagesCommand);
expect(await testBed.commandService.executeCommand(SaveCellImagesCommand.id)).toBe(true);
expect(dialogService.dialogs).toEqual([expect.objectContaining({
id: BATCH_SAVE_IMAGES_DIALOG_ID,
title: { title: 'sheets-drawing-ui.save.title (A1:B2)' },
children: { label: BATCH_SAVE_IMAGES_DIALOG_ID },
})]);
dialogService.dialogs[0].onClose();
expect(dialogService.dialogs).toEqual([]);
testBed.univer.dispose();
});
it('returns false when the single image download fails', async () => {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[IDialogService, { useClass: TestDialogService }],
[IBatchSaveImagesService, { useClass: TestBatchSaveImagesService }],
]);
const batchSaveService = testBed.get(IBatchSaveImagesService) as TestBatchSaveImagesService;
batchSaveService.setImages([createImage('image-4', 'C3')]);
batchSaveService.failDownload();
testBed.commandService.registerCommand(SaveCellImagesCommand);
expect(await testBed.commandService.executeCommand(SaveCellImagesCommand.id)).toBe(false);
expect(batchSaveService.downloaded).toEqual([]);
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,178 @@
/**
* 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 { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { cloneGroupParams, groupToUngroup, ungroupToGroup } from '../utils';
describe('sheets drawing command utils', () => {
it('restores grouped sheet drawings with bounds calculated from child transforms', () => {
const grouped = ungroupToGroup([
{
parent: {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
groupBaseBound: { left: 1, top: 2, width: 3, height: 4 },
},
children: [
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'child-1',
transform: { left: 2, top: 3, width: 10, height: 20 },
},
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'child-2',
transform: { left: 20, top: 5, width: 5, height: 5 },
},
],
},
] as never);
expect(grouped).toHaveLength(1);
expect(grouped[0].parent).toMatchObject({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
groupBaseBound: { left: 1, top: 2, width: 3, height: 4 },
transform: {
left: 2,
top: 3,
width: 23,
height: 20,
},
});
expect(grouped[0].children[0]).toMatchObject({
drawingId: 'child-1',
groupId: 'group-1',
transform: { left: 2, top: 3, width: 10, height: 20 },
});
expect(grouped[0].children[1]).toMatchObject({
drawingId: 'child-2',
groupId: 'group-1',
});
});
it('ungroups sheet drawings back into standalone transforms in the parent coordinate space', () => {
const ungrouped = groupToUngroup([
{
parent: {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
transform: { left: 30, top: 40, width: 80, height: 60 },
groupBaseBound: { left: 0, top: 0, width: 80, height: 60 },
},
children: [
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'child-1',
transform: { left: 5, top: 6, width: 7, height: 8 },
},
],
},
] as never);
expect(ungrouped).toHaveLength(1);
expect(ungrouped[0].parent).toEqual({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0 },
});
expect(ungrouped[0].children[0]).toMatchObject({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'child-1',
transform: {
left: 35,
top: 46,
width: 7,
height: 8,
},
groupId: undefined,
});
});
it('clones nested group ids so pasted groups do not share original drawing ids', () => {
const result = cloneGroupParams({
nestedIdRecord: {
'group-root': {
drawingId: 'group-root',
children: ['group-child', 'shape-1'],
},
'group-child': {
drawingId: 'group-child',
children: ['shape-2'],
},
},
groups: [
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-root',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
},
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'group-child',
groupId: 'group-root',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
},
],
flatChildren: [
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-1',
groupId: 'group-root',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
},
{
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-2',
groupId: 'group-child',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
},
],
});
const clonedRootId = result.idMap.get('group-root');
const clonedChildGroupId = result.idMap.get('group-child');
const clonedShapeId = result.idMap.get('shape-1');
expect(clonedRootId).toBeDefined();
expect(clonedRootId).not.toBe('group-root');
expect(clonedChildGroupId).toBeDefined();
expect(clonedShapeId).toBeDefined();
const flatChildren = result.cloned.flatChildren!;
expect(result.cloned.groups).toHaveLength(2);
expect(flatChildren).toHaveLength(2);
expect(result.cloned.groups[1].groupId).toBe(clonedRootId);
expect(flatChildren[0].drawingId).toBe(clonedShapeId);
expect(flatChildren[0].groupId).toBe(clonedRootId);
expect(result.cloned.nestedIdRecord[clonedRootId!].children).toContain(clonedChildGroupId);
expect(result.cloned.nestedIdRecord[clonedRootId!].children).toContain(clonedShapeId);
});
});

View File

@@ -0,0 +1,52 @@
/**
* 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 { IRemoveSheetDrawingCommandParams, ISheetDrawing } from '@univerjs/sheets-drawing';
import { CommandType, ICommandService } from '@univerjs/core';
import { ISheetDrawingService, RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing';
export const DeleteDrawingsCommand: ICommand = {
id: 'sheet.command.delete-drawing',
type: CommandType.COMMAND,
handler: (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const drawingManagerService = accessor.get(ISheetDrawingService);
const drawings = drawingManagerService.getFocusDrawings();
if (drawings.length === 0) {
return false;
}
const unitId = drawings[0].unitId;
const newDrawings = drawings.map((drawing) => {
const { unitId, subUnitId, drawingId, drawingType } = drawing as ISheetDrawing;
return {
unitId,
subUnitId,
drawingId,
drawingType,
};
});
return commandService.executeCommand<IRemoveSheetDrawingCommandParams>(RemoveSheetDrawingCommand.id, {
unitId,
drawings: newDrawings,
});
},
};

View File

@@ -0,0 +1,179 @@
/**
* 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, ITransformState } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { Image } from '@univerjs/engine-render';
import type { ISheetDrawingPosition } from '@univerjs/sheets-drawing';
import {
CommandType,
DrawingTypeEnum,
ICommandService,
IUndoRedoService,
sequenceExecute,
} from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetSkeletonService } from '@univerjs/sheets';
import { ClearSheetDrawingTransformerOperation, DrawingApplyType, ISheetDrawingService, SetDrawingApplyMutation, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing';
interface IFlipDrawingCommandParam {
unitId: string;
subUnitId: string;
drawingId: string;
}
export interface IFlipSheetDrawingCommandParams {
unitId: string;
drawings: IFlipDrawingCommandParam[];
flipH?: boolean;
flipV?: boolean;
}
/**
* The command to flip sheet drawing elements
*/
export const FlipSheetDrawingCommand: ICommand = {
id: 'sheet.command.toggle-flip-drawings',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (accessor: IAccessor, params?: IFlipSheetDrawingCommandParams) => {
if (!params) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
const sheetSkeletonService = accessor.get(SheetSkeletonService);
const { drawings } = params;
const flipH = params.flipH;
const flipV = params.flipV;
const unitIds: string[] = [];
const updateParams: any[] = [];
for (const param of drawings) {
const { unitId, subUnitId, drawingId } = param;
unitIds.push(unitId);
const drawingData = sheetDrawingService.getDrawingData(unitId, subUnitId);
const existing = drawingData?.[drawingId];
if (!existing) {
continue;
}
const skeleton = sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!skeleton) {
continue;
}
const transform = { ...existing.transform } as ITransformState;
if (flipH) {
transform.flipX = !transform.flipX;
}
if (flipV) {
transform.flipY = !transform.flipY;
}
const sheetTransform = transformToDrawingPosition(transform, skeleton);
const axisAlignSheetTransform = transformToAxisAlignPosition(transform, skeleton) as ISheetDrawingPosition;
const updateParamItem: any = {
unitId,
subUnitId,
drawingType: existing.drawingType,
drawingId,
transform,
sheetTransform,
axisAlignSheetTransform,
};
const drawingType = existing.drawingType;
if (drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
const scene = getSceneByDrawingSearch(accessor, unitId);
if (scene) {
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(drawingShapeKey) as Image;
if (imageShape) {
const srcRect = imageShape.srcRect;
if (srcRect) {
let newSrcRect;
const { left = 0, top = 0, right = 0, bottom = 0 } = srcRect;
if (flipH) {
const centerX = left + (right - left) / 2;
const newLeft = centerX - (right - left) / 2;
const newRight = centerX + (right - left) / 2;
newSrcRect = {
left: newLeft,
top,
right: newRight,
bottom,
};
}
if (flipV) {
const centerY = top + (bottom - top) / 2;
const newTop = centerY - (bottom - top) / 2;
const newBottom = centerY + (bottom - top) / 2;
newSrcRect = {
left,
top: newTop,
right,
bottom: newBottom,
};
}
if (newSrcRect) {
updateParamItem.srcRect = newSrcRect;
}
}
}
}
}
updateParams.push(updateParamItem);
}
if (updateParams.length === 0) return false;
const jsonOp = sheetDrawingService.getBatchUpdateOp(updateParams) as IDrawingJsonUndo1;
const { unitId: opUnitId, subUnitId: opSubUnitId, undo, redo, objects } = jsonOp;
const updateMutation = { id: SetDrawingApplyMutation.id, params: { unitId: opUnitId, subUnitId: opSubUnitId, op: redo, objects, type: DrawingApplyType.UPDATE } };
const undoUpdateMutation = { id: SetDrawingApplyMutation.id, params: { unitId: opUnitId, subUnitId: opSubUnitId, op: undo, objects, type: DrawingApplyType.UPDATE } };
const result = sequenceExecute([updateMutation], commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: opUnitId,
undoMutations: [undoUpdateMutation, { id: ClearSheetDrawingTransformerOperation.id, params: unitIds }],
redoMutations: [updateMutation, { id: ClearSheetDrawingTransformerOperation.id, params: unitIds }],
});
return true;
}
return false;
},
};
function getSceneByDrawingSearch(accessor: IAccessor, unitId: string) {
const renderManagerService = accessor.get(IRenderManagerService);
const render = renderManagerService.getRenderUnitById(unitId);
if (!render) {
return null;
}
return render.scene;
}

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 } from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingJsonUndo1 } from '@univerjs/drawing';
import {
CommandType,
ICommandService,
IUndoRedoService,
} from '@univerjs/core';
import {
ClearSheetDrawingTransformerOperation,
DrawingApplyType,
ISheetDrawingService,
SetDrawingApplyMutation,
} from '@univerjs/sheets-drawing';
import { groupToUngroup } from './utils';
/**
* The command to insert new defined name
*/
export const GroupSheetDrawingCommand: ICommand = {
id: 'sheet.command.group-sheet-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
if (!params) return false;
const unitIds: string[] = [];
params.forEach(({ parent, children }) => {
unitIds.push(parent.unitId);
children.forEach((child) => {
unitIds.push(child.unitId);
});
});
// execute do mutations and add undo mutations to undo stack if completed
const jsonOp = sheetDrawingService.getGroupDrawingOp(params) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
const result = commandService.syncExecuteCommand(SetDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.GROUP });
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: groupToUngroup(objects as IDrawingGroupUpdateParam[]), type: DrawingApplyType.UNGROUP } },
{ id: ClearSheetDrawingTransformerOperation.id, params: unitIds },
],
redoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.GROUP } },
{ id: ClearSheetDrawingTransformerOperation.id, params: unitIds },
],
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,69 @@
/**
* 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, Nullable } from '@univerjs/core';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { SheetDrawingUpdateController } from '../../controllers/sheet-drawing-update.controller';
export interface IInsertImageCommandParams {
files: Nullable<File[]>;
};
export const InsertFloatImageCommand: ICommand<IInsertImageCommandParams> = {
id: 'sheet.command.insert-float-image',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const sheetDrawingUpdateController = getCurrentTypeOfRenderer(
UniverInstanceType.UNIVER_SHEET,
univerInstanceService,
renderManagerService
)
?.with(SheetDrawingUpdateController);
if (!sheetDrawingUpdateController) {
return false;
}
const files = params?.files;
if (files) {
const awaitFiles = files.map((file) => sheetDrawingUpdateController.insertFloatImageByFile(file));
return (await Promise.all(awaitFiles)).every((result) => result);
} else {
return sheetDrawingUpdateController.insertFloatImage() ?? false;
}
},
};
export const InsertCellImageCommand: ICommand = {
id: 'sheet.command.insert-cell-image',
type: CommandType.COMMAND,
handler: (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
return getCurrentTypeOfRenderer(
UniverInstanceType.UNIVER_SHEET,
univerInstanceService,
renderManagerService
)
?.with(SheetDrawingUpdateController)
.insertCellImage() ?? false;
},
};

View File

@@ -0,0 +1,91 @@
/**
* 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 { ISetDrawingCommandParams, ISheetDrawing } from '@univerjs/sheets-drawing';
import { CommandType, Direction, ICommandService } from '@univerjs/core';
import { SheetSkeletonService } from '@univerjs/sheets';
import { ClearSheetDrawingTransformerOperation, ISheetDrawingService, SetSheetDrawingCommand, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing';
import { UndoRedoGroupService } from '@univerjs/ui';
export interface IMoveDrawingsCommandParams {
direction: Direction;
}
export const MoveDrawingsCommand: ICommand = {
id: 'sheet.command.move-drawing',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params: IMoveDrawingsCommandParams) => {
const commandService = accessor.get(ICommandService);
const drawingManagerService = accessor.get(ISheetDrawingService);
const sheetSkeletonService = accessor.get(SheetSkeletonService);
const undoRedoGroupService = accessor.get(UndoRedoGroupService);
const { direction } = params;
const drawings = drawingManagerService.getFocusDrawings();
if (drawings.length === 0) {
return false;
}
const unitId = drawings[0].unitId;
const newDrawings = drawings.map((drawing) => {
const { transform, unitId, subUnitId } = drawing as ISheetDrawing;
const skeleton = sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!transform || !skeleton) {
return null;
}
const newTransform = { ...transform };
const { left = 0, top = 0 } = transform;
if (direction === Direction.UP) {
newTransform.top = top - 1;
} else if (direction === Direction.DOWN) {
newTransform.top = top + 1;
} else if (direction === Direction.LEFT) {
newTransform.left = left - 1;
} else if (direction === Direction.RIGHT) {
newTransform.left = left + 1;
}
return {
...drawing,
transform: newTransform,
sheetTransform: transformToDrawingPosition(newTransform, skeleton),
axisAlignSheetTransform: transformToAxisAlignPosition(newTransform, skeleton),
} as ISheetDrawing;
}).filter((drawing) => drawing != null) as ISheetDrawing[];
const result = undoRedoGroupService.runTimed(
unitId,
`sheet-drawing:nudge:${drawings[0].subUnitId}:${drawings.map((drawing) => drawing.drawingId).sort().join(',')}`,
() => commandService.syncExecuteCommand<ISetDrawingCommandParams>(SetSheetDrawingCommand.id, {
unitId,
drawings: newDrawings,
})
);
if (result) {
commandService.syncExecuteCommand(ClearSheetDrawingTransformerOperation.id, [unitId]);
return true;
}
return false;
},
};

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 { LocaleKey } from '../../locale/types';
import { CommandType, LocaleService } from '@univerjs/core';
import { IDialogService } from '@univerjs/ui';
import { IBatchSaveImagesService } from '../../services/batch-save-images.service';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from '../../views/batch-save-images/component-name';
export const SaveCellImagesCommand: ICommand = {
id: 'sheet.command.save-cell-images',
type: CommandType.COMMAND,
handler: async (accessor) => {
const dialogService = accessor.get(IDialogService);
const batchSaveService = accessor.get(IBatchSaveImagesService);
const images = batchSaveService.getCellImagesInSelection();
// If only one image, download directly without dialog
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Multiple images: open batch save dialog
const localeService = accessor.get(LocaleService);
const selectionRange = batchSaveService.getSelectionRangeNotation();
const titleText = `${localeService.t<LocaleKey>('sheets-drawing-ui.save.title')} (${selectionRange})`;
dialogService.open({
id: BATCH_SAVE_IMAGES_DIALOG_ID,
draggable: true,
width: 360,
title: { title: titleText },
children: {
label: BATCH_SAVE_IMAGES_DIALOG_ID,
},
preservePositionOnDestroy: true,
onClose: () => dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID),
});
return true;
},
};

View File

@@ -0,0 +1,73 @@
/**
* 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 { IDrawingGroupUpdateParam, IDrawingJsonUndo1 } from '@univerjs/drawing';
import {
CommandType,
ICommandService,
IUndoRedoService,
} from '@univerjs/core';
import { ClearSheetDrawingTransformerOperation, DrawingApplyType, ISheetDrawingService, SetDrawingApplyMutation } from '@univerjs/sheets-drawing';
import { ungroupToGroup } from './utils';
/**
* The command to insert new defined name
*/
export const UngroupSheetDrawingCommand: ICommand = {
id: 'sheet.command.ungroup-sheet-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
if (!params) return false;
const unitIds: string[] = [];
params.forEach(({ parent, children }) => {
unitIds.push(parent.unitId);
children.forEach((child) => {
unitIds.push(child.unitId);
});
});
// execute do mutations and add undo mutations to undo stack if completed
const jsonOp = sheetDrawingService.getUngroupDrawingOp(params) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
const result = commandService.syncExecuteCommand(SetDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.UNGROUP });
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: ungroupToGroup(objects as IDrawingGroupUpdateParam[]), type: DrawingApplyType.GROUP } },
{ id: ClearSheetDrawingTransformerOperation.id, params: unitIds },
],
redoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.UNGROUP } },
{ id: ClearSheetDrawingTransformerOperation.id, params: unitIds },
],
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,165 @@
/**
* 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 { IDrawingGroupNestedIds, IDrawingGroupNestedParam, IDrawingParam } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { DrawingTypeEnum, generateRandomId } from '@univerjs/core';
import { getGroupState, transformObjectOutOfGroup } from '@univerjs/engine-render';
export function ungroupToGroup(ungroupParams: IDrawingGroupUpdateParam[]) {
const newGroupParams: IDrawingGroupUpdateParam[] = [];
ungroupParams.forEach((ungroupParam) => {
const { parent, children } = ungroupParam;
const { unitId, subUnitId, drawingId: groupId } = parent;
const groupTransform = getGroupState(0, 0, children.map((o) => o.transform || {}));
const newChildren = children.map((drawing) => {
const transform = drawing.transform || { left: 0, top: 0 };
const { unitId, subUnitId, drawingId } = drawing;
return {
unitId,
subUnitId,
drawingId,
transform: {
...transform,
left: transform.left!,
top: transform.top!,
},
groupId,
};
}) as IDrawingParam[];
const groupParam = {
unitId,
subUnitId,
drawingId: groupId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
groupBaseBound: { ...parent.groupBaseBound },
transform: groupTransform,
} as IDrawingParam;
newGroupParams.push({
parent: groupParam,
children: newChildren,
});
});
return newGroupParams;
}
export function groupToUngroup(groupParams: IDrawingGroupUpdateParam[]) {
const newGroupParams: IDrawingGroupUpdateParam[] = [];
groupParams.forEach((groupParam) => {
const { parent, children } = groupParam;
const { unitId, subUnitId, drawingId: groupId, transform: groupTransform = { width: 0, height: 0 } } = parent;
if (groupTransform == null) {
return;
}
const newChildren = children.map((object) => {
const { transform } = object;
const { unitId, subUnitId, drawingId } = object;
const newTransform = transformObjectOutOfGroup(transform || {}, groupTransform, groupTransform.width || 0, groupTransform.height || 0, parent.groupBaseBound);
return {
unitId,
subUnitId,
drawingId,
transform: newTransform,
groupId: undefined,
};
}) as IDrawingParam[];
const ungroupParam = {
unitId,
subUnitId,
drawingId: groupId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left: 0,
top: 0,
},
} as IDrawingParam;
newGroupParams.push({
parent: ungroupParam,
children: newChildren,
});
});
return newGroupParams;
}
export interface ICloneGroupResult {
cloned: IDrawingGroupNestedParam;
/** Map from original drawingId → new drawingId, for updating connector/line relations */
idMap: Map<string, string>;
}
// only paste can use this function
export function cloneGroupParams(groupParams: IDrawingGroupNestedParam): ICloneGroupResult {
const idMap = new Map<string, string>();
groupParams.flatChildren?.forEach((p) => idMap.set(p.drawingId, generateRandomId(10)));
groupParams.groups.forEach((p) => idMap.set(p.drawingId, generateRandomId(10)));
const clonedNestedIdRecord: Record<string, IDrawingGroupNestedIds> = {};
for (const [oldGroupId, entry] of Object.entries(groupParams.nestedIdRecord)) {
const newGroupId = idMap.get(oldGroupId) ?? oldGroupId;
clonedNestedIdRecord[newGroupId] = {
drawingId: newGroupId,
children: entry.children?.map((id) => idMap.get(id) ?? id),
};
}
const flatChildren: IDrawingParam[] = [];
const groups: IDrawingParam[] = [];
for (const group of groupParams.groups) {
const groupDrawingId = idMap.get(group.drawingId) ?? group.drawingId;
const parentGroupId = group.groupId ? idMap.get(group.groupId) ?? group.groupId : undefined;
groups.push(cloneDrawingParam(group, groupDrawingId, parentGroupId));
}
for (const child of groupParams.flatChildren || []) {
const childDrawingId = idMap.get(child.drawingId) ?? child.drawingId;
const parentGroupId = child.groupId ? idMap.get(child.groupId) ?? child.groupId : undefined;
flatChildren.push(cloneDrawingParam(child, childDrawingId, parentGroupId));
}
return {
cloned: {
nestedIdRecord: clonedNestedIdRecord,
flatChildren,
groups,
},
idMap,
};
}
function cloneDrawingParam(param: IDrawingParam, newDrawingId: string, parentGroupId: string | undefined): IDrawingParam {
const newParam = { ...param };
if (newDrawingId) {
newParam.drawingId = newDrawingId;
}
if (parentGroupId) {
newParam.groupId = parentGroupId;
} else {
delete newParam.groupId;
}
return JSON.parse(JSON.stringify(newParam)) as IDrawingParam;
}

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 type { ISheetDrawing } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum, ImageSourceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { InsertSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { EditSheetDrawingOperation } from '../edit-sheet-drawing.operation';
import { SidebarSheetDrawingOperation } from '../open-drawing-panel.operation';
function createSheetDrawing(drawingId: string): ISheetDrawing {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: `https://example.com/${drawingId}.png`,
sheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}
describe('EditSheetDrawingOperation', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('keeps focus and sidebar unchanged when no drawing is provided', async () => {
const testBed = createSheetsDrawingUiTestBed();
const drawingManagerService = testBed.get(IDrawingManagerService);
testBed.commandService.registerCommand(EditSheetDrawingOperation);
testBed.commandService.registerCommand(SidebarSheetDrawingOperation);
expect(await testBed.commandService.executeCommand(EditSheetDrawingOperation.id, null as never)).toBe(false);
expect(drawingManagerService.getFocusDrawings()).toEqual([]);
expect(testBed.sidebarService.visible).toBe(false);
testBed.univer.dispose();
});
it('selects a drawing and opens the sheet drawing editor', async () => {
const testBed = createSheetsDrawingUiTestBed();
const drawingManagerService = testBed.get(IDrawingManagerService);
testBed.commandService.registerCommand(EditSheetDrawingOperation);
testBed.commandService.registerCommand(SidebarSheetDrawingOperation);
await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: testBed.unitId,
drawings: [createSheetDrawing('drawing-1')],
});
expect(await testBed.commandService.executeCommand(EditSheetDrawingOperation.id, {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
})).toBe(true);
expect(drawingManagerService.getFocusDrawings()).toEqual([expect.objectContaining({
drawingId: 'drawing-1',
})]);
expect(testBed.sidebarService.visible).toBe(true);
testBed.univer.dispose();
});
});

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 { DrawingTypeEnum } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../../../views/sheet-image-panel/component-name';
import { SidebarSheetDrawingOperation } from '../open-drawing-panel.operation';
describe('SidebarSheetDrawingOperation', () => {
afterEach(() => {
// each test disposes its own univer instance
});
it('opens the sheet drawing panel for the active worksheet', async () => {
const testBed = createSheetsDrawingUiTestBed();
testBed.commandService.registerCommand(SidebarSheetDrawingOperation);
expect(await testBed.commandService.executeCommand(SidebarSheetDrawingOperation.id, { value: 'open' })).toBe(true);
expect(testBed.sidebarService.visible).toBe(true);
expect(testBed.sidebarService.options).toMatchObject({
id: COMPONENT_SHEET_DRAWING_PANEL,
header: { title: 'sheets-drawing-ui.panel.title' },
children: { label: COMPONENT_SHEET_DRAWING_PANEL },
width: 360,
});
testBed.univer.dispose();
});
it('clears the focused drawing when the panel is closed', async () => {
const testBed = createSheetsDrawingUiTestBed();
const drawingManagerService = testBed.get(IDrawingManagerService);
testBed.commandService.registerCommand(SidebarSheetDrawingOperation);
drawingManagerService.registerDrawingData(testBed.unitId, {
[testBed.subUnitId]: {
data: {
'drawing-1': {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
},
},
order: ['drawing-1'],
},
});
drawingManagerService.focusDrawing([{
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
drawingId: 'drawing-1',
}]);
await testBed.commandService.executeCommand(SidebarSheetDrawingOperation.id, { value: 'open' });
testBed.sidebarService.close();
expect(drawingManagerService.getFocusDrawings()).toEqual([]);
testBed.univer.dispose();
});
it('closes the panel for non-open actions', async () => {
const testBed = createSheetsDrawingUiTestBed();
testBed.commandService.registerCommand(SidebarSheetDrawingOperation);
await testBed.commandService.executeCommand(SidebarSheetDrawingOperation.id, { value: 'open' });
expect(testBed.sidebarService.visible).toBe(true);
expect(await testBed.commandService.executeCommand(SidebarSheetDrawingOperation.id, { value: 'close' })).toBe(true);
expect(testBed.sidebarService.visible).toBe(false);
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, IDrawingSearch, IOperation } from '@univerjs/core';
import {
CommandType,
ICommandService,
} from '@univerjs/core';
import { SetDrawingSelectedOperation } from '@univerjs/drawing';
import { SidebarSheetDrawingOperation } from './open-drawing-panel.operation';
export const EditSheetDrawingOperation: IOperation<IDrawingSearch> = {
id: 'sheet.operation.edit-sheet-image',
type: CommandType.OPERATION,
handler: (accessor: IAccessor, params?: IDrawingSearch) => {
// const drawingManagerService = accessor.get(IDrawingManagerService);
const commandService = accessor.get(ICommandService);
if (params == null) {
return false;
}
commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [params]);
commandService.executeCommand(SidebarSheetDrawingOperation.id, { value: 'open' });
return true;
},
};

View File

@@ -0,0 +1,64 @@
/**
* 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, DrawingTypeEnum, ICommandService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ISidebarService } from '@univerjs/ui';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../../views/sheet-image-panel/component-name';
export interface IUIComponentCommandParams {
value: string;
}
export const SidebarSheetDrawingOperation: ICommand = {
id: 'sidebar.operation.sheet-image',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IUIComponentCommandParams) => {
const sidebarService = accessor.get(ISidebarService);
const localeService = accessor.get(LocaleService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const drawingManagerService = accessor.get(IDrawingManagerService);
const commandService = accessor.get(ICommandService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
switch (params.value) {
case 'open':
sidebarService.open({
id: COMPONENT_SHEET_DRAWING_PANEL,
header: { title: localeService.t<LocaleKey>('sheets-drawing-ui.panel.title') },
children: { label: COMPONENT_SHEET_DRAWING_PANEL },
onClose: () => {
const focusedDrawings = drawingManagerService.getFocusDrawings();
if (focusedDrawings.length === 1 && focusedDrawings[0].drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, []);
}
},
width: 360,
});
break;
case 'close':
default:
sidebarService.close(COMPONENT_SHEET_DRAWING_PANEL);
break;
}
return true;
},
};

View File

@@ -0,0 +1,29 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY = 'sheets-drawing-ui.config';
export const configSymbol = Symbol(SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsDrawingUIConfig {
menu?: MenuConfig;
overrides?: DependencyOverride;
}
export const defaultPluginConfig: IUniverSheetsDrawingUIConfig = {};

View File

@@ -0,0 +1,181 @@
/**
* 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 { DrawingTypeEnum } from '@univerjs/core';
import { ContextMenuPosition } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DrawingContextMenuService } from '../../services/drawing-context-menu.service';
import { DrawingContextMenuController } from '../drawing-context-menu.controller';
function createController(options?: {
selectedObjects?: Map<string, { oKey: string }>;
drawingLookup?: (oKey: string) => unknown;
scene?: any;
drawingContextMenuService?: DrawingContextMenuService;
}) {
const changeEnd$ = new Subject<any>();
const transformer = {
changeEnd$,
getSelectedObjectMap: vi.fn(() => options?.selectedObjects ?? new Map([['shape-1', { oKey: 'shape-1' }]])),
};
const scene = options?.scene ?? {
getTransformerByCreate: vi.fn(() => transformer),
};
const contextMenuService = {
triggerContextMenu: vi.fn(),
};
const drawingContextMenuService = options?.drawingContextMenuService ?? new DrawingContextMenuService();
const controller = new DrawingContextMenuController(
{ getDrawingOKey: vi.fn(options?.drawingLookup ?? (() => ({ unitId: 'u1' }))) } as any,
contextMenuService as any,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as any,
{ getAllUnitsForType: vi.fn(() => [{ getUnitId: () => 'unit-1' }]) } as any,
drawingContextMenuService
);
return { controller, changeEnd$, contextMenuService, drawingContextMenuService, transformer };
}
describe('DrawingContextMenuController', () => {
it('opens drawing context menu on right click when all selected objects belong to drawings', () => {
const { controller, changeEnd$, contextMenuService } = createController();
const event = { button: 2 };
changeEnd$.next({ event });
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
controller.dispose();
});
it('uses a drawing-type provider for a single selected drawing', () => {
const drawingContextMenuService = new DrawingContextMenuService();
const provider = {
getContextMenuPosition: vi.fn(() => 'chart-context-menu'),
};
drawingContextMenuService.registerProvider(DrawingTypeEnum.DRAWING_CHART, provider);
const drawing = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
};
const { controller, changeEnd$, contextMenuService } = createController({
drawingLookup: () => drawing,
drawingContextMenuService,
});
const event = { button: 2 };
changeEnd$.next({ event });
expect(provider.getContextMenuPosition).toHaveBeenCalledWith({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
drawing,
});
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, 'chart-context-menu');
controller.dispose();
});
it('falls back to the drawing menu when there is no provider', () => {
const { controller, changeEnd$, contextMenuService } = createController({
drawingLookup: () => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'shape-1',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
}),
});
const event = { button: 2 };
changeEnd$.next({ event });
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
controller.dispose();
});
it('keeps the drawing menu for multiple selected drawings', () => {
const drawingContextMenuService = new DrawingContextMenuService();
const provider = {
getContextMenuPosition: vi.fn(() => 'chart-context-menu'),
};
drawingContextMenuService.registerProvider(DrawingTypeEnum.DRAWING_CHART, provider);
const { controller, changeEnd$, contextMenuService } = createController({
selectedObjects: new Map([
['chart-1', { oKey: 'chart-1' }],
['chart-2', { oKey: 'chart-2' }],
]),
drawingLookup: (oKey) => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: oKey,
drawingType: DrawingTypeEnum.DRAWING_CHART,
}),
drawingContextMenuService,
});
const event = { button: 2 };
changeEnd$.next({ event });
expect(provider.getContextMenuPosition).not.toHaveBeenCalled();
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
controller.dispose();
});
it('keeps newer providers when an older provider is disposed', () => {
const drawingContextMenuService = new DrawingContextMenuService();
const firstProvider = {
getContextMenuPosition: vi.fn(() => 'first-menu'),
};
const secondProvider = {
getContextMenuPosition: vi.fn(() => 'second-menu'),
};
const firstDisposable = drawingContextMenuService.registerProvider(DrawingTypeEnum.DRAWING_CHART, firstProvider);
drawingContextMenuService.registerProvider(DrawingTypeEnum.DRAWING_CHART, secondProvider);
firstDisposable.dispose();
expect(drawingContextMenuService.getContextMenuPosition({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
drawing: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
},
})).toBe('second-menu');
});
it('ignores non-right-clicks, empty selections and non-drawing selected objects', () => {
const { controller, changeEnd$, contextMenuService, transformer } = createController({
selectedObjects: new Map(),
});
changeEnd$.next({ event: { button: 0 } });
changeEnd$.next({ event: { button: 2 } });
transformer.getSelectedObjectMap.mockReturnValue(new Map([['shape-1', { oKey: 'shape-1' }]]));
(controller as any)._drawingManagerService.getDrawingOKey = vi.fn(() => null);
changeEnd$.next({ event: { button: 2 } });
expect(contextMenuService.triggerContextMenu).not.toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,68 @@
/**
* 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, vi } from 'vitest';
import { SheetCellImageAutofillController } from '../sheet-cell-image-autofill.controller';
import { resizeImageByCell } from '../sheet-cell-image.controller';
vi.mock('../sheet-cell-image.controller', () => ({
resizeImageByCell: vi.fn(),
}));
describe('SheetCellImageAutofillController', () => {
it('registers an autofill hook that resizes every pasted cell image by its target cell', () => {
let hook: any;
const disposable = { dispose: vi.fn() };
const autoFillService = {
addHook: vi.fn((registeredHook) => {
hook = registeredHook;
return disposable;
}),
};
const injector = { id: 'injector' };
const controller = new SheetCellImageAutofillController(autoFillService as any, injector as any);
const location = { unitId: 'unit-1', subUnitId: 'sheet-1' };
const cellA = { p: { drawingsOrder: ['a'] } };
const cellB = { p: { drawingsOrder: ['b'] } };
hook.onBeforeSubmit(location, 'down', 'copy', {
2: {
3: cellA,
4: cellB,
},
});
expect(autoFillService.addHook).toHaveBeenCalledWith(expect.objectContaining({
id: 'sheet-cell-image-autofill',
onBeforeSubmit: expect.any(Function),
}));
expect(resizeImageByCell).toHaveBeenCalledWith(injector, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
}, cellA);
expect(resizeImageByCell).toHaveBeenCalledWith(injector, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 4,
}, cellB);
controller.dispose();
expect(disposable.dispose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,131 @@
/**
* 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 { DataStreamTreeTokenType, DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
import { INTERCEPTOR_POINT } from '@univerjs/sheets';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { resizeImageByCell, SheetCellImageController } from '../sheet-cell-image.controller';
const DRAWING_ID = 'drawing-1';
function createCellImage(dataStream: string) {
return {
p: {
body: { dataStream },
drawingsOrder: [DRAWING_ID],
drawings: {
[DRAWING_ID]: {
transform: { width: 20, height: 10, left: 1, top: 1 },
docTransform: {
size: { width: 20, height: 10 },
angle: 0,
positionH: { posOffset: 1 },
positionV: { posOffset: 1 },
},
},
},
documentStyle: { pageSize: { width: 20, height: 10 } },
},
};
}
function createInjector() {
const skeleton = {
getCellWithCoordByIndex: vi.fn(() => ({
mergeInfo: { startX: 0, endX: 100, startY: 0, endY: 50 },
})),
};
const skeletonManagerService = {
getSkeletonParam: vi.fn(() => ({ skeleton })),
};
const render = {
with: vi.fn(() => skeletonManagerService),
};
const renderManagerService = {
getRenderUnitById: vi.fn(() => render),
};
return { get: vi.fn(() => renderManagerService) } as never;
}
describe('SheetCellImageController', () => {
it.each([
DataStreamTreeTokenType.CUSTOM_BLOCK + DataStreamTreeTokenType.PARAGRAPH + DataStreamTreeTokenType.SECTION_BREAK,
DataStreamTreeTokenType.PARAGRAPH + DataStreamTreeTokenType.CUSTOM_BLOCK + DataStreamTreeTokenType.PARAGRAPH + DataStreamTreeTokenType.SECTION_BREAK,
])('resizes cell images for supported document structures', (dataStream) => {
const cell = createCellImage(dataStream);
expect(resizeImageByCell(createInjector(), {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 2,
}, cell as never)).toBe(true);
expect(cell.p.drawings[DRAWING_ID].transform).toEqual({ width: 96, height: 48, left: 0, top: 0 });
expect(cell.p.drawings[DRAWING_ID].docTransform.size).toEqual({ width: 96, height: 48 });
});
it('reloads editor drawing data when the embedded editor opens and clears it when closed', () => {
let interceptor: any;
const visible$ = new Subject<{ visible: boolean }>();
const drawingManagerService = {
removeDrawingDataForUnit: vi.fn(),
initializeNotification: vi.fn(),
};
const docDrawingController = { loadDrawingDataForUnit: vi.fn() };
const controller = new SheetCellImageController(
{
intercept: vi.fn((point, config) => {
expect(point).toBe(INTERCEPTOR_POINT.CELL_CONTENT);
interceptor = config;
return { dispose: vi.fn() };
}),
} as never,
{} as never,
drawingManagerService as never,
docDrawingController as never,
{ visible$ } as never
);
visible$.next({ visible: true });
expect(drawingManagerService.removeDrawingDataForUnit).toHaveBeenCalledWith(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
expect(docDrawingController.loadDrawingDataForUnit).toHaveBeenCalledWith(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
expect(drawingManagerService.initializeNotification).toHaveBeenCalledWith(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
visible$.next({ visible: false });
expect(drawingManagerService.removeDrawingDataForUnit).toHaveBeenCalledTimes(2);
const rawCell = {
p: {
body: { dataStream: 'image\r\n' },
drawingsOrder: ['drawing-1'],
},
};
const result = interceptor.handler(rawCell, {
rawData: rawCell,
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 2,
}, (cell: unknown) => cell);
expect(result).not.toBe(rawCell);
expect(result.interceptorStyle.tr).toEqual({ a: 0 });
controller.dispose();
});
});

View File

@@ -0,0 +1,438 @@
/**
* 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 } from '@univerjs/core';
import type { ISheetClipboardHook } from '@univerjs/sheets-ui';
import { DrawingTypeEnum, ICommandService, ImageSourceType, Injector, ObjectMatrix } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetSkeletonService } from '@univerjs/sheets';
import { RemoveSheetDrawingCommand, SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
import { COPY_TYPE, ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE } from '@univerjs/sheets-ui';
import { IClipboardInterfaceService } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { InsertFloatImageCommand } from '../../commands/commands/insert-image.command';
import { SheetsDrawingCopyPasteController } from '../sheet-drawing-copy-paste.controller';
function createSkeleton() {
return {
getNoMergeCellWithCoordByIndex: vi.fn((row: number, column: number) => ({
startX: column * 10,
endX: column * 10 + 10,
startY: row * 20,
endY: row * 20 + 20,
})),
getCellIndexAndOffsetByPosition: vi.fn((left: number, top: number) => {
const column = Math.floor(left / 10);
const row = Math.floor(top / 20);
return {
column,
row,
columnOffset: left - column * 10,
rowOffset: top - row * 20,
};
}),
};
}
function createImageDrawing(overrides: Record<string, unknown> = {}) {
return {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,AA==',
anchorType: SheetDrawingAnchorType.Both,
transform: { left: 5, top: 5, width: 10, height: 20 },
sheetTransform: {
from: { row: 0, column: 0, rowOffset: 5, columnOffset: 5 },
to: { row: 1, column: 1, rowOffset: 5, columnOffset: 5 },
},
axisAlignSheetTransform: {
from: { row: 0, column: 0, rowOffset: 5, columnOffset: 5 },
to: { row: 1, column: 1, rowOffset: 5, columnOffset: 5 },
},
...overrides,
};
}
type IImageDrawing = ReturnType<typeof createImageDrawing>;
interface IPrivateControllerAccess {
_copyInfo: {
unitId: string;
subUnitId: string;
copyRange?: IRange;
drawings: IImageDrawing[];
} | null;
}
interface ITestClipboardHook extends ISheetClipboardHook {
onBeforeCopy: NonNullable<ISheetClipboardHook['onBeforeCopy']>;
onBeforeCopyFocusedObject?: (unitId: string, subUnitId: string, copyType: COPY_TYPE) => boolean;
onPasteCells: NonNullable<ISheetClipboardHook['onPasteCells']>;
onPasteFiles: NonNullable<ISheetClipboardHook['onPasteFiles']>;
onPasteUnrecognized: NonNullable<ISheetClipboardHook['onPasteUnrecognized']>;
}
function createController(options?: { focusedDrawings?: IImageDrawing[]; drawingData?: Record<string, object> }) {
let hook: ISheetClipboardHook | undefined;
const skeleton = createSkeleton();
const drawingService = {
getFocusDrawings: vi.fn(() => options?.focusedDrawings ?? []),
getDrawingData: vi.fn(() => options?.drawingData ?? {}),
getBatchAddOp: vi.fn((drawings: IImageDrawing[]) => ({ undo: 'add-undo', redo: 'add-redo', objects: drawings })),
getBatchUpdateOp: vi.fn((drawings: IImageDrawing[]) => ({ undo: 'update-undo', redo: 'update-redo', objects: drawings })),
};
const commandService = {
executeCommand: vi.fn(),
};
const sheetClipboardService = {
addClipboardHook: vi.fn((config: ISheetClipboardHook) => {
hook = config;
return { dispose: vi.fn() };
}),
};
const injector = new Injector([
[ISheetClipboardService, { useValue: sheetClipboardService }],
[IRenderManagerService, { useValue: {} }],
[SheetSkeletonService, { useValue: { getSkeleton: vi.fn(() => skeleton) } }],
[IDrawingManagerService, { useValue: drawingService }],
[IClipboardInterfaceService, { useValue: { writeText: vi.fn() } }],
[ICommandService, { useValue: commandService }],
[SheetsDrawingCopyPasteController],
]);
const controller = injector.get(SheetsDrawingCopyPasteController);
return { controller, hook: hook as ITestClipboardHook, skeleton, drawingService, commandService };
}
describe('SheetsDrawingCopyPasteController', () => {
it('copies default position-anchored images contained in a cell range', () => {
const positionOnlyDrawing = createImageDrawing({
drawingId: 'position-only',
anchorType: undefined,
});
const { controller, hook, drawingService } = createController({
drawingData: {
[positionOnlyDrawing.drawingId]: positionOnlyDrawing,
},
});
hook.onBeforeCopy('unit-1', 'sheet-1', {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
}, COPY_TYPE.COPY);
hook.onPasteCells(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0, 1], cols: [0, 1] },
},
{
unitId: 'unit-2',
subUnitId: 'sheet-2',
range: { rows: [2, 3], cols: [3, 4] },
},
new ObjectMatrix(),
{ copyId: 'range-copy', copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(drawingService.getBatchAddOp).toHaveBeenCalledTimes(1);
expect(drawingService.getBatchAddOp.mock.calls[0][0]).toMatchObject([{
unitId: 'unit-2',
subUnitId: 'sheet-2',
transform: { left: 35, top: 45, width: 10, height: 20 },
}]);
controller.dispose();
});
it('copies drawings contained in a cell range and pastes them with the range offset', () => {
const containedDrawing = createImageDrawing();
const outsideDrawing = createImageDrawing({
drawingId: 'outside-image',
transform: { left: 80, top: 80, width: 10, height: 20 },
});
const positionOnlyDrawing = createImageDrawing({
drawingId: 'position-only',
anchorType: SheetDrawingAnchorType.Position,
transform: { left: 50, top: 50, width: 10, height: 20 },
});
const unanchoredDrawing = createImageDrawing({
drawingId: 'unanchored',
anchorType: SheetDrawingAnchorType.None,
});
const { controller, hook, drawingService } = createController({
focusedDrawings: [outsideDrawing],
drawingData: {
[containedDrawing.drawingId]: containedDrawing,
[outsideDrawing.drawingId]: outsideDrawing,
[positionOnlyDrawing.drawingId]: positionOnlyDrawing,
[unanchoredDrawing.drawingId]: unanchoredDrawing,
chart: { drawingId: 'chart', drawingType: DrawingTypeEnum.DRAWING_CHART },
},
});
hook.onBeforeCopy('unit-1', 'sheet-1', {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
}, COPY_TYPE.COPY);
const mutations = hook.onPasteCells(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0, 1], cols: [0, 1] },
},
{
unitId: 'unit-2',
subUnitId: 'sheet-2',
range: { rows: [2, 3], cols: [3, 4] },
},
new ObjectMatrix(),
{ copyId: 'range-copy', copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(drawingService.getBatchAddOp).toHaveBeenCalledTimes(1);
const pastedDrawing = drawingService.getBatchAddOp.mock.calls[0][0][0];
expect(pastedDrawing).toMatchObject({
unitId: 'unit-2',
subUnitId: 'sheet-2',
transform: { left: 35, top: 45, width: 10, height: 20 },
sheetTransform: {
from: { row: 2, column: 3, rowOffset: 5, columnOffset: 5 },
to: { row: 3, column: 4, rowOffset: 5, columnOffset: 5 },
},
});
expect(pastedDrawing.drawingId).not.toBe(containedDrawing.drawingId);
expect(mutations.redos).toEqual([
expect.objectContaining({ params: expect.objectContaining({ op: 'add-redo', objects: [pastedDrawing] }) }),
]);
controller.dispose();
});
it('cuts a focused image drawing and pastes it as a new drawing at the target range', () => {
const focusedDrawing = createImageDrawing({ drawingId: 'focused-image' });
const { controller, hook, drawingService, commandService } = createController({
focusedDrawings: [focusedDrawing],
});
expect(hook.onBeforeCopyFocusedObject).toBeTypeOf('function');
expect(hook.onBeforeCopyFocusedObject?.('unit-1', 'sheet-1', COPY_TYPE.CUT)).toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(RemoveSheetDrawingCommand.id, {
unitId: 'unit-1',
drawings: [focusedDrawing],
});
const mutations = hook.onPasteCells(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [3], cols: [4] },
},
new ObjectMatrix(),
{ copyId: 'image-cut', copyType: COPY_TYPE.CUT, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(drawingService.getBatchAddOp).toHaveBeenCalledTimes(1);
const pastedDrawing = drawingService.getBatchAddOp.mock.calls[0][0][0];
expect(pastedDrawing).toMatchObject({
unitId: 'unit-1',
subUnitId: 'sheet-1',
transform: { left: 40, top: 60, width: 10, height: 20 },
});
expect(pastedDrawing.drawingId).not.toBe(focusedDrawing.drawingId);
expect(mutations.redos).toEqual([
expect.objectContaining({ params: expect.objectContaining({ op: 'add-redo', objects: [pastedDrawing] }) }),
]);
controller.dispose();
});
it('copies all focused image drawings', () => {
const firstDrawing = createImageDrawing({ drawingId: 'first-image' });
const secondDrawing = createImageDrawing({
drawingId: 'second-image',
transform: { left: 30, top: 40, width: 10, height: 20 },
});
const { controller, hook, drawingService } = createController({
focusedDrawings: [firstDrawing, secondDrawing],
});
expect(hook.onBeforeCopyFocusedObject?.('unit-1', 'sheet-1', COPY_TYPE.COPY)).toBe(true);
const mutations = hook.onPasteFiles(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [3], cols: [4] },
},
[new File(['clipboard'], 'clipboard-image.png', { type: 'image/png' })],
{ pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(drawingService.getBatchAddOp).toHaveBeenCalledTimes(2);
expect(drawingService.getBatchAddOp.mock.calls.map(([drawings]) => drawings[0].transform)).toEqual([
expect.objectContaining({ left: 40, top: 60 }),
expect.objectContaining({ left: 65, top: 95 }),
]);
expect(mutations.redos).toHaveLength(2);
controller.dispose();
});
it('pastes external image files instead of the previous copied drawing', () => {
const focusedDrawing = createImageDrawing({ drawingId: 'focused-image' });
const { controller, hook } = createController({
focusedDrawings: [focusedDrawing],
});
const internalImage = new File(['internal'], 'clipboard-image.png', { type: 'image/png' });
const externalImage = new File(['external'], 'external.png', { type: 'image/png' });
(controller as unknown as IPrivateControllerAccess)._copyInfo = {
unitId: focusedDrawing.unitId,
subUnitId: focusedDrawing.subUnitId,
drawings: [focusedDrawing],
};
hook.onPasteFiles(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
[internalImage],
{ pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
const mutations = hook.onPasteFiles(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
[externalImage],
{ pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(mutations).toEqual({
undos: [],
redos: [{
id: InsertFloatImageCommand.id,
params: { files: [externalImage] },
}],
});
controller.dispose();
});
it('clears a copied image after pasting external cell content', () => {
const focusedDrawing = createImageDrawing({ drawingId: 'focused-image' });
const { controller, hook } = createController({
focusedDrawings: [focusedDrawing],
});
(controller as unknown as IPrivateControllerAccess)._copyInfo = {
unitId: focusedDrawing.unitId,
subUnitId: focusedDrawing.subUnitId,
drawings: [focusedDrawing],
};
const mutations = hook.onPasteCells(
null,
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
new ObjectMatrix(),
{ copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(mutations).toEqual({ undos: [], redos: [] });
const externalImage = new File(['external'], 'external.png', { type: 'image/png' });
expect(hook.onPasteFiles(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
[externalImage],
{ pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
).redos).toEqual([{
id: InsertFloatImageCommand.id,
params: { files: [externalImage] },
}]);
expect(hook.onPasteUnrecognized({
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
})).toEqual({ undos: [], redos: [] });
controller.dispose();
});
it('clears a copied image after an unrecognized internal paste', () => {
const focusedDrawing = createImageDrawing({ drawingId: 'focused-image' });
const { controller, hook } = createController({
focusedDrawings: [focusedDrawing],
});
const externalImage = new File(['external'], 'external.png', { type: 'image/png' });
(controller as unknown as IPrivateControllerAccess)._copyInfo = {
unitId: focusedDrawing.unitId,
subUnitId: focusedDrawing.subUnitId,
drawings: [focusedDrawing],
};
hook.onPasteUnrecognized({
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
});
const mutations = hook.onPasteFiles(
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
range: { rows: [0], cols: [0] },
},
[externalImage],
{ pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }
);
expect(mutations.redos).toEqual([{
id: InsertFloatImageCommand.id,
params: { files: [externalImage] },
}]);
controller.dispose();
});
});

View File

@@ -0,0 +1,168 @@
/**
* 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 { ObjectType } from '@univerjs/engine-render';
import { InsertSheetDrawingCommand, SetDrawingArrangeCommand, SetSheetDrawingPlacementCommand } from '@univerjs/sheets-drawing';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetDrawingPermissionController } from '../sheet-drawing-permission.controller';
function createController() {
let beforeCommandHandler: ((command: { id: string; params?: unknown }) => void) | undefined;
const viewPermission$ = new Subject<Array<{ value: boolean }>>();
const editPermission$ = new Subject<Array<{ value: boolean }>>();
const activeSheet$ = new BehaviorSubject({ getSheetId: () => 'sheet-1' });
const workbook = {
activeSheet$,
getUnitId: () => 'unit-1',
getActiveSheet: () => activeSheet$.value,
};
const workbook$ = new BehaviorSubject(workbook);
const drawingObject = { objectType: ObjectType.IMAGE, oKey: 'image-drawing-1' };
const unrelatedObject = { objectType: ObjectType.IMAGE, oKey: 'image-other' };
const transformer = { clearSelectedObjects: vi.fn() };
const scene = {
getAllObjectsByOrder: vi.fn(() => [drawingObject, unrelatedObject]),
getTransformerByCreate: vi.fn(() => transformer),
removeObject: vi.fn(),
attachTransformerTo: vi.fn(),
detachTransformerFrom: vi.fn(),
};
const sheetDrawingService = {
setDrawingVisible: vi.fn(),
setDrawingEditable: vi.fn(),
getDrawingData: vi.fn(() => ({
'drawing-1': { drawingId: 'drawing-1' },
})),
addNotification: vi.fn(),
};
let composePermissionCall = 0;
const permissionService = {
composePermission: vi.fn(() => [{ value: true }]),
composePermission$: vi.fn(() => {
composePermissionCall += 1;
return composePermissionCall === 1 ? viewPermission$ : editPermission$;
}),
};
const sheetPermissionCheckController = {
permissionCheckWithoutRange: vi.fn(() => false),
blockExecuteWithoutPermission: vi.fn(),
};
const controller = new SheetDrawingPermissionController(
{
beforeCommandExecuted: vi.fn((handler) => {
beforeCommandHandler = handler;
return { dispose: vi.fn() };
}),
} as never,
{ t: (key: string) => key } as never,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as never,
permissionService as never,
{
getCurrentTypeOfUnit$: vi.fn(() => workbook$),
getCurrentUnitOfType: vi.fn(() => workbook),
} as never,
{ currentUser$: new BehaviorSubject({ userID: 'user-1' }) } as never,
sheetPermissionCheckController as never,
sheetDrawingService as never
);
return {
controller,
viewPermission$,
editPermission$,
scene,
transformer,
drawingObject,
unrelatedObject,
sheetDrawingService,
beforeCommandHandler: () => beforeCommandHandler,
sheetPermissionCheckController,
};
}
describe('SheetDrawingPermissionController', () => {
it('hides sheet drawings and removes rendered drawing objects when view permission is revoked', () => {
const { controller, viewPermission$, scene, transformer, drawingObject, unrelatedObject, sheetDrawingService } = createController();
viewPermission$.next([{ value: false }]);
expect(sheetDrawingService.setDrawingVisible).toHaveBeenLastCalledWith(false);
expect(scene.removeObject).toHaveBeenCalledWith(drawingObject);
expect(scene.removeObject).not.toHaveBeenCalledWith(unrelatedObject);
expect(transformer.clearSelectedObjects).toHaveBeenCalled();
viewPermission$.next([{ value: true }]);
expect(sheetDrawingService.setDrawingVisible).toHaveBeenLastCalledWith(true);
expect(sheetDrawingService.addNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
controller.dispose();
});
it('detaches and reattaches transformers when edit permission changes, and blocks protected commands', () => {
const {
controller,
editPermission$,
scene,
transformer,
drawingObject,
sheetDrawingService,
beforeCommandHandler,
sheetPermissionCheckController,
} = createController();
editPermission$.next([{ value: false }]);
expect(sheetDrawingService.setDrawingEditable).toHaveBeenLastCalledWith(false);
expect(scene.detachTransformerFrom).toHaveBeenCalledWith(drawingObject);
expect(transformer.clearSelectedObjects).toHaveBeenCalled();
editPermission$.next([{ value: true }]);
expect(sheetDrawingService.setDrawingEditable).toHaveBeenLastCalledWith(true);
expect(scene.attachTransformerTo).toHaveBeenCalledWith(drawingObject);
expect(sheetDrawingService.addNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
beforeCommandHandler()?.({
id: InsertSheetDrawingCommand.id,
params: {
drawings: [{ unitId: 'unit-1', subUnitId: 'sheet-1' }],
},
});
beforeCommandHandler()?.({
id: SetDrawingArrangeCommand.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
},
});
beforeCommandHandler()?.({
id: SetSheetDrawingPlacementCommand.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawings: [],
},
});
expect(sheetPermissionCheckController.permissionCheckWithoutRange).toHaveBeenCalledTimes(3);
expect(sheetPermissionCheckController.blockExecuteWithoutPermission).toHaveBeenCalledWith('sheets-drawing-ui.permission.dialog.editErr');
controller.dispose();
});
});

View File

@@ -0,0 +1,244 @@
/**
* 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 { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { mountPrintingFloatDom } from '../../views/PrintingFloatDom';
import { SheetDrawingPrintingController } from '../sheet-drawing-printing.controller';
vi.mock('../../views/PrintingFloatDom', () => ({
mountPrintingFloatDom: vi.fn(() => vi.fn()),
}));
function createController(options?: { hasDrawingData?: boolean }) {
const interceptors = new Map<string, any>();
const points = {
PRINTING_COMPONENT_COLLECT: 'PRINTING_COMPONENT_COLLECT',
PRINTING_RANGE: 'PRINTING_RANGE',
PRINTING_DOM_COLLECT: 'PRINTING_DOM_COLLECT',
};
const drawingData = {
'unit-1': {
'sheet-1': {
order: ['image-1', 'chart-1', 'dom-1', 'block-1', 'grouped-image', 'hidden-image', 'hidden-chart'],
data: {
'image-1': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 20, top: 30, width: 120, height: 60 },
},
'chart-1': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: { left: 40, top: 50, width: 120, height: 60 },
},
'dom-1': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'dom-1',
drawingType: DrawingTypeEnum.DRAWING_DOM,
componentKey: 'custom-dom',
transform: { left: 80, top: 90, width: 40, height: 20 },
},
'block-1': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'block-1',
drawingType: DrawingTypeEnum.DRAWING_BLOCK,
componentKey: 'custom-block',
transform: { left: 80, top: 80, width: 20, height: 20 },
},
'grouped-image': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'grouped-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
groupId: 'group-1',
transform: { left: -100, top: -100, width: 20, height: 20 },
},
'hidden-image': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'hidden-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
hidden: true,
transform: { left: 1000, top: 1000, width: 500, height: 500 },
},
'hidden-chart': {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'hidden-chart',
drawingType: DrawingTypeEnum.DRAWING_CHART,
hidden: true,
transform: { left: 1000, top: 1000, width: 500, height: 500 },
},
},
},
},
};
const render = {
scene: { scaleX: 1, scaleY: 1 },
with: vi.fn(() => ({
getSkeletonParam: vi.fn(() => ({
skeleton: {
getCellIndexByOffset: vi.fn((x: number, y: number) => ({
row: Math.floor(y / 10),
column: Math.floor(x / 10),
})),
},
})),
})),
};
const controller = new SheetDrawingPrintingController(
{
getPrintComponent: vi.fn((key: string) => `${key}-print`),
interceptor: {
getInterceptPoints: vi.fn(() => points),
intercept: vi.fn((point, config) => {
interceptors.set(point, config);
return { dispose: vi.fn() };
}),
},
} as never,
{ renderDrawing: vi.fn(() => Promise.resolve()) } as never,
{ getDrawingDataForUnit: vi.fn(() => options?.hasDrawingData === false ? undefined : drawingData['unit-1']) } as never,
{ getRenderUnitById: vi.fn(() => render) } as never,
{
get: vi.fn((key: string) => `${key}-Component`),
} as never,
{} as never
);
return { controller, interceptors, points };
}
describe('SheetDrawingPrintingController', () => {
it('renders printable images while excluding charts and floating DOM drawings', () => {
const { controller, interceptors, points } = createController();
const scene = {};
const resourceCollector = { add: vi.fn() };
const next = vi.fn(() => 'next-result');
const result = interceptors.get(points.PRINTING_COMPONENT_COLLECT).handler(null, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
scene,
resourceCollector,
}, next);
expect((controller as any)._drawingRenderService.renderDrawing).toHaveBeenCalledTimes(2);
expect((controller as any)._drawingRenderService.renderDrawing).toHaveBeenCalledWith(expect.objectContaining({ drawingId: 'image-1' }), scene, { allowInactiveSheet: true });
expect((controller as any)._drawingRenderService.renderDrawing).toHaveBeenCalledWith(expect.objectContaining({ drawingId: 'grouped-image' }), scene, { allowInactiveSheet: true });
expect(resourceCollector.add).toHaveBeenCalledTimes(2);
expect(result).toBe('next-result');
controller.dispose();
});
it('expands the printed range to include ungrouped drawing bounds', () => {
const { controller, interceptors, points } = createController();
const next = vi.fn((range) => range);
const range = interceptors.get(points.PRINTING_RANGE).handler(
{ startRow: 4, endRow: 4, startColumn: 4, endColumn: 4 },
{ unitId: 'unit-1', subUnitId: 'sheet-1' },
next
);
expect(range).toEqual({
startRow: 3,
endRow: 11,
startColumn: 2,
endColumn: 16,
});
controller.dispose();
});
it('defers React root cleanup when replacing printed float components', async () => {
const { controller, interceptors, points } = createController();
const unmount = vi.fn();
vi.mocked(mountPrintingFloatDom).mockReturnValueOnce(unmount);
const disposers: Array<() => void> = [];
const add = vi.fn((dispose: () => void) => disposers.push(dispose));
const disposableCollection = { add };
const resourceCollector = { add: vi.fn() };
const next = vi.fn((value) => value);
const root = document.createElement('div');
const result = interceptors.get(points.PRINTING_DOM_COLLECT).handler(disposableCollection, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
scene: 'scene',
skeleton: 'skeleton',
worksheet: 'worksheet',
root,
resourceCollector,
}, next);
expect(mountPrintingFloatDom).toHaveBeenCalledWith(
{
floatDomInfos: [
expect.objectContaining({ drawingId: 'dom-1', componentKey: 'custom-dom-print-Component' }),
expect.objectContaining({ drawingId: 'block-1', componentKey: 'custom-block-print-Component' }),
],
scene: 'scene',
skeleton: 'skeleton',
worksheet: 'worksheet',
},
expect.any(HTMLElement),
expect.any(Object)
);
const printingRoot = vi.mocked(mountPrintingFloatDom).mock.calls[0][1];
expect(printingRoot).not.toBe(root);
expect(printingRoot.parentElement).toBe(root);
expect(add).toHaveBeenCalled();
expect(resourceCollector.add).not.toHaveBeenCalled();
expect(unmount).not.toHaveBeenCalled();
expect(result).toBe(disposableCollection);
disposers.forEach((dispose) => dispose());
expect(unmount).not.toHaveBeenCalled();
expect(printingRoot.parentElement).toBeNull();
await Promise.resolve();
expect(unmount).toHaveBeenCalledOnce();
controller.dispose();
});
it('continues DOM collection when the sheet has no drawing data', () => {
const { controller, interceptors, points } = createController({ hasDrawingData: false });
const disposableCollection = { add: vi.fn() };
const next = vi.fn(() => 'next-result');
const mountCallCount = vi.mocked(mountPrintingFloatDom).mock.calls.length;
const result = interceptors.get(points.PRINTING_DOM_COLLECT).handler(disposableCollection, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
}, next);
expect(next).toHaveBeenCalledWith(disposableCollection);
expect(result).toBe('next-result');
expect(mountPrintingFloatDom).toHaveBeenCalledTimes(mountCallCount);
controller.dispose();
});
});

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 { DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { SetDrawingSelectedOperation } from '@univerjs/drawing';
import { SetDrawingArrangeCommand, SetSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { GroupSheetDrawingCommand } from '../../commands/commands/group-sheet-drawing.command';
import { UngroupSheetDrawingCommand } from '../../commands/commands/ungroup-sheet-drawing.command';
import { SheetDrawingUpdateController } from '../sheet-drawing-update.controller';
function createSkeleton() {
return {
rowHeaderWidth: 0,
columnHeaderHeight: 0,
columnTotalWidth: 1000,
rowTotalHeight: 1000,
getNoMergeCellWithCoordByIndex: vi.fn((row: number, column: number) => ({
startX: column * 10,
endX: column * 10 + 10,
startY: row * 20,
endY: row * 20 + 20,
})),
getCellIndexAndOffsetByPosition: vi.fn((left: number, top: number) => {
const column = Math.floor(left / 10);
const row = Math.floor(top / 20);
return {
column,
row,
columnOffset: left - column * 10,
rowOffset: top - row * 20,
};
}),
};
}
function createController(options?: { openFiles?: unknown[] }) {
const featurePluginOrderUpdate$ = new Subject<any>();
const featurePluginUpdate$ = new Subject<any[]>();
const featurePluginGroupUpdate$ = new Subject<any[]>();
const featurePluginUngroupUpdate$ = new Subject<any[]>();
const focus$ = new Subject<any[] | null>();
const skeleton = createSkeleton();
const commandService = {
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(() => true),
};
const sheetDrawingService = {
getDrawingByParam: vi.fn(({ drawingId }) => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 10, top: 20, width: 30, height: 40 },
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 4, rowOffset: 0, columnOffset: 0 },
},
})),
getDrawingsByGroup: vi.fn(() => []),
focusDrawing: vi.fn(),
};
const selectionManagerService = {
getWorkbookSelections: vi.fn(() => ({
getCurrentSelections: vi.fn(() => []),
getCurrentLastSelection: vi.fn(() => undefined),
})),
};
const controller = new SheetDrawingUpdateController(
{
unitId: 'unit-1',
unit: {
getUnitId: () => 'unit-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
},
scene: { width: 400, height: 300 },
} as never,
commandService as never,
{ interceptAfterCommand: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ getCellWithCoordByOffset: vi.fn() } as never,
{ saveImage: vi.fn(), addImageSourceCache: vi.fn() } as never,
{ openFile: vi.fn(async () => options?.openFiles ?? []) } as never,
sheetDrawingService as never,
{
featurePluginOrderUpdate$,
featurePluginUpdate$,
featurePluginGroupUpdate$,
featurePluginUngroupUpdate$,
focus$,
} as never,
{ setContextValue: vi.fn() } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string, value?: string) => `${key}:${value ?? ''}`) } as never,
selectionManagerService as never,
{
getSkeleton: vi.fn(() => skeleton),
getSkeletonParam: vi.fn(() => ({ unitId: 'unit-1', sheetId: 'sheet-1', skeleton })),
} as never,
{ get: vi.fn() } as never,
{ getImage: vi.fn() } as never
);
return {
controller,
commandService,
sheetDrawingService,
featurePluginOrderUpdate$,
featurePluginUpdate$,
featurePluginGroupUpdate$,
featurePluginUngroupUpdate$,
focus$,
};
}
describe('SheetDrawingUpdateController', () => {
it('reports empty and over-limit file selections without inserting images', async () => {
const empty = createController({ openFiles: [] });
await expect(empty.controller.insertFloatImage()).resolves.toBe(false);
await expect(empty.controller.insertCellImage()).resolves.toBe(false);
expect((empty.controller as any)._messageService.show).not.toHaveBeenCalled();
empty.controller.dispose();
const overLimitFiles = Array.from({ length: 30 }, (_, index) => ({ name: `image-${index}.png` }));
const overLimit = createController({ openFiles: overLimitFiles });
await expect(overLimit.controller.insertFloatImage()).resolves.toBe(false);
expect((overLimit.controller as any)._messageService.show).toHaveBeenCalledWith({
type: MessageType.Error,
content: expect.stringContaining('sheets-drawing-ui.update-status.exceedMaxCount'),
});
expect(overLimit.commandService.executeCommand).not.toHaveBeenCalled();
overLimit.controller.dispose();
});
it('converts drawing transform updates into sheet drawing commands', () => {
const { controller, commandService, featurePluginUpdate$ } = createController();
featurePluginUpdate$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'image-1',
transform: { left: 25, top: 45 },
}]);
expect(commandService.executeCommand).toHaveBeenCalledWith(SetSheetDrawingCommand.id, {
unitId: 'unit-1',
drawings: [
expect.objectContaining({
drawingId: 'image-1',
transform: expect.objectContaining({ left: 25, top: 45, width: 30, height: 40 }),
sheetTransform: expect.objectContaining({
from: { row: 2, column: 2, rowOffset: 5, columnOffset: 5 },
to: { row: 4, column: 5, rowOffset: 5, columnOffset: 5 },
}),
}),
],
});
controller.dispose();
});
it('forwards arrange, group, ungroup and focus drawing events to sheet services', () => {
const {
controller,
commandService,
sheetDrawingService,
featurePluginOrderUpdate$,
featurePluginGroupUpdate$,
featurePluginUngroupUpdate$,
focus$,
} = createController();
const parent = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
transform: { left: 0, top: 0, width: 40, height: 40 },
};
const child = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 10, top: 20, width: 30, height: 40 },
};
featurePluginOrderUpdate$.next({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingIds: ['image-1'],
arrangeType: 'front',
});
featurePluginGroupUpdate$.next([{ parent, children: [child] }]);
featurePluginUngroupUpdate$.next([{ parent, children: [child] }]);
focus$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'image-1' }]);
focus$.next([]);
expect(commandService.executeCommand).toHaveBeenCalledWith(SetDrawingArrangeCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingIds: ['image-1'],
arrangeType: 'front',
});
expect(commandService.executeCommand).toHaveBeenCalledWith(GroupSheetDrawingCommand.id, [
expect.objectContaining({
parent: expect.objectContaining({ drawingId: 'group-1', sheetTransform: expect.any(Object) }),
children: [expect.objectContaining({ drawingId: 'image-1', sheetTransform: expect.any(Object) })],
}),
]);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, [{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
}]);
expect(commandService.executeCommand).toHaveBeenCalledWith(UngroupSheetDrawingCommand.id, [
expect.objectContaining({
children: [expect.objectContaining({ drawingId: 'image-1', sheetTransform: expect.any(Object) })],
}),
]);
expect((controller as any)._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, true);
expect((controller as any)._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, false);
expect(sheetDrawingService.focusDrawing).toHaveBeenLastCalledWith([]);
controller.dispose();
});
it('does not persist derived group rotateEnabled before grouping', () => {
const {
controller,
commandService,
featurePluginGroupUpdate$,
} = createController();
const parent = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0, width: 80, height: 40 },
};
const children = [
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 10, top: 20, width: 30, height: 40 },
},
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: { left: 40, top: 20, width: 30, height: 40 },
},
];
featurePluginGroupUpdate$.next([{ parent, children }]);
expect(commandService.executeCommand).toHaveBeenCalledWith(GroupSheetDrawingCommand.id, [
expect.objectContaining({
parent: expect.objectContaining({
drawingId: 'group-1',
}),
}),
]);
const groupCommandParams = commandService.executeCommand.mock.calls.find(([commandId]) => commandId === GroupSheetDrawingCommand.id)?.[1] as any[];
expect(Object.prototype.hasOwnProperty.call(groupCommandParams[0].parent.transform ?? {}, 'rotateEnabled')).toBe(false);
controller.dispose();
});
});

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 { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { BATCH_SAVE_IMAGES_DIALOG_ID, BatchSaveImagesDialog } from '../views/batch-save-images';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../views/sheet-image-panel/component-name';
import { SheetDrawingPanel } from '../views/sheet-image-panel/SheetDrawingPanel';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager
) {
super();
this._registerComponents();
}
private _registerComponents(): void {
this.disposeWithMe(this._componentManager.register(COMPONENT_SHEET_DRAWING_PANEL, SheetDrawingPanel));
this.disposeWithMe(this._componentManager.register(BATCH_SAVE_IMAGES_DIALOG_ID, BatchSaveImagesDialog));
}
}

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 { DrawingTypeEnum, IDrawingParam, Nullable, Workbook } from '@univerjs/core';
import { IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ContextMenuPosition, IContextMenuService } from '@univerjs/ui';
import { IDrawingContextMenuService } from '../services/drawing-context-menu.service';
export class DrawingContextMenuController extends RxDisposable {
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IDrawingContextMenuService private readonly _drawingContextMenuService: IDrawingContextMenuService
) {
super();
this._init();
}
private _init(): void {
this._univerInstanceService.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET).forEach((workbook) => this._contextMenuListener(workbook));
}
private _contextMenuListener(workbook: Nullable<Workbook>) {
if (!workbook) {
return;
}
const scene = this._renderManagerService.getRenderUnitById(workbook.getUnitId())?.scene;
if (!scene) {
return;
}
const transformer = scene.getTransformerByCreate();
if (!transformer) {
return;
}
this.disposeWithMe(transformer.changeEnd$.subscribe((params) => {
const { event } = params;
if (event.button !== 2) return;
const selectedObjects = transformer.getSelectedObjectMap();
if (selectedObjects.size === 0) return;
const drawings: IDrawingParam[] = [];
for (const object of selectedObjects.values()) {
const oKey = object.oKey;
const drawingParam = this._drawingManagerService.getDrawingOKey(oKey);
if (!drawingParam) return;
drawings.push(drawingParam);
}
this._contextMenuService.triggerContextMenu(event, this._getContextMenuPosition(drawings));
}));
}
private _getContextMenuPosition(drawings: IDrawingParam[]): string {
if (drawings.length !== 1) {
return ContextMenuPosition.DRAWING;
}
const [drawing] = drawings;
return this._drawingContextMenuService.getContextMenuPosition({
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
drawingType: drawing.drawingType as DrawingTypeEnum,
drawing,
}) ?? ContextMenuPosition.DRAWING;
}
}

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 { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from '../../views/batch-save-images';
import { MobileBatchSaveImagesDialog } from '../../views/batch-save-images/MobileBatchSaveImagesDialog';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../../views/sheet-image-panel/component-name';
import { SheetDrawingPanel } from '../../views/sheet-image-panel/SheetDrawingPanel';
export class MobileComponentsController extends Disposable {
constructor(@Inject(ComponentManager) componentManager: ComponentManager) {
super();
this.disposeWithMe(componentManager.register(COMPONENT_SHEET_DRAWING_PANEL, SheetDrawingPanel));
this.disposeWithMe(componentManager.register(BATCH_SAVE_IMAGES_DIALOG_ID, MobileBatchSaveImagesDialog));
}
}

View File

@@ -0,0 +1,145 @@
/**
* 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 { ISheetDrawing } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum } from '@univerjs/core';
import { drawingPositionToTransform, SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
import { describe, expect, it, vi } from 'vitest';
import { SheetsDrawingRenderController } from '../sheet-drawing.render-controller';
vi.mock('@univerjs/sheets-drawing', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/sheets-drawing')>();
return {
...actual,
drawingPositionToTransform: vi.fn(() => ({ left: 24, top: 36, width: 120, height: 80 })),
};
});
describe('SheetsDrawingRenderController', () => {
it('initializes sheet drawing data and materializes sheet transforms for render objects', () => {
const drawingWithSheetTransform: Partial<ISheetDrawing> = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-1',
sheetTransform: {
from: { row: 1, column: 2, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 4, rowOffset: 0, columnOffset: 0 },
},
};
const drawingWithoutSkeleton: Partial<ISheetDrawing> = {
unitId: 'unit-1',
subUnitId: 'missing-sheet',
drawingId: 'drawing-2',
sheetTransform: {
from: { row: 5, column: 6, rowOffset: 0, columnOffset: 0 },
to: { row: 7, column: 8, rowOffset: 0, columnOffset: 0 },
},
};
const groupedDrawing: Partial<ISheetDrawing> = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-4',
groupId: 'group-1',
transform: { left: 2, top: 3, width: 40, height: 50 },
sheetTransform: {
from: { row: 1, column: 2, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 4, rowOffset: 0, columnOffset: 0 },
},
};
const drawingGroup: Partial<ISheetDrawing> = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-5',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
sheetTransform: {
from: { row: 1, column: 2, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 4, rowOffset: 0, columnOffset: 0 },
},
};
const absoluteDrawing: Partial<ISheetDrawing> = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-6',
anchorType: SheetDrawingAnchorType.None,
transform: { left: 240, top: 96, width: 120, height: 80 },
sheetTransform: {
from: { row: 0, column: 0, rowOffset: 96, columnOffset: 240 },
to: { row: 0, column: 0, rowOffset: 176, columnOffset: 360 },
},
};
const drawingData = {
'sheet-1': {
data: {
'drawing-1': drawingWithSheetTransform,
'drawing-3': { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-3' },
'drawing-4': groupedDrawing,
'drawing-5': drawingGroup,
'drawing-6': absoluteDrawing,
},
},
'missing-sheet': {
data: {
'drawing-2': drawingWithoutSkeleton,
},
},
};
const sheetDrawingService = {
initializeNotification: vi.fn(),
getDrawingDataForUnit: vi.fn(() => drawingData),
};
const drawingManagerService = {
registerDrawingData: vi.fn(),
initializeNotification: vi.fn(),
};
const skeletonParam = {
skeleton: {
id: 'skeleton-1',
rowHeaderWidthAndMarginLeft: 10,
columnHeaderHeightAndMarginTop: 20,
},
};
const sheetSkeletonService = {
getSkeletonParam: vi.fn((unitId: string, subUnitId: string) => {
if (unitId === 'unit-1' && subUnitId === 'sheet-1') {
return skeletonParam;
}
return null;
}),
};
const controller = new SheetsDrawingRenderController(
{ unitId: 'unit-1' } as never,
sheetDrawingService as never,
drawingManagerService as never,
sheetSkeletonService as never
);
expect(sheetDrawingService.initializeNotification).toHaveBeenCalledWith('unit-1');
expect(drawingPositionToTransform).toHaveBeenCalledWith(drawingWithSheetTransform.sheetTransform, skeletonParam);
expect(drawingWithSheetTransform.transform).toEqual({ left: 24, top: 36, width: 120, height: 80 });
expect(drawingGroup.transform).toEqual({ left: 14, top: 16, width: 120, height: 80 });
expect(groupedDrawing.transform).toEqual({ left: 2, top: 3, width: 40, height: 50 });
expect(absoluteDrawing.transform).toEqual({ left: 240, top: 96, width: 120, height: 80 });
expect(drawingPositionToTransform).not.toHaveBeenCalledWith(absoluteDrawing.sheetTransform, skeletonParam);
expect(drawingWithoutSkeleton).not.toHaveProperty('transform');
expect(drawingManagerService.registerDrawingData).toHaveBeenCalledWith('unit-1', drawingData);
expect(drawingManagerService.initializeNotification).toHaveBeenCalledWith('unit-1');
controller.dispose();
});
});

View File

@@ -0,0 +1,134 @@
/**
* 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 { IDocImage } from '@univerjs/docs-drawing';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import { Disposable, Inject } from '@univerjs/core';
import { DrawingRenderService } from '@univerjs/drawing-ui';
import { CURSOR_TYPE } from '@univerjs/engine-render';
import { isCellImage, SheetsSelectionsService } from '@univerjs/sheets';
import { HoverManagerService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { throttleTime } from 'rxjs';
const CELL_IMAGE_PREVIEW_KEY = 'preview-cell-image';
export class SheetCellImageHoverRenderController extends Disposable implements IRenderModule {
private _isSetCursor = false;
constructor(
private _context: IRenderContext<Workbook>,
@Inject(HoverManagerService) private _hoverManagerService: HoverManagerService,
@Inject(SheetsSelectionsService) private _selectionsService: SheetsSelectionsService,
@Inject(DrawingRenderService) private _drawingRenderService: DrawingRenderService,
@Inject(SheetSkeletonManagerService) private _sheetSkeletonManagerService: SheetSkeletonManagerService
) {
super();
this._initHover();
this._initImageClick();
}
private _initHover() {
this.disposeWithMe(this._hoverManagerService.currentRichTextNoDistinct$
.pipe(throttleTime(33))
.subscribe((richText) => {
let currentSelections: Readonly<ISelectionWithStyle[]> = [];
// null richText means the unit was disposed, should not get current selections
if (richText !== null) {
currentSelections = this._selectionsService.getWorkbookSelections(this._context.unitId).getCurrentSelections();
}
if (
richText?.unitId === this._context.unitId &&
richText?.drawing &&
currentSelections.length === 1 &&
currentSelections[0].primary
) {
const { row, col } = richText;
const { actualRow, actualColumn, startRow, startColumn, endRow, endColumn, isMerged, isMergedMainCell } = currentSelections[0].primary;
if (
(isMerged || isMergedMainCell) &&
row >= startRow &&
row <= endRow &&
col >= startColumn &&
col <= endColumn
) {
this._isSetCursor = true;
this._context.scene.setCursor(CURSOR_TYPE.ZOOM_IN);
} else if (row === actualRow && col === actualColumn) {
this._isSetCursor = true;
this._context.scene.setCursor(CURSOR_TYPE.ZOOM_IN);
} else if (this._isSetCursor) {
this._isSetCursor = false;
this._context.scene.resetCursor();
}
} else if (this._isSetCursor) {
this._isSetCursor = false;
this._context.scene.resetCursor();
}
}));
}
private _initImageClick() {
this.disposeWithMe(this._hoverManagerService.currentClickedCell$.subscribe((click) => {
if (click?.drawing && this._isSetCursor) {
const imageDrawing = click.drawing.drawing.drawingOrigin as IDocImage;
this._previewImage(imageDrawing);
}
}));
const currentDbClickedCell$ = this._hoverManagerService.currentDbClickedCell$;
if (!currentDbClickedCell$) {
return;
}
this.disposeWithMe(currentDbClickedCell$.subscribe(({ location }) => {
if (location.unitId !== this._context.unitId) {
return;
}
const worksheet = this._context.unit.getSheetBySheetId(location.subUnitId);
const documentData = worksheet?.getCellRaw(location.row, location.col)?.p;
const drawingId = documentData?.drawingsOrder?.[0];
const imageDrawing = isCellImage(documentData) && drawingId
? documentData.drawings?.[drawingId] as IDocImage | undefined
: undefined;
if (imageDrawing) {
this._previewImage(imageDrawing);
}
}));
}
private _previewImage(imageDrawing: IDocImage) {
if (this._disposed) {
return;
}
const imageCacheMap = this._sheetSkeletonManagerService.getCurrentSkeleton()?.imageCacheMap;
const imageEle = imageCacheMap?.getImage(
imageDrawing.imageSourceType,
imageDrawing.source,
() => this._previewImage(imageDrawing)
);
if (!imageEle) return;
this._drawingRenderService.previewImage(CELL_IMAGE_PREVIEW_KEY, imageEle.src, imageEle.width, imageEle.height);
this._context.scene.resetCursor();
this._isSetCursor = false;
}
}

View File

@@ -0,0 +1,71 @@
/**
* 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 { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, DrawingTypeEnum, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { SheetSkeletonService } from '@univerjs/sheets';
import { drawingPositionToTransform, ISheetDrawingService, SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
export class SheetsDrawingRenderController extends Disposable implements IRenderModule {
constructor(
private _context: IRenderContext,
@ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(SheetSkeletonService) private readonly _sheetSkeletonService: SheetSkeletonService
) {
super();
this._init();
}
private _init(): void {
this._drawingInitializeListener();
}
private _drawingInitializeListener() {
// initialize drawing data and add to sheet canvas
this._sheetDrawingService.initializeNotification(this._context.unitId);
const data = this._sheetDrawingService.getDrawingDataForUnit(this._context.unitId);
for (const subUnit in data) {
const subUnitData = data[subUnit];
for (const drawingId in subUnitData.data) {
const drawingData = subUnitData.data[drawingId];
const { unitId, subUnitId } = drawingData;
const skeletonParam = this._sheetSkeletonService.getSkeletonParam(unitId, subUnitId);
if (
skeletonParam &&
drawingData.sheetTransform &&
!drawingData.groupId &&
drawingData.anchorType !== SheetDrawingAnchorType.None
) {
const transform = drawingPositionToTransform(drawingData.sheetTransform, skeletonParam);
drawingData.transform = transform && drawingData.drawingType === DrawingTypeEnum.DRAWING_GROUP
? {
...transform,
left: (transform.left ?? 0) - skeletonParam.skeleton.rowHeaderWidthAndMarginLeft,
top: (transform.top ?? 0) - skeletonParam.skeleton.columnHeaderHeightAndMarginTop,
}
: transform;
}
}
}
this._drawingManagerService.registerDrawingData(this._context.unitId, this._sheetDrawingService.getDrawingDataForUnit(this._context.unitId));
this._drawingManagerService.initializeNotification(this._context.unitId);
}
}

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 { Disposable, Inject, Injector, ObjectMatrix } from '@univerjs/core';
import { IAutoFillService } from '@univerjs/sheets';
import { resizeImageByCell } from './sheet-cell-image.controller';
export class SheetCellImageAutofillController extends Disposable {
constructor(
@Inject(IAutoFillService) private readonly _autoFillService: IAutoFillService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initAutoFillHooks();
}
private _initAutoFillHooks(): void {
this.disposeWithMe(
this._autoFillService.addHook({
id: 'sheet-cell-image-autofill',
onBeforeSubmit: (location, direction, applyType, cellValue) => {
new ObjectMatrix(cellValue).forValue((row, col, cell) => {
resizeImageByCell(this._injector, { unitId: location.unitId, subUnitId: location.subUnitId, row, col }, cell);
});
},
})
);
}
}

View File

@@ -0,0 +1,122 @@
/**
* 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, IDocDrawingBase } from '@univerjs/core';
import type { IInnerPasteCommandParams } from '@univerjs/docs-ui';
import type { LocaleKey } from '../locale/types';
import {
BuildTextUtils,
createDocumentModelWithStyle,
Disposable,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { InnerPasteCommand } from '@univerjs/docs-ui';
import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { EditingRenderController, SetCellEditVisibleOperation } from '@univerjs/sheets-ui';
import { IDialogService } from '@univerjs/ui';
const DISABLE_UNITS = [
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
];
export class SheetCellImageCopyPasteController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IDialogService private readonly _dialogService: IDialogService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this._initDocImageCopyPasteHooks();
}
private _setCellImage(drwaing: IDocDrawingBase) {
const docDataModel = createDocumentModelWithStyle('', {});
const editingRenderController = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)?.with(EditingRenderController);
const jsonXActions = BuildTextUtils.drawing.add({
documentDataModel: docDataModel,
drawings: [drwaing],
selection: {
collapsed: true,
startOffset: 0,
endOffset: 0,
},
});
if (jsonXActions) {
docDataModel.apply(jsonXActions);
if (editingRenderController) {
editingRenderController.submitCellData(docDataModel);
}
}
}
private _initDocImageCopyPasteHooks() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id === InnerPasteCommand.id) {
const params = commandInfo.params as IInnerPasteCommandParams;
const { doc } = params;
const currentDoc = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (currentDoc == null || !Object.keys(doc.drawings ?? {}).length) {
return;
}
const docUnitId = currentDoc.getUnitId();
if (DISABLE_UNITS.includes(docUnitId)) {
const handleCloseDialog = () => {
this._dialogService.close('sheet-cell-image-copy-paste');
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
});
};
// empty
if (currentDoc.getBody()?.dataStream === '\r\n') {
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
});
this._setCellImage(Object.values(doc.drawings!)[0]);
} else {
this._dialogService.open({
id: 'sheet-cell-image-copy-paste',
title: {
label: this._localeService.t<LocaleKey>('sheets-drawing-ui.cell-image.pasteTitle'),
},
children: {
label: this._localeService.t<LocaleKey>('sheets-drawing-ui.cell-image.pasteContent'),
},
width: 320,
onClose: handleCloseDialog,
showOk: true,
showCancel: true,
onOk: () => {
handleCloseDialog();
this._setCellImage(Object.values(doc.drawings!)[0]);
},
onCancel: handleCloseDialog,
});
}
}
}
})
);
}
}

View File

@@ -0,0 +1,118 @@
/**
* 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, IDocDrawingBase, Nullable } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { ISheetLocationBase } from '@univerjs/sheets';
import { Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, Inject, Injector, InterceptorEffectEnum } from '@univerjs/core';
import { DocDrawingController } from '@univerjs/docs-drawing';
import { IDrawingManagerService } from '@univerjs/drawing';
import { InterceptCellContentPriority, INTERCEPTOR_POINT, isCellImage, SheetInterceptorService } from '@univerjs/sheets';
import { IEditorBridgeService } from '@univerjs/sheets-ui';
import { getDrawingSizeByCell } from './sheet-drawing-update.controller';
export function resizeImageByCell(injector: Injector, location: ISheetLocationBase, cell: Nullable<ICellData>) {
const documentData = cell?.p;
const drawingId = isCellImage(documentData) && documentData?.drawingsOrder?.length === 1 ? documentData.drawingsOrder[0] : undefined;
const image = drawingId ? documentData?.drawings?.[drawingId] as IImageData & IDocDrawingBase : undefined;
if (image && documentData) {
const imageSize = getDrawingSizeByCell(
injector,
{
unitId: location.unitId,
subUnitId: location.subUnitId,
row: location.row,
col: location.col,
},
image.docTransform!.size.width!,
image.docTransform!.size.height!,
image.docTransform!.angle
);
if (imageSize) {
image.transform!.width = imageSize.width;
image.transform!.height = imageSize.height;
image.docTransform!.size.width = imageSize.width;
image.docTransform!.size.height = imageSize.height;
image.transform!.left = 0;
image.transform!.top = 0;
image.docTransform!.positionH.posOffset = 0;
image.docTransform!.positionV.posOffset = 0;
documentData.documentStyle.pageSize!.width = Infinity;
documentData.documentStyle.pageSize!.height = Infinity;
return true;
}
}
return false;
}
export class SheetCellImageController extends Disposable {
constructor(
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(Injector) private readonly _injector: Injector,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(DocDrawingController) private readonly _docDrawingController: DocDrawingController,
@Inject(IEditorBridgeService) private readonly _editorBridgeService: IEditorBridgeService
) {
super();
this._handleInitEditor();
this._initCellContentInterceptor();
}
private _handleInitEditor() {
this.disposeWithMe(this._editorBridgeService.visible$.subscribe((param) => {
if (!param.visible) {
this._drawingManagerService.removeDrawingDataForUnit(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
} else if (param.visible) {
this._drawingManagerService.removeDrawingDataForUnit(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
this._docDrawingController.loadDrawingDataForUnit(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
this._drawingManagerService.initializeNotification(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
}
}));
}
private _initCellContentInterceptor() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(
INTERCEPTOR_POINT.CELL_CONTENT,
{
effect: InterceptorEffectEnum.Style,
priority: InterceptCellContentPriority.CELL_IMAGE,
handler: (cell, pos, next) => {
if (cell && isCellImage(cell.p)) {
if (cell === pos.rawData) {
cell = { ...pos.rawData };
}
if (!cell.interceptorStyle) {
cell.interceptorStyle = {};
}
cell.interceptorStyle.tr = { a: 0 };
resizeImageByCell(this._injector, { unitId: pos.unitId, subUnitId: pos.subUnitId, row: pos.row, col: pos.col }, cell);
}
return next(cell);
},
}
)
);
}
}

View File

@@ -0,0 +1,117 @@
/**
* 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, IDrawingParam, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { ISetWorksheetActiveOperationParams } from '@univerjs/sheets';
import type { ISheetDrawing } from '@univerjs/sheets-drawing';
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { SetWorksheetActiveOperation, SheetSkeletonService } from '@univerjs/sheets';
import { drawingPositionToTransform, ISheetDrawingService, SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
export class SheetDrawingActiveRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetSkeletonService) private readonly _sheetSkeletonService: SheetSkeletonService,
@ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService
) {
super();
this._commandListener();
}
private _commandListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetWorksheetActiveOperation.id) {
const { unitId, subUnitId } = command.params as ISetWorksheetActiveOperationParams;
this._updateDrawings(unitId, subUnitId);
}
})
);
this.disposeWithMe(
this._context.activated$.subscribe((activated) => {
const { unit, unitId } = this._context;
if (activated) {
this._updateDrawings(unitId, unit.getActiveSheet().getSheetId());
} else {
this._clearDrawings(unitId);
}
})
);
}
private _clearDrawings(selfUnitId: string): void {
setTimeout(() => {
const drawingMap = this._drawingManagerService.drawingManagerData;
const removeDrawings: IDrawingParam[] = [];
Object.keys(drawingMap ?? {}).forEach((unitId) => {
const subUnitMap = drawingMap[unitId] ?? {};
Object.keys(subUnitMap).forEach((subUnitId) => {
const drawingData = subUnitMap[subUnitId]?.data ?? {};
Object.keys(drawingData).forEach((drawingId) => {
if (unitId === selfUnitId) {
removeDrawings.push(drawingData[drawingId]);
}
});
});
});
this._sheetDrawingService.removeNotification(removeDrawings);
this._drawingManagerService.removeNotification(removeDrawings);
});
}
private _updateDrawings(showUnitId: string, showSubunitId: string): void {
setTimeout(() => {
const sheetSkeletonParam = this._sheetSkeletonService.getSkeletonParam(showUnitId, showSubunitId);
const drawingMap = this._drawingManagerService.drawingManagerData;
const insertDrawings: IDrawingParam[] = [];
const removeDrawings: IDrawingParam[] = [];
Object.keys(drawingMap ?? {}).forEach((unitId) => {
if (unitId !== showUnitId) {
return;
}
const subUnitMap = drawingMap[unitId] ?? {};
Object.keys(subUnitMap).forEach((subUnitId) => {
const drawingData = subUnitMap[subUnitId]?.data ?? {};
Object.keys(drawingData).forEach((drawingId) => {
if (unitId === showUnitId && subUnitId === showSubunitId) {
const drawing = drawingData[drawingId] as ISheetDrawing;
if (drawing.sheetTransform && drawing.anchorType !== SheetDrawingAnchorType.None) {
drawing.transform = drawingPositionToTransform(drawing.sheetTransform, sheetSkeletonParam);
}
insertDrawings.push(drawingData[drawingId]);
} else {
removeDrawings.push(drawingData[drawingId]);
}
});
});
});
this._sheetDrawingService.removeNotification(removeDrawings);
this._sheetDrawingService.addNotification(insertDrawings);
this._drawingManagerService.removeNotification(removeDrawings);
this._drawingManagerService.addNotification(insertDrawings);
}, 0);
}
}

View File

@@ -0,0 +1,509 @@
/**
* 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, Nullable } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { IDiscreteRange } from '@univerjs/sheets';
import type { IRemoveSheetDrawingCommandParams, ISheetDrawing, ISheetImage } from '@univerjs/sheets-drawing';
import type { IPasteHookValueType, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui';
import { Disposable, DrawingTypeEnum, generateRandomId, ICommandService, ImageSourceType, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { attachRangeWithCoord, discreteRangeToRange, SheetSkeletonService } from '@univerjs/sheets';
import { DrawingApplyType, RemoveSheetDrawingCommand, SetDrawingApplyMutation, SheetDrawingAnchorType, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing';
import {
COPY_TYPE,
ISheetClipboardService,
PREDEFINED_HOOK_NAME_PASTE,
virtualizeDiscreteRanges,
} from '@univerjs/sheets-ui';
import { IClipboardInterfaceService } from '@univerjs/ui';
import { InsertFloatImageCommand } from '../commands/commands/insert-image.command';
const IMAGE_PNG_MIME_TYPE = 'image/png';
function base64ToBlob(base64: string) {
const arr = base64.split(',');
const binStr = atob(arr[1]);
const len = binStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binStr.charCodeAt(i);
}
return new Blob([bytes], { type: IMAGE_PNG_MIME_TYPE });
}
function copyBase64ToClipboard(base64: string) {
const item = new ClipboardItem({ [IMAGE_PNG_MIME_TYPE]: base64ToBlob(base64) });
navigator.clipboard.write([item]).catch((err) => {
console.error('Could not copy image using clipboard API: ', err);
});
}
function focusDocument() {
function createInputElement() {
const input = document.createElement('input');
input.style.position = 'absolute';
input.style.height = '1px';
input.style.width = '1px';
input.style.opacity = '0';
return input;
}
const activeElement = document.activeElement;
const input = createInputElement();
document.body.appendChild(input);
input.focus();
return () => {
input.blur();
document.body.removeChild(input);
if (activeElement instanceof HTMLElement) {
activeElement.focus();
}
};
}
const specialPastes: IPasteHookValueType[] = [
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA,
];
export class SheetsDrawingCopyPasteController extends Disposable {
private _copyInfo: Nullable<{
drawings: ISheetDrawing[];
copyRange?: IRange;
unitId: string;
subUnitId: string;
}>;
constructor(
@ISheetClipboardService private _sheetClipboardService: ISheetClipboardService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetSkeletonService) private readonly _sheetSkeletonService: SheetSkeletonService,
@IDrawingManagerService private readonly _drawingService: IDrawingManagerService,
@IClipboardInterfaceService private readonly _clipboardInterfaceService: IClipboardInterfaceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initCopyPaste();
}
private get _focusedDrawings() {
return this._drawingService.getFocusDrawings() as ISheetDrawing[];
}
// eslint-disable-next-line max-lines-per-function
private _initCopyPaste() {
this._sheetClipboardService.addClipboardHook({
id: 'SHEET_IMAGE_UI_PLUGIN',
onBeforeCopyFocusedObject: (unitId, _subUnitId, copyType) => {
this._copyInfo = null;
const drawings = this._focusedDrawings.filter((drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE);
if (drawings.length === 0) {
return false;
}
const [drawing] = drawings;
if (copyType === COPY_TYPE.CUT) {
const params: IRemoveSheetDrawingCommandParams = {
unitId,
drawings,
};
// Delete the drawing when it is cut
this._commandService.executeCommand<IRemoveSheetDrawingCommandParams>(RemoveSheetDrawingCommand.id, params);
}
setTimeout(() => {
// Rewrite the clipboard content to prevent the default copy behavior, prevent to call the 'onPasteCells'
const dispose = focusDocument();
if (drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
&& (drawing as ISheetImage).imageSourceType === ImageSourceType.BASE64) {
copyBase64ToClipboard((drawing as ISheetImage).source);
} else {
this._clipboardInterfaceService.writeText('');
}
dispose();
}, 200);
this._copyInfo = {
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawings,
};
return true;
},
onBeforeCopy: (unitId, subUnitId, range) => {
this._copyInfo = this._createDrawingsCopyInfoByRange(unitId, subUnitId, range);
},
onPasteCells: (pasteFrom, pasteTo, _data, payload) => {
if (!payload.copyId) {
this._copyInfo = null;
return { redos: [], undos: [] };
}
if (!pasteFrom || !this._copyInfo) {
return { redos: [], undos: [] };
}
const { copyType = COPY_TYPE.COPY, pasteType } = payload;
const { range: copyRange, unitId: fromUnitId, subUnitId: fromSubUnitId } = pasteFrom;
const { range: pasteRange, unitId: toUnitId, subUnitId: toSubUnitId } = pasteTo;
const mutations = this._copyInfo.copyRange
? this._generateRangeDrawingsPasteMutations(
{ unitId: toUnitId, subUnitId: toSubUnitId, pasteRange, pasteType },
{ unitId: fromUnitId, subUnitId: fromSubUnitId, copyType, copyRange }
)
: this._generateSingleDrawingPasteMutations({ pasteTo, pasteType }, COPY_TYPE.COPY);
return mutations;
},
onPastePlainText: (_pasteTo: ISheetDiscreteRangeLocation, _clipText: string) => {
this._copyInfo = null;
return { undos: [], redos: [] };
},
onPasteUnrecognized: (pasteTo: ISheetDiscreteRangeLocation) => {
if (!this._copyInfo || this._copyInfo.copyRange) {
return { undos: [], redos: [] };
}
const mutations = this._generateSingleDrawingPasteMutations({ pasteTo, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }, COPY_TYPE.COPY);
this._copyInfo = null;
return mutations;
},
onPasteFiles: (pasteTo: ISheetDiscreteRangeLocation, files) => {
if (this._copyInfo && !this._copyInfo.copyRange) {
const mutations = this._generateSingleDrawingPasteMutations({ pasteTo, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE }, COPY_TYPE.COPY);
this._copyInfo = null;
return mutations;
}
const images = files.filter((file) => file.type.includes('image'));
if (images.length) {
this._copyInfo = null;
return {
undos: [],
redos: [
{
id: InsertFloatImageCommand.id,
params: { files: images },
},
],
};
}
return { undos: [], redos: [] };
},
});
}
private _createDrawingsCopyInfoByRange(unitId: string, subUnitId: string, range: IRange) {
const skeleton = this._sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!skeleton) return;
const selectionRect = attachRangeWithCoord(skeleton, range);
if (!selectionRect) return;
const { startX, endX, startY, endY } = selectionRect;
const drawings = this._drawingService.getDrawingData(unitId, subUnitId);
const containedDrawings: ISheetDrawing[] = [];
Object.keys(drawings).forEach((drawingId) => {
const drawing = drawings[drawingId];
if (drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
const anchorType = (drawing as ISheetDrawing).anchorType ?? SheetDrawingAnchorType.Position;
if (anchorType === SheetDrawingAnchorType.None) {
return;
}
const { transform } = drawing;
if (!transform) {
return;
}
const { left = 0, top = 0, width = 0, height = 0 } = transform;
const { drawingStartX, drawingEndX, drawingStartY, drawingEndY } = {
drawingStartX: left,
drawingEndX: left + width,
drawingStartY: top,
drawingEndY: top + height,
};
if (startX <= drawingStartX && drawingEndX <= endX && startY <= drawingStartY && drawingEndY <= endY) {
containedDrawings.push(drawing as ISheetDrawing);
}
});
if (containedDrawings.length) {
return {
copyRange: range,
drawings: containedDrawings,
unitId,
subUnitId,
};
}
}
private _generateSingleDrawingPasteMutations(pasteContext: {
pasteTo: ISheetDiscreteRangeLocation;
pasteType: IPasteHookValueType;
}, copyType: COPY_TYPE) {
const { pasteType, pasteTo } = pasteContext;
if (specialPastes.includes(pasteType)) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId, range } = pasteTo;
const pasteToSkeleton = this._sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!pasteToSkeleton) {
return { redos: [], undos: [] };
}
const copyInfo = this._copyInfo!;
const { drawings } = copyInfo;
const pasteRange = discreteRangeToRange(range);
let drawingAnchor: { left: number; top: number } | undefined;
if (drawings.length > 1) {
let anchorLeft = Infinity;
let anchorTop = Infinity;
for (const drawing of drawings) {
const { left = 0, top = 0 } = drawing.transform ?? {};
anchorLeft = Math.min(anchorLeft, left);
anchorTop = Math.min(anchorTop, top);
}
drawingAnchor = { left: anchorLeft, top: anchorTop };
}
return this._generateMutations(drawings, {
unitId,
subUnitId,
isCut: copyType === COPY_TYPE.CUT,
getTransform: (transform, sheetTransform) => {
const pasteRect = attachRangeWithCoord(pasteToSkeleton, {
startRow: pasteRange.startRow,
endRow: pasteRange.endRow,
startColumn: pasteRange.startColumn,
endColumn: pasteRange.endColumn,
});
const newTransform = {
...transform,
left: drawingAnchor && pasteRect
? pasteRect.startX + (transform?.left ?? 0) - drawingAnchor.left
: pasteRect?.startX,
top: drawingAnchor && pasteRect
? pasteRect.startY + (transform?.top ?? 0) - drawingAnchor.top
: pasteRect?.startY,
};
return {
transform: newTransform,
sheetTransform: transformToDrawingPosition(newTransform, pasteToSkeleton) ?? sheetTransform,
axisAlignSheetTransform: transformToAxisAlignPosition(newTransform, pasteToSkeleton) ?? sheetTransform,
};
},
});
}
private _generateMutations(drawings: ISheetDrawing[], payload: {
unitId: string;
subUnitId: string;
getTransform: (transform: ISheetDrawing['transform'], sheetTransform: ISheetDrawing['sheetTransform']) => {
transform: ISheetDrawing['transform'];
sheetTransform: ISheetDrawing['sheetTransform'];
axisAlignSheetTransform: ISheetDrawing['sheetTransform'];
};
isCut: boolean;
}) {
const {
unitId,
subUnitId,
getTransform,
isCut,
} = payload;
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const { _drawingService } = this;
drawings.forEach((drawing) => {
const { transform, sheetTransform } = drawing;
if (!transform) {
return;
}
const transformContext = getTransform(transform, sheetTransform);
const drawingObject: Partial<ISheetDrawing> = {
...drawing,
unitId,
subUnitId,
drawingId: isCut ? drawing.drawingId : generateRandomId(),
transform: transformContext.transform,
sheetTransform: transformContext.sheetTransform,
axisAlignSheetTransform: transformContext.axisAlignSheetTransform,
};
if (isCut) {
const { undo, redo, objects } = _drawingService.getBatchUpdateOp([drawingObject] as ISheetDrawing[]) as IDrawingJsonUndo1;
redos.push({
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
type: DrawingApplyType.UPDATE,
op: redo,
objects,
},
});
undos.push({
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
type: DrawingApplyType.UPDATE,
op: undo,
objects,
},
});
} else {
const { undo, redo, objects } = _drawingService.getBatchAddOp([drawingObject] as ISheetDrawing[]) as IDrawingJsonUndo1;
redos.push({ id: SetDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.INSERT } });
undos.push({ id: SetDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects, type: DrawingApplyType.REMOVE } });
}
});
return { redos, undos };
}
// eslint-disable-next-line max-lines-per-function
private _generateRangeDrawingsPasteMutations(
pasteContext: {
unitId: string;
subUnitId: string;
pasteRange: IDiscreteRange;
pasteType: IPasteHookValueType;
},
copyContext: {
unitId?: string;
subUnitId?: string;
copyRange?: IDiscreteRange;
copyType: COPY_TYPE;
}
) {
if (!this._copyInfo) {
return { redos: [], undos: [] };
}
const { unitId: toUnitId, subUnitId: toSubUnitId, pasteRange, pasteType } = pasteContext;
const { unitId: fromUnitId, subUnitId: fromSubUnitId, copyRange, copyType } = copyContext;
if (specialPastes.includes(pasteType)) {
return { redos: [], undos: [] };
}
const toSkeleton = this._sheetSkeletonService.getSkeleton(toUnitId, toSubUnitId);
if (!toSkeleton) {
return { redos: [], undos: [] };
}
const { drawings } = this._copyInfo;
if (!copyRange) {
return this._generateSingleDrawingPasteMutations({
pasteTo: { unitId: toUnitId, subUnitId: toSubUnitId, range: discreteRangeToRange(pasteRange) as unknown as IDiscreteRange },
pasteType,
}, copyType);
}
const fromSkeleton = this._sheetSkeletonService.getSkeleton(fromUnitId!, fromSubUnitId!);
if (!fromSkeleton) {
return { redos: [], undos: [] };
}
const { ranges: [vCopyRange, vPastedRange], mapFunc } = virtualizeDiscreteRanges([copyRange, pasteRange]);
const { row: copyRow, col: copyCol } = mapFunc(vCopyRange.startRow, vCopyRange.startColumn);
const { row: pasteRow, col: pasteCol } = mapFunc(vPastedRange.startRow, vPastedRange.startColumn);
const copyRect = attachRangeWithCoord(fromSkeleton, {
startRow: copyRow,
endRow: copyRow,
startColumn: copyCol,
endColumn: copyCol,
});
const pasteRect = attachRangeWithCoord(toSkeleton, {
startRow: pasteRow,
endRow: pasteRow,
startColumn: pasteCol,
endColumn: pasteCol,
});
if (!copyRect || !pasteRect) {
return { redos: [], undos: [] };
}
const leftOffset = pasteRect.startX - copyRect.startX;
const topOffset = pasteRect.startY - copyRect.startY;
const rowOffset = pasteRow - copyRow;
const columnOffset = pasteCol - copyCol;
return this._generateMutations(drawings, {
unitId: toUnitId,
subUnitId: toSubUnitId,
getTransform: (transform, sheetTransform) => {
const newTransform = {
...transform,
left: (transform?.left ?? 0) + leftOffset,
top: (transform?.top ?? 0) + topOffset,
};
return {
transform: newTransform,
sheetTransform: {
...sheetTransform,
to: {
...sheetTransform.to,
row: sheetTransform.to.row + rowOffset,
column: sheetTransform.to.column + columnOffset,
},
from: {
...sheetTransform.from,
row: sheetTransform.from.row + rowOffset,
column: sheetTransform.from.column + columnOffset,
},
},
axisAlignSheetTransform: transformToAxisAlignPosition(newTransform, toSkeleton) ?? sheetTransform,
};
},
isCut: copyType === COPY_TYPE.CUT,
});
}
}

View File

@@ -0,0 +1,216 @@
/**
* 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 { IDrawingGroupNestedParam, IMutationInfo, Nullable } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { ISheetDrawing } from '@univerjs/sheets-drawing';
import type { IPasteHookValueType, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui';
import { Disposable, DrawingTypeEnum, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { attachRangeWithCoord, discreteRangeToRange, SheetSkeletonService } from '@univerjs/sheets';
import { DrawingApplyType, ISheetDrawingService, SetDrawingApplyMutation, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing';
import { ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE } from '@univerjs/sheets-ui';
import { cloneGroupParams } from '../commands/commands/utils';
export interface IGroupFeaturePasteHookParams {
/** Unit ID of the copy source */
fromUnitId: string;
/** Sub-unit ID of the copy source */
fromSubUnitId: string;
/** Unit ID of the paste destination */
toUnitId: string;
/** Sub-unit ID of the paste destination */
toSubUnitId: string;
/** Maps original drawingId → new (cloned) drawingId */
idMap: Map<string, string>;
/** The fully cloned group param with remapped IDs */
cloned: IDrawingGroupNestedParam;
}
export type GroupFeaturePasteHook = (params: IGroupFeaturePasteHookParams) => { redos: IMutationInfo[]; undos: IMutationInfo[] };
const specialPastes: IPasteHookValueType[] = [
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA,
];
export class SheetsDrawingGroupCopyPasteController extends Disposable {
private readonly _featurePasteHooks: GroupFeaturePasteHook[] = [];
private _copyInfo: Nullable<{
unitId: string;
subUnitId: string;
groupNestedParam: IDrawingGroupNestedParam;
}>;
constructor(
@ISheetClipboardService private readonly _sheetClipboardService: ISheetClipboardService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetSkeletonService) private readonly _sheetSkeletonService: SheetSkeletonService,
@ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService
) {
super();
this._initCopyPaste();
}
private get _focusedDrawings() {
return this._sheetDrawingService.getFocusDrawings() as ISheetDrawing[];
}
private _initCopyPaste() {
this._sheetClipboardService.addClipboardHook({
id: 'SHEET_DRAWING_GROUP',
onBeforeCopyFocusedObject: (_unitId, _subUnitId) => {
this._copyInfo = null;
const focusDrawings = this._focusedDrawings;
if (focusDrawings.length === 0) return false;
// Only handle the first focused group drawing.
const groupDrawing = focusDrawings.find((d) => d.drawingType === DrawingTypeEnum.DRAWING_GROUP);
if (!groupDrawing) return false;
const groupNestedParam = this._drawingManagerService.getDrawingsByGroupNested({
unitId: groupDrawing.unitId,
subUnitId: groupDrawing.subUnitId,
drawingId: groupDrawing.drawingId,
});
if (!groupNestedParam) return false;
this._copyInfo = {
unitId: groupDrawing.unitId,
subUnitId: groupDrawing.subUnitId,
groupNestedParam,
};
return true;
},
onPasteCells: (pasteFrom, pasteTo, _data, payload) => {
if (!payload.copyId || !pasteFrom || !this._copyInfo) return { redos: [], undos: [] };
const { pasteType } = payload;
if (specialPastes.includes(pasteType)) return { redos: [], undos: [] };
return this._generateGroupPasteMutations(pasteTo);
},
});
}
public registerFeaturePasteHook(hook: GroupFeaturePasteHook): void {
this._featurePasteHooks.push(hook);
}
private _getGroupFeaturePasteMutations(params: IGroupFeaturePasteHookParams): { redos: IMutationInfo[]; undos: IMutationInfo[] } {
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
for (const hook of this._featurePasteHooks) {
const result = hook(params);
redos.push(...result.redos);
undos.push(...result.undos);
}
return { redos, undos };
}
private _generateGroupPasteMutations(pasteTo: ISheetDiscreteRangeLocation): { redos: IMutationInfo[]; undos: IMutationInfo[] } {
if (!this._copyInfo) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId, range } = pasteTo;
const pasteToSkeleton = this._sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!pasteToSkeleton) {
return { redos: [], undos: [] };
}
const { groupNestedParam } = this._copyInfo;
// Root group is the last element of the post-order groups array.
const origRootGroup = groupNestedParam.groups[groupNestedParam.groups.length - 1] as ISheetDrawing;
// Clone all drawing IDs.
const { cloned, idMap } = cloneGroupParams(groupNestedParam);
const newRootGroupId = cloned.groups[cloned.groups.length - 1].drawingId;
// Resolve the paste destination rectangle.
const pasteRange = discreteRangeToRange(range);
const pasteRect = attachRangeWithCoord(pasteToSkeleton, {
startRow: pasteRange.startRow,
endRow: pasteRange.endRow,
startColumn: pasteRange.startColumn,
endColumn: pasteRange.endColumn,
});
if (!pasteRect) return { redos: [], undos: [] };
// Only the root group's absolute position changes; all other drawings keep their
// group-relative transforms as-is.
const newTransform = { ...(origRootGroup.transform), left: pasteRect.startX, top: pasteRect.startY };
const allDrawings: ISheetDrawing[] = [
...(cloned.flatChildren ?? []).map((d) => ({ ...d, unitId, subUnitId } as ISheetDrawing)),
...cloned.groups.map((d) => {
if (d.drawingId !== newRootGroupId) {
return { ...d, unitId, subUnitId } as ISheetDrawing;
}
return {
...d,
unitId,
subUnitId,
transform: newTransform,
sheetTransform: transformToDrawingPosition(newTransform, pasteToSkeleton) ?? origRootGroup.sheetTransform,
axisAlignSheetTransform: transformToAxisAlignPosition(newTransform, pasteToSkeleton) ?? origRootGroup.sheetTransform,
} as ISheetDrawing;
}),
];
// A single batch INSERT is sufficient: the shape rendering layer calls insertGroupObject()
// for each shape's groupId, which automatically reconstructs the full group hierarchy in
// the scene from the data that was just written by applyJson1.
const { undo: removeOp, redo: insertOp, objects } = this._sheetDrawingService.getBatchAddOp(allDrawings) as IDrawingJsonUndo1;
const redos: IMutationInfo[] = [{
id: SetDrawingApplyMutation.id,
params: { op: insertOp, unitId, subUnitId, objects, type: DrawingApplyType.INSERT },
}];
const undos: IMutationInfo[] = [{
id: SetDrawingApplyMutation.id,
params: { op: removeOp, unitId, subUnitId, objects, type: DrawingApplyType.REMOVE },
}];
const featureMutations = this._getGroupFeaturePasteMutations({
fromUnitId: this._copyInfo.unitId,
fromSubUnitId: this._copyInfo.subUnitId,
toUnitId: unitId,
toSubUnitId: subUnitId,
idMap,
cloned,
});
redos.push(...featureMutations.redos);
undos.push(...featureMutations.undos);
return { redos, undos };
}
override dispose(): void {
this._copyInfo = null;
this._featurePasteHooks.length = 0;
super.dispose();
}
}

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 { Workbook, Worksheet } from '@univerjs/core';
import type {
IInsertSheetDrawingCommandParams,
IRemoveSheetDrawingCommandParams,
ISetDrawingArrangeCommandParams,
ISetDrawingCommandParams,
} from '@univerjs/sheets-drawing';
import type { LocaleKey } from '../locale/types';
import {
Disposable,
ICommandService,
Inject,
IPermissionService,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
UserManagerService,
} from '@univerjs/core';
import { IRenderManagerService, ObjectType } from '@univerjs/engine-render';
import {
SheetPermissionCheckController,
WorkbookEditablePermission,
WorkbookViewPermission,
WorksheetEditPermission,
WorksheetViewPermission,
} from '@univerjs/sheets';
import {
InsertSheetDrawingCommand,
ISheetDrawingService,
RemoveSheetDrawingCommand,
SetDrawingArrangeCommand,
SetSheetDrawingCommand,
SetSheetDrawingPlacementCommand,
} from '@univerjs/sheets-drawing';
import { combineLatest, distinctUntilChanged, EMPTY, map, switchMap, tap } from 'rxjs';
const drawingObjectTypes = [
ObjectType.IMAGE, // floating image
ObjectType.SHAPE, // shape
ObjectType.CHART, // chart rect
ObjectType.DRAWING_DOM, // floating dom rect
];
export class SheetDrawingPermissionController extends Disposable {
constructor(
@Inject(ICommandService) private _commandService: ICommandService,
@Inject(LocaleService) private _localeService: LocaleService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IPermissionService private readonly _permissionService: IPermissionService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(UserManagerService) private _userManagerService: UserManagerService,
@Inject(SheetPermissionCheckController) private _sheetPermissionCheckController: SheetPermissionCheckController,
@Inject(ISheetDrawingService) private readonly _sheetDrawingService: ISheetDrawingService
) {
super();
this._initDrawingVisible();
this._initDrawingEditable();
this._initViewPermissionChange();
this._initEditPermissionChange();
this._initCommandPermissionCheck();
}
private _initDrawingVisible() {
const workbook$ = this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentUser$ = this._userManagerService.currentUser$;
const combined$ = combineLatest([workbook$, currentUser$]);
this.disposeWithMe(
combined$
.pipe(
switchMap(([workbook, _]) => {
if (!workbook) {
this._sheetDrawingService.setDrawingVisible(false);
return EMPTY;
}
return workbook.activeSheet$.pipe(
tap((sheet) => {
if (!sheet) {
this._sheetDrawingService.setDrawingVisible(false);
return;
}
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const worksheetViewPermission = this._permissionService
.composePermission([
new WorkbookViewPermission(unitId).id,
new WorksheetViewPermission(unitId, subUnitId).id,
])
.every((permission) => permission.value);
if (worksheetViewPermission) {
this._sheetDrawingService.setDrawingVisible(true);
} else {
this._handleDrawingVisibilityFalse(workbook, sheet);
}
})
);
})
)
.subscribe()
);
}
private _handleDrawingVisibilityFalse(workbook: Workbook, sheet: Worksheet) {
this._sheetDrawingService.setDrawingVisible(false);
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (!scene) {
return;
}
const objects = scene.getAllObjectsByOrder();
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.removeObject(object);
}
});
}
private _initDrawingEditable() {
const workbook$ = this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentUser$ = this._userManagerService.currentUser$;
const combined$ = combineLatest([workbook$, currentUser$]);
this.disposeWithMe(
combined$
.pipe(
switchMap(([workbook, _]) => {
if (!workbook) {
this._sheetDrawingService.setDrawingEditable(false);
return EMPTY;
}
return workbook.activeSheet$.pipe(
tap((sheet) => {
if (!sheet) {
this._sheetDrawingService.setDrawingEditable(false);
return;
}
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const worksheetEditPermission = this._permissionService
.composePermission([
new WorkbookEditablePermission(unitId).id,
new WorksheetEditPermission(unitId, subUnitId).id,
])
.every((permission) => permission.value);
if (worksheetEditPermission) {
this._sheetDrawingService.setDrawingEditable(true);
} else {
this._handleDrawingEditableFalse(workbook, sheet);
}
})
);
})
)
.subscribe()
);
}
private _handleDrawingEditableFalse(workbook: Workbook, sheet: Worksheet) {
this._sheetDrawingService.setDrawingEditable(false);
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (!scene) {
return;
}
const objects = scene.getAllObjectsByOrder();
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.detachTransformerFrom(object);
}
});
}
// eslint-disable-next-line max-lines-per-function
private _initViewPermissionChange() {
const workbook$ = this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentUser$ = this._userManagerService.currentUser$;
this.disposeWithMe(
combineLatest([workbook$, currentUser$])
.pipe(
switchMap(([workbook, _]) => {
if (!workbook) return EMPTY;
return workbook.activeSheet$.pipe(
switchMap((sheet) => {
if (!sheet) {
return EMPTY;
}
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (!scene) {
return EMPTY;
}
const transformer = scene.getTransformerByCreate();
const worksheetViewPermission$ = this._permissionService
.composePermission$([
new WorkbookViewPermission(unitId).id,
new WorksheetViewPermission(unitId, subUnitId).id,
])
.pipe(
map((permissions) => permissions.every((item) => item.value)),
distinctUntilChanged()
);
return worksheetViewPermission$.pipe(
map((permission) => ({
permission,
scene,
transformer,
unitId,
subUnitId,
}))
);
})
);
})
)
.subscribe({
next: ({ permission, scene, transformer, unitId, subUnitId }) => {
this._sheetDrawingService.setDrawingVisible(permission);
const objects = scene.getAllObjectsByOrder();
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
if (permission) {
this._sheetDrawingService.addNotification(drawingDataValues);
} else {
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.removeObject(object);
}
});
transformer.clearSelectedObjects();
}
},
complete: () => {
this._sheetDrawingService.setDrawingVisible(true);
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const sheet = workbook?.getActiveSheet();
const unitId = workbook?.getUnitId();
const subUnitId = sheet?.getSheetId();
if (!unitId || !subUnitId) {
return;
}
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
this._sheetDrawingService.addNotification(drawingDataValues);
},
})
);
}
// eslint-disable-next-line max-lines-per-function
private _initEditPermissionChange() {
const workbook$ = this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentUser$ = this._userManagerService.currentUser$;
this.disposeWithMe(
combineLatest([workbook$, currentUser$])
.pipe(
switchMap(([workbook, _]) => {
if (!workbook) {
return EMPTY;
}
return workbook.activeSheet$.pipe(
switchMap((sheet) => {
if (!sheet) {
return EMPTY;
}
const unitId = workbook.getUnitId();
const subUnitId = sheet.getSheetId();
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (!scene) {
return EMPTY;
}
const transformer = scene.getTransformerByCreate();
const composeWorksheetEditPermission$ = this._permissionService
.composePermission$([
new WorkbookEditablePermission(unitId).id,
new WorksheetEditPermission(unitId, subUnitId).id,
])
.pipe(
map((permissions) => permissions.every((item) => item.value)),
distinctUntilChanged()
);
return composeWorksheetEditPermission$.pipe(
map((permission) => ({
permission,
scene,
transformer,
unitId,
subUnitId,
}))
);
})
);
})
)
.subscribe({
next: ({ permission, scene, transformer, unitId, subUnitId }) => {
this._sheetDrawingService.setDrawingEditable(permission);
const objects = scene.getAllObjectsByOrder();
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
if (permission) {
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.attachTransformerTo(object);
}
});
this._sheetDrawingService.addNotification(drawingDataValues);
} else {
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.detachTransformerFrom(object);
}
});
transformer.clearSelectedObjects();
}
},
complete: () => {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const unitId = workbook.getUnitId();
const sheet = workbook.getActiveSheet();
if (!sheet) {
return;
}
const subUnitId = sheet.getSheetId();
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (!scene) {
return;
}
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawingDataValues = Object.values(drawingData);
this._sheetDrawingService.setDrawingEditable(true);
const objects = scene.getAllObjectsByOrder();
objects.forEach((object) => {
if (
drawingObjectTypes.includes(object.objectType) &&
drawingDataValues.some((item) => object.oKey.includes(item.drawingId))
) {
scene.detachTransformerFrom(object);
}
});
},
})
);
}
private _initCommandPermissionCheck() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command) => {
let unitId: string | undefined;
let subUnitId: string | undefined;
if (command.id === InsertSheetDrawingCommand.id || command.id === RemoveSheetDrawingCommand.id || command.id === SetSheetDrawingCommand.id) {
const params = command.params as IInsertSheetDrawingCommandParams | IRemoveSheetDrawingCommandParams | ISetDrawingCommandParams;
const { drawings } = params;
unitId = drawings?.[0]?.unitId;
subUnitId = drawings?.[0]?.subUnitId;
} else if (command.id === SetSheetDrawingPlacementCommand.id) {
const target = getPlacementCommandTarget(command.params);
unitId = target.unitId;
subUnitId = target.subUnitId;
} else if (command.id === SetDrawingArrangeCommand.id) {
const params = command.params as ISetDrawingArrangeCommandParams;
unitId = params.unitId;
subUnitId = params.subUnitId;
}
if (!unitId || !subUnitId) {
return;
}
const permission = this._sheetPermissionCheckController.permissionCheckWithoutRange({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission],
}, unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-drawing-ui.permission.dialog.editErr'));
}
})
);
}
}
function getPlacementCommandTarget(params: object | undefined): {
unitId?: string;
subUnitId?: string;
} {
if (!params) {
return {};
}
const unitId = 'unitId' in params && typeof params.unitId === 'string'
? params.unitId
: undefined;
const subUnitId = 'subUnitId' in params && typeof params.subUnitId === 'string'
? params.subUnitId
: undefined;
return { unitId, subUnitId };
}

View File

@@ -0,0 +1,190 @@
/**
* 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 } from '@univerjs/core';
import type { IFloatDomData } from '@univerjs/sheets-drawing';
import { Disposable, DrawingTypeEnum, Inject, Injector, Tools } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DrawingRenderService } from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetPrintInterceptorService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { ComponentManager } from '@univerjs/ui';
import { mountPrintingFloatDom } from '../views/PrintingFloatDom';
export class SheetDrawingPrintingController extends Disposable {
constructor(
@Inject(SheetPrintInterceptorService) private readonly _sheetPrintInterceptorService: SheetPrintInterceptorService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(ComponentManager) private readonly _componetManager: ComponentManager,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initPrinting();
this._initPrintingDom();
}
private _initPrinting() {
this.disposeWithMe(
this._sheetPrintInterceptorService.interceptor.intercept(
this._sheetPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_COMPONENT_COLLECT,
{
handler: (_param, pos, next) => {
const { unitId, scene, subUnitId, resourceCollector } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[subUnitId];
if (subUnitData) {
subUnitData.order.forEach((id) => {
const drawing = subUnitData.data[id];
if (drawing.hidden) {
return;
}
if (
drawing.drawingType !== DrawingTypeEnum.DRAWING_CHART &&
drawing.drawingType !== DrawingTypeEnum.DRAWING_DOM &&
drawing.drawingType !== DrawingTypeEnum.DRAWING_BLOCK
) {
const resource = this._drawingRenderService.renderDrawing(drawing, scene, { allowInactiveSheet: true });
if (resource) {
resourceCollector.add(Promise.resolve(resource));
}
}
});
}
return next();
},
}
)
);
this.disposeWithMe(
this._sheetPrintInterceptorService.interceptor.intercept(
this._sheetPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_RANGE,
{
handler: (range, pos, next) => {
const { unitId, subUnitId } = pos;
const renderer = this._renderManagerService.getRenderUnitById(unitId);
if (!renderer) {
return next(range);
}
const skeleton = renderer.with(SheetSkeletonManagerService).getSkeletonParam(subUnitId);
if (!skeleton) {
return next(range);
}
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[pos.subUnitId];
if (!subUnitData) {
return next(range);
}
const { scaleX, scaleY } = renderer.scene;
const newRange: IRange = range ? { ...range } : { startColumn: 0, endColumn: 0, endRow: 0, startRow: 0 };
const data = subUnitData.order.map((key) => subUnitData.data[key]);
if (data.length) {
data.forEach((param) => {
if (!param.hidden && !param.groupId && param.transform && Tools.isDefine(param.transform.left) && Tools.isDefine(param.transform.top) && Tools.isDefine(param.transform.width) && Tools.isDefine(param.transform.height)) {
const start = skeleton.skeleton.getCellIndexByOffset(param.transform.left, param.transform.top, scaleX, scaleY, { x: 0, y: 0 });
const end = skeleton.skeleton.getCellIndexByOffset(param.transform.left + param.transform.width, param.transform.top + param.transform.height, scaleX, scaleY, { x: 0, y: 0 });
if (start.column < newRange.startColumn) {
newRange.startColumn = start.column;
}
if (start.row < newRange.startRow) {
newRange.startRow = start.row;
}
if (newRange.endRow < end.row) {
newRange.endRow = end.row;
}
if (newRange.endColumn < end.column) {
newRange.endColumn = end.column;
}
}
});
return next(newRange);
}
return next(range);
},
}
)
);
}
private _initPrintingDom() {
this.disposeWithMe(
this._sheetPrintInterceptorService.interceptor.intercept(
this._sheetPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT,
{
handler: (disposableCollection, pos, next) => {
const { unitId, subUnitId } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[subUnitId];
if (subUnitData) {
const floatDomInfos = subUnitData.order.map((id) => {
const drawing = subUnitData.data[id] as IFloatDomData;
if (drawing.hidden) {
return null;
}
if (drawing.drawingType === DrawingTypeEnum.DRAWING_DOM || drawing.drawingType === DrawingTypeEnum.DRAWING_BLOCK) {
const printingComponentKey = this._sheetPrintInterceptorService.getPrintComponent(drawing.componentKey);
return {
...drawing,
componentKey: this._componetManager.get(printingComponentKey || drawing.componentKey) as any,
};
}
return null;
}).filter(Boolean) as IFloatDomData[];
// Each prepare cycle gets its own React container. Removing the old
// container immediately and unmounting it later prevents its cleanup
// from racing with a new render mounted into the persistent page root.
const printingRoot = document.createElement('div');
printingRoot.style.position = 'absolute';
printingRoot.style.inset = '0';
pos.root.appendChild(printingRoot);
const unmountPrintingFloatDom = mountPrintingFloatDom(
{
floatDomInfos,
scene: pos.scene,
skeleton: pos.skeleton,
worksheet: pos.worksheet,
},
printingRoot,
this._injector
);
disposableCollection?.add(() => {
printingRoot.remove();
queueMicrotask(unmountPrintingFloatDom);
});
return next(disposableCollection);
}
// Do not terminate the interceptor chain on sheets without drawings.
return next(disposableCollection);
},
}
)
);
}
}

View File

@@ -0,0 +1,722 @@
/**
* 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, IDrawingParam, IImageIoServiceParam, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { IRenderContext, IRenderModule, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ISheetLocationBase, WorkbookSelectionModel } from '@univerjs/sheets';
import type {
IInsertSheetDrawingCommandParams,
ISetDrawingArrangeCommandParams,
ISetDrawingCommandParams,
ISheetDrawing,
ISheetDrawingPosition,
} from '@univerjs/sheets-drawing';
import type { LocaleKey } from '../locale/types';
import {
BooleanNumber,
BuildTextUtils,
createDocumentModelWithStyle,
Disposable,
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
generateRandomId,
ICommandService,
IContextService,
IImageIoService,
ImageSourceType,
ImageUploadStatusType,
Inject,
Injector,
IURLImageService,
LocaleService,
PositionedObjectLayoutType,
WrapTextType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { buildDocTransform, docDrawingPositionToTransform } from '@univerjs/docs';
import {
DRAWING_IMAGE_ALLOW_IMAGE_LIST,
DRAWING_IMAGE_COUNT_LIMIT,
DRAWING_IMAGE_HEIGHT_LIMIT,
DRAWING_IMAGE_WIDTH_LIMIT,
getDrawingImageAllowSize,
getImageSize,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
attachRangeWithCoord,
SetRangeValuesCommand,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import {
drawingPositionToTransform,
InsertSheetDrawingCommand,
ISheetDrawingService,
SetDrawingArrangeCommand,
SetSheetDrawingCommand,
transformToAxisAlignPosition,
transformToDrawingPosition,
} from '@univerjs/sheets-drawing';
import { ISheetSelectionRenderService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
import { GroupSheetDrawingCommand } from '../commands/commands/group-sheet-drawing.command';
import { UngroupSheetDrawingCommand } from '../commands/commands/ungroup-sheet-drawing.command';
/**
* Calculate the bounding box after rotation
* @param {number} width Width
* @param {number} height Height
* @param {number} angleDegrees Rotation angle in degrees (0-360)
* @returns {{ rotatedWidth: number; rotatedHeight: number }} Rotated width and height
*/
function rotatedBoundingBox(width: number, height: number, angleDegrees: number): { rotatedWidth: number; rotatedHeight: number } {
const angle = angleDegrees * Math.PI / 180; // Convert angle to radians
const rotatedWidth = Math.abs(width * Math.cos(angle)) + Math.abs(height * Math.sin(angle));
const rotatedHeight = Math.abs(width * Math.sin(angle)) + Math.abs(height * Math.cos(angle));
return { rotatedWidth, rotatedHeight };
}
/**
* Get the size of the drawing within the cell
* @param {IAccessor} accessor Accessor
* @param {ISheetLocationBase} location Cell location
* @param {number} originImageWidth Original image width
* @param {number} originImageHeight Original image height
* @param {number} angle Rotation angle in degrees (0-360)
* @returns {{ width: number; height: number }} Drawing size
*/
export function getDrawingSizeByCell(
accessor: IAccessor,
location: ISheetLocationBase,
originImageWidth: number,
originImageHeight: number,
angle: number
) {
const { rotatedHeight, rotatedWidth } = rotatedBoundingBox(originImageWidth, originImageHeight, angle);
const renderManagerService = accessor.get(IRenderManagerService);
const currentRender = renderManagerService.getRenderUnitById(location.unitId);
if (!currentRender) {
return false;
}
const skeletonManagerService = currentRender.with(SheetSkeletonManagerService);
const skeleton = skeletonManagerService.getSkeletonParam(location.subUnitId)?.skeleton;
if (skeleton == null) {
return false;
}
const cellInfo = skeleton.getCellWithCoordByIndex(location.row, location.col);
const cellWidth = cellInfo.mergeInfo.endX - cellInfo.mergeInfo.startX - 2;
const cellHeight = cellInfo.mergeInfo.endY - cellInfo.mergeInfo.startY - 2;
const imageRatio = rotatedWidth / rotatedHeight;
const imageWidth = Math.ceil(Math.min(cellWidth, cellHeight * imageRatio));
const scale = imageWidth / rotatedWidth;
const realScale = !(scale) || Number.isNaN(scale) ? 0.001 : scale;
return {
width: originImageWidth * realScale,
height: originImageHeight * realScale,
};
}
export class SheetDrawingUpdateController extends Disposable implements IRenderModule {
private readonly _workbookSelections: WorkbookSelectionModel;
constructor(
private readonly _context: IRenderContext<Workbook>,
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@ISheetSelectionRenderService private readonly _selectionRenderService: ISheetSelectionRenderService,
@IImageIoService private readonly _imageIoService: IImageIoService,
@ILocalFileService private readonly _fileOpenerService: ILocalFileService,
@ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IContextService private readonly _contextService: IContextService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(SheetsSelectionsService) selectionManagerService: SheetsSelectionsService,
@Inject(SheetSkeletonService) private readonly _sheetSkeletonService: SheetSkeletonService,
@Inject(Injector) private readonly _injector: Injector,
@IURLImageService private readonly _urlImageService: IURLImageService
) {
super();
this._workbookSelections = selectionManagerService.getWorkbookSelections(this._context.unitId);
this._updateDrawingListener();
this._updateOrderListener();
this._groupDrawingListener();
this._focusDrawingListener();
}
async insertFloatImage(): Promise<boolean> {
const files = await this._fileOpenerService.openFile({
multiple: true,
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
const fileLength = files.length;
if (fileLength > DRAWING_IMAGE_COUNT_LIMIT) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.exceedMaxCount', String(DRAWING_IMAGE_COUNT_LIMIT)),
});
return false;
} else if (fileLength === 0) {
return false;
}
files.forEach(async (file) => await this.insertFloatImageByFile(file));
return true;
}
async insertCellImage(): Promise<boolean> {
const files = await this._fileOpenerService.openFile({
multiple: false,
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
const file = files[0];
if (file) {
await this._insertCellImage(file);
return true;
}
return false;
}
insertCellImageByFile(file: File, location?: ISheetLocationBase) {
return this._insertCellImage(file, location);
}
async insertFloatImageByFile(file: File) {
let imageParam: Nullable<IImageIoServiceParam>;
try {
imageParam = await this._imageIoService.saveImage(file);
} catch (error) {
const type = (error as Error).message;
if (type === ImageUploadStatusType.ERROR_EXCEED_SIZE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.exceedMaxSize', String(getDrawingImageAllowSize() / (1024 * 1024))),
});
} else if (type === ImageUploadStatusType.ERROR_IMAGE_TYPE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.invalidImageType'),
});
} else if (type === ImageUploadStatusType.ERROR_IMAGE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.invalidImage'),
});
}
}
if (imageParam == null) {
return;
}
const info = this._getUnitInfo();
const { unitId, subUnitId } = info;
const { imageId, imageSourceType, source, base64Cache } = imageParam;
const { width, height, image } = await getImageSize(base64Cache || '');
const { width: sceneWidth, height: sceneHeight } = this._context.scene;
this._imageIoService.addImageSourceCache(source, imageSourceType, image);
let scale = 1;
if (width > DRAWING_IMAGE_WIDTH_LIMIT || height > DRAWING_IMAGE_HEIGHT_LIMIT) {
const scaleWidth = DRAWING_IMAGE_WIDTH_LIMIT / width;
const scaleHeight = DRAWING_IMAGE_HEIGHT_LIMIT / height;
scale = Math.max(scaleWidth, scaleHeight);
}
const sheetSkeletonParam = this._sheetSkeletonService.getSkeletonParam(unitId, subUnitId);
if (!sheetSkeletonParam) return;
const { skeleton } = sheetSkeletonParam;
const sheetTransform = this._getImagePosition(width * scale, height * scale, sceneWidth, sceneHeight, skeleton);
if (!sheetTransform) return;
const newTransform = drawingPositionToTransform(sheetTransform, sheetSkeletonParam);
if (!newTransform) return;
const sheetDrawingParam: ISheetDrawing = {
unitId,
subUnitId,
drawingId: imageId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType,
source,
transform: newTransform,
sheetTransform,
axisAlignSheetTransform: transformToAxisAlignPosition(newTransform, skeleton) ?? sheetTransform,
};
return this._commandService.executeCommand<IInsertSheetDrawingCommandParams>(InsertSheetDrawingCommand.id, {
unitId,
drawings: [sheetDrawingParam],
});
}
// eslint-disable-next-line max-lines-per-function
private async _insertCellImage(file: File, location?: ISheetLocationBase) {
let imageParam: Nullable<IImageIoServiceParam>;
try {
imageParam = await this._imageIoService.saveImage(file);
} catch (error) {
const type = (error as Error).message;
if (type === ImageUploadStatusType.ERROR_EXCEED_SIZE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.exceedMaxSize', String(getDrawingImageAllowSize() / (1024 * 1024))),
});
} else if (type === ImageUploadStatusType.ERROR_IMAGE_TYPE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.invalidImageType'),
});
} else if (type === ImageUploadStatusType.ERROR_IMAGE) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('sheets-drawing-ui.update-status.invalidImage'),
});
}
}
if (imageParam == null) {
return false;
}
const { imageId, imageSourceType, source, base64Cache } = imageParam;
const { width, height, image } = await getImageSize(base64Cache || '');
this._imageIoService.addImageSourceCache(source, imageSourceType, image);
const selection = this._workbookSelections.getCurrentLastSelection();
if (!selection) {
return false;
}
// use start row and col when upload image to a merged cell
let row = selection.primary.actualRow;
let col = selection.primary.actualColumn;
if (selection.primary.isMerged) {
row = selection.primary.startRow;
col = selection.primary.startColumn;
}
const docDataModel = createDocumentModelWithStyle('', {});
const imageSize = getDrawingSizeByCell(
this._injector,
{
unitId: this._context.unitId,
subUnitId: this._context.unit.getActiveSheet().getSheetId(),
row,
col,
},
width,
height,
0
);
if (!imageSize) {
return false;
}
const docTransform = buildDocTransform(imageSize.width, imageSize.height);
const docDrawingParam = {
unitId: docDataModel.getUnitId(),
subUnitId: docDataModel.getUnitId(),
drawingId: imageId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType,
source,
transform: docDrawingPositionToTransform(docTransform),
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE, // Insert inline drawing by default.
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
};
const jsonXActions = BuildTextUtils.drawing.add({
documentDataModel: docDataModel,
drawings: [docDrawingParam],
selection: {
collapsed: true,
startOffset: 0,
endOffset: 0,
},
});
if (jsonXActions) {
docDataModel.apply(jsonXActions);
return this._commandService.syncExecuteCommand(SetRangeValuesCommand.id, {
value: {
[location?.row ?? row]: {
[location?.col ?? col]: {
p: (docDataModel.getSnapshot()),
t: 1,
},
},
},
unitId: location?.unitId,
subUnitId: location?.subUnitId,
});
}
return false;
}
async insertCellImageByUrl(url: string, location?: ISheetLocationBase) {
let src = url;
try {
src = await this._urlImageService.getImage(url);
} catch (error) {
console.error(`Failed to get image from URLImageService: ${url}`, error);
}
const { width, height, image } = await getImageSize(src || '');
this._imageIoService.addImageSourceCache(url, ImageSourceType.URL, image);
const selection = this._workbookSelections.getCurrentLastSelection();
if (!selection) {
return false;
}
const docDataModel = createDocumentModelWithStyle('', {});
const imageSize = getDrawingSizeByCell(
this._injector,
{
unitId: this._context.unitId,
subUnitId: this._context.unit.getActiveSheet().getSheetId(),
row: selection.primary.actualRow,
col: selection.primary.actualColumn,
},
width,
height,
0
);
if (!imageSize) {
return false;
}
const docTransform = buildDocTransform(imageSize.width, imageSize.height);
const docDrawingParam = {
unitId: docDataModel.getUnitId(),
subUnitId: docDataModel.getUnitId(),
drawingId: generateRandomId(),
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: url,
transform: docDrawingPositionToTransform(docTransform),
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE, // Insert inline drawing by default.
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
};
const jsonXActions = BuildTextUtils.drawing.add({
documentDataModel: docDataModel,
drawings: [docDrawingParam],
selection: {
collapsed: true,
startOffset: 0,
endOffset: 0,
},
});
if (jsonXActions) {
docDataModel.apply(jsonXActions);
return this._commandService.syncExecuteCommand(SetRangeValuesCommand.id, {
value: {
[location?.row ?? selection.primary.actualRow]: {
[location?.col ?? selection.primary.actualColumn]: {
p: (docDataModel.getSnapshot()),
t: 1,
},
},
},
unitId: location?.unitId,
subUnitId: location?.subUnitId,
});
}
return false;
}
private _getUnitInfo() {
const workbook = this._context.unit;
const worksheet = workbook.getActiveSheet();
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
return {
unitId,
subUnitId,
};
}
private _getImagePosition(imageWidth: number, imageHeight: number, sceneWidth: number, sceneHeight: number, skeleton: SpreadsheetSkeleton): Nullable<ISheetDrawingPosition> {
const selections = this._workbookSelections.getCurrentSelections();
let range: IRange = {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
};
if (selections && selections.length > 0) {
range = selections[selections.length - 1].range;
}
const rangeWithCoord = attachRangeWithCoord(skeleton, range);
if (rangeWithCoord == null) {
return;
}
let { startColumn, startRow, startX, startY } = rangeWithCoord;
let isChangeStart = false;
if (startX + imageWidth > sceneWidth) {
startX = sceneWidth - imageWidth;
if (startX < 0) {
startX = 0;
imageWidth = sceneWidth;
}
isChangeStart = true;
}
if (startY + imageHeight > sceneHeight) {
startY = sceneHeight - imageHeight;
if (startY < 0) {
startY = 0;
imageHeight = sceneHeight;
}
isChangeStart = true;
}
if (isChangeStart) {
const newCoord = this._selectionRenderService.getCellWithCoordByOffset(startX, startY);
if (newCoord == null) {
return;
}
startX = newCoord.startX;
startY = newCoord.startY;
startColumn = newCoord.actualColumn;
startRow = newCoord.actualRow;
}
const from = {
column: startColumn,
columnOffset: 0,
row: startRow,
rowOffset: 0,
};
const endSelectionCell = this._selectionRenderService.getCellWithCoordByOffset(startX + imageWidth, startY + imageHeight);
if (endSelectionCell == null) {
return;
}
const to = {
column: endSelectionCell.actualColumn,
columnOffset: startX + imageWidth - endSelectionCell.startX,
row: endSelectionCell.actualRow,
rowOffset: startY + imageHeight - endSelectionCell.startY,
};
return {
from,
to,
};
}
private _updateOrderListener() {
this.disposeWithMe(this._drawingManagerService.featurePluginOrderUpdate$.subscribe((params) => {
const { unitId, subUnitId, drawingIds, arrangeType } = params;
this._commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId,
subUnitId,
drawingIds,
arrangeType,
} as ISetDrawingArrangeCommandParams);
}));
}
private _updateDrawingListener() {
this.disposeWithMe(this._drawingManagerService.featurePluginUpdate$.subscribe((params) => {
const drawings: Partial<ISheetDrawing>[] = [];
if (params.length === 0) {
return;
}
(params as IImageData[]).forEach((param) => {
const { unitId, subUnitId, drawingId, transform } = param;
const sheetSkeletonParam = this._sheetSkeletonService.getSkeletonParam(unitId, subUnitId);
if (!transform || !sheetSkeletonParam) {
return;
}
const { skeleton } = sheetSkeletonParam;
const sheetDrawing = this._sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (sheetDrawing == null || sheetDrawing.unitId !== this._context.unitId) {
return;
}
const sheetTransform = transformToDrawingPosition({ ...sheetDrawing.transform, ...transform }, skeleton);
const axisAlignSheetTransform = transformToAxisAlignPosition({ ...sheetDrawing.transform, ...transform }, skeleton);
if (sheetTransform == null || axisAlignSheetTransform == null) {
return;
}
const newDrawing: Partial<ISheetDrawing> = {
...param,
transform: { ...sheetDrawing.transform, ...transform, ...drawingPositionToTransform(sheetTransform, sheetSkeletonParam) },
sheetTransform: { ...sheetTransform },
axisAlignSheetTransform: { ...axisAlignSheetTransform },
};
drawings.push(newDrawing);
});
if (drawings.length > 0) {
this._commandService.executeCommand(SetSheetDrawingCommand.id, {
unitId: params[0].unitId,
drawings,
} as ISetDrawingCommandParams);
}
}));
}
private _getSheetTransformByParam(param: IDrawingParam, isCreate: boolean): Nullable<{
sheetTransform: ISheetDrawingPosition;
axisAlignSheetTransform: ISheetDrawingPosition;
}> {
const { unitId, subUnitId, drawingId, transform } = param;
const skeleton = this._sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!transform || !skeleton) {
return null;
}
const sheetDrawing = this._sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId });
let sheetDrawingTransform = sheetDrawing?.transform;
if (isCreate) {
sheetDrawingTransform = {};
}
if (!isCreate && (!sheetDrawing || sheetDrawing.unitId !== this._context.unitId)) {
return null;
}
const sheetTransform = transformToDrawingPosition({ ...sheetDrawingTransform, ...transform }, skeleton);
const axisAlignSheetTransform = transformToAxisAlignPosition({ ...sheetDrawingTransform, ...transform }, skeleton);
if (!sheetTransform || !axisAlignSheetTransform) {
return null;
}
return { sheetTransform, axisAlignSheetTransform };
}
private _groupDrawingListener() {
this.disposeWithMe(this._drawingManagerService.featurePluginGroupUpdate$.subscribe((params) => {
const grpParams = [];
for (const param of params) {
const parent = param.parent;
const grpSheetTransform = this._getSheetTransformByParam(parent, true);
const children = [];
for (const child of param.children) {
const childSheetTransformInfo = this._getSheetTransformByParam(child, false);
if (childSheetTransformInfo != null) {
children.push({
...child,
sheetTransform: childSheetTransformInfo.sheetTransform,
axisAlignSheetTransform: childSheetTransformInfo.axisAlignSheetTransform,
});
}
}
const grpParam = {
parent: { ...parent, sheetTransform: grpSheetTransform?.sheetTransform, axisAlignSheetTransform: grpSheetTransform?.axisAlignSheetTransform },
children,
};
grpParams.push(grpParam);
}
if (grpParams.length > 0) {
this._commandService.executeCommand(GroupSheetDrawingCommand.id, grpParams);
const { unitId, subUnitId, drawingId } = params[0].parent;
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{ unitId, subUnitId, drawingId }]);
}
}));
this.disposeWithMe(this._drawingManagerService.featurePluginUngroupUpdate$.subscribe((params) => {
const unGroupParams = [];
for (const param of params) {
const { children } = param;
const childParams = [];
for (const child of children) {
const childSheetTransform = this._getSheetTransformByParam(child, false);
if (childSheetTransform != null) {
childParams.push({
...child,
sheetTransform: childSheetTransform.sheetTransform,
axisAlignSheetTransform: childSheetTransform.axisAlignSheetTransform,
});
}
}
unGroupParams.push({
...param,
children: childParams,
});
}
this._commandService.executeCommand(UngroupSheetDrawingCommand.id, unGroupParams);
}));
}
private _focusDrawingListener() {
this.disposeWithMe(
this._drawingManagerService.focus$.subscribe((params) => {
if (params == null || params.length === 0) {
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._sheetDrawingService.focusDrawing([]);
} else {
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true);
this._sheetDrawingService.focusDrawing(params);
}
})
);
}
}

View File

@@ -0,0 +1,96 @@
/**
* 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 { IContextService } from '@univerjs/core';
import type { IShortcutItem } from '@univerjs/ui';
import type { IMoveDrawingsCommandParams } from '../../commands/commands/move-drawings.command';
import { Direction, FOCUSING_COMMON_DRAWINGS, FOCUSING_FX_BAR_EDITOR, FOCUSING_PANEL_EDITOR, FOCUSING_SHAPE_TEXT_EDITOR, FOCUSING_SHEET } from '@univerjs/core';
import { KeyCode } from '@univerjs/ui';
import { DeleteDrawingsCommand } from '../../commands/commands/delete-drawings.command';
import { MoveDrawingsCommand } from '../../commands/commands/move-drawings.command';
export function whenSheetDrawingFocused(contextService: IContextService): boolean {
return (
contextService.getContextValue(FOCUSING_SHEET) &&
!contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) &&
!contextService.getContextValue(FOCUSING_PANEL_EDITOR) &&
contextService.getContextValue(FOCUSING_COMMON_DRAWINGS) &&
!contextService.getContextValue(FOCUSING_SHAPE_TEXT_EDITOR)
);
}
export const MoveDrawingDownShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDrawingsCommand.id,
description: 'sheets-drawing-ui.shortcut.drawing-move-down',
group: '4_drawing-view',
groupTitle: 'sheets-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_DOWN,
priority: 100,
preconditions: whenSheetDrawingFocused,
staticParameters: {
direction: Direction.DOWN,
},
};
export const MoveDrawingUpShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDrawingsCommand.id,
description: 'sheets-drawing-ui.shortcut.drawing-move-up',
group: '4_drawing-view',
groupTitle: 'sheets-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_UP,
priority: 100,
preconditions: whenSheetDrawingFocused,
staticParameters: {
direction: Direction.UP,
},
};
export const MoveDrawingLeftShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDrawingsCommand.id,
description: 'sheets-drawing-ui.shortcut.drawing-move-left',
group: '4_drawing-view',
groupTitle: 'sheets-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_LEFT,
priority: 100,
preconditions: whenSheetDrawingFocused,
staticParameters: {
direction: Direction.LEFT,
},
};
export const MoveDrawingRightShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDrawingsCommand.id,
description: 'sheets-drawing-ui.shortcut.drawing-move-right',
group: '4_drawing-view',
groupTitle: 'sheets-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_RIGHT,
priority: 100,
preconditions: whenSheetDrawingFocused,
staticParameters: {
direction: Direction.RIGHT,
},
};
export const DeleteDrawingsShortcutItem: IShortcutItem = {
id: DeleteDrawingsCommand.id,
description: 'sheets-drawing-ui.shortcut.drawing-delete',
group: '4_drawing-view',
groupTitle: 'sheets-drawing-ui.shortcut.drawing-view',
priority: 100,
// when focusing on any other input tag do not trigger this shortcut
preconditions: whenSheetDrawingFocused,
binding: KeyCode.DELETE,
mac: KeyCode.BACKSPACE,
};

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 { Disposable, DrawingTypeEnum, ICommandService, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IMenuManagerService, IShortcutService, ISidebarService } from '@univerjs/ui';
import { DeleteDrawingsCommand } from '../commands/commands/delete-drawings.command';
import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command';
import { GroupSheetDrawingCommand } from '../commands/commands/group-sheet-drawing.command';
import { InsertCellImageCommand, InsertFloatImageCommand } from '../commands/commands/insert-image.command';
import { MoveDrawingsCommand } from '../commands/commands/move-drawings.command';
import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command';
import { UngroupSheetDrawingCommand } from '../commands/commands/ungroup-sheet-drawing.command';
import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation';
import { SidebarSheetDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
import { menuSchema } from '../menu/schema';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../views/sheet-image-panel/component-name';
import {
DeleteDrawingsShortcutItem,
MoveDrawingDownShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
MoveDrawingUpShortcutItem,
} from './shortcuts/drawing.shortcut';
export class SheetDrawingUIController extends Disposable {
constructor(
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@ICommandService private readonly _commandService: ICommandService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(ISidebarService) private readonly _sidebarService: ISidebarService
) {
super();
this._init();
}
private _initCustomComponents(): void {
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands() {
[
InsertFloatImageCommand,
InsertCellImageCommand,
SidebarSheetDrawingOperation,
EditSheetDrawingOperation,
GroupSheetDrawingCommand,
UngroupSheetDrawingCommand,
MoveDrawingsCommand,
DeleteDrawingsCommand,
SaveCellImagesCommand,
FlipSheetDrawingCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initShortcuts(): void {
[
// sheet drawing shortcuts
MoveDrawingDownShortcutItem,
MoveDrawingUpShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
DeleteDrawingsShortcutItem,
].forEach((item) => {
this.disposeWithMe(this._shortcutService.registerShortcut(item));
});
}
private _initImagePanel(): void {
this.disposeWithMe(this._drawingManagerService.focus$.subscribe((drawings) => {
if (!this._sidebarService.visible || this._sidebarService.options.id !== COMPONENT_SHEET_DRAWING_PANEL) {
return;
}
if (drawings.length === 1 && drawings[0].drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
this._sidebarService.close(COMPONENT_SHEET_DRAWING_PANEL);
}));
}
private _init(): void {
this._initCommands();
this._initCustomComponents();
this._initMenus();
this._initShortcuts();
this._initImagePanel();
}
}

View File

@@ -0,0 +1,136 @@
/**
* 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 { LifecycleService, LifecycleStages } from '@univerjs/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SheetCanvasFloatDomManagerService } from '../../../services/canvas-float-dom-manager.service';
import { registerSheetsDrawingFloatingHostCapability, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES, touchSheetsDrawingFloatingHostCapabilityWhenReady } from '../register-sheets-drawing-floating-host';
const registerDependencies = vi.hoisted(() => vi.fn());
const touchDependencies = vi.hoisted(() => vi.fn());
vi.mock('@univerjs/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/core')>();
return {
...actual,
registerDependencies,
touchDependencies,
};
});
describe('registerSheetsDrawingFloatingHostCapability', () => {
beforeEach(() => {
registerDependencies.mockClear();
touchDependencies.mockClear();
});
it('registers the minimal sheet float DOM host dependency', () => {
const injector = {
has: () => false,
};
registerSheetsDrawingFloatingHostCapability(injector as never);
expect(SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES).toEqual([
[SheetCanvasFloatDomManagerService],
]);
expect(registerDependencies).toHaveBeenCalledWith(injector, [
[SheetCanvasFloatDomManagerService],
]);
expect(touchDependencies).toHaveBeenCalledWith(injector, [
[SheetCanvasFloatDomManagerService],
]);
});
it('does not register the floating host dependency twice', () => {
const injector = {
has: (token: unknown) => token === SheetCanvasFloatDomManagerService,
};
registerSheetsDrawingFloatingHostCapability(injector as never);
expect(registerDependencies).not.toHaveBeenCalled();
expect(touchDependencies).toHaveBeenCalledWith(injector, [
[SheetCanvasFloatDomManagerService],
]);
});
it('defers touching the floating host until lifecycle ready', async () => {
let resolveReady: () => void = () => {};
const onStage = vi.fn(() => new Promise<void>((resolve) => {
resolveReady = resolve;
}));
const lifecycleService = {
stage: LifecycleStages.Starting,
onStage,
};
const injector = {
has: (token: unknown) => token === LifecycleService,
get: (token: unknown) => {
if (token === LifecycleService) {
return lifecycleService;
}
throw new Error('Unexpected dependency');
},
};
registerSheetsDrawingFloatingHostCapability(injector as never);
expect(onStage).toHaveBeenCalledWith(LifecycleStages.Ready);
expect(touchDependencies).not.toHaveBeenCalled();
resolveReady();
await Promise.resolve();
expect(touchDependencies).toHaveBeenCalledWith(injector, [
[SheetCanvasFloatDomManagerService],
]);
});
it('allows the full sheets drawing UI plugin to touch an already registered floating host', async () => {
let resolveReady: () => void = () => {};
const onStage = vi.fn(() => new Promise<void>((resolve) => {
resolveReady = resolve;
}));
const lifecycleService = {
stage: LifecycleStages.Starting,
onStage,
};
const injector = {
has: (token: unknown) => token === LifecycleService || token === SheetCanvasFloatDomManagerService,
get: (token: unknown) => {
if (token === LifecycleService) {
return lifecycleService;
}
throw new Error('Unexpected dependency');
},
};
touchSheetsDrawingFloatingHostCapabilityWhenReady(injector as never);
expect(registerDependencies).not.toHaveBeenCalled();
expect(onStage).toHaveBeenCalledWith(LifecycleStages.Ready);
expect(touchDependencies).not.toHaveBeenCalled();
resolveReady();
await Promise.resolve();
expect(touchDependencies).toHaveBeenCalledWith(injector, [
[SheetCanvasFloatDomManagerService],
]);
});
});

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 {
registerSheetsDrawingFloatingHostCapability,
SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES,
touchSheetsDrawingFloatingHostCapabilityWhenReady,
} from './register-sheets-drawing-floating-host';

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.
*/
import type { Dependency, Injector } from '@univerjs/core';
import { LifecycleService, LifecycleStages, registerDependencies, touchDependencies } from '@univerjs/core';
import { SheetCanvasFloatDomManagerService } from '../../services/canvas-float-dom-manager.service';
export const SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES: Dependency[] = [
[SheetCanvasFloatDomManagerService],
];
export function registerSheetsDrawingFloatingHostCapability(injector: Injector): void {
if (!injector.has(SheetCanvasFloatDomManagerService)) {
registerDependencies(injector, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES);
}
touchSheetsDrawingFloatingHostCapabilityWhenReady(injector);
}
function touchSheetsDrawingFloatingHostCapability(injector: Injector): void {
touchDependencies(injector, [[SheetCanvasFloatDomManagerService]]);
}
export function touchSheetsDrawingFloatingHostCapabilityWhenReady(injector: Injector): void {
if (!injector.has(LifecycleService)) {
touchSheetsDrawingFloatingHostCapability(injector);
return;
}
const lifecycleService = injector.get(LifecycleService);
if (lifecycleService.stage >= LifecycleStages.Ready) {
touchSheetsDrawingFloatingHostCapability(injector);
return;
}
lifecycleService.onStage(LifecycleStages.Ready).then(() => {
touchSheetsDrawingFloatingHostCapability(injector);
});
}

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 * from './floating-host';

View File

@@ -0,0 +1,827 @@
/**
* 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, ICellData, IDrawingParam, IWorkbookData, Workbook } from '@univerjs/core';
import type { IRender, Rect, Scene } from '@univerjs/engine-render';
import type { ISheetFloatDom, ISheetImage } from '@univerjs/sheets-drawing';
import {
Disposable,
DrawingTypeEnum,
EventSubject,
ICommandService,
ILogService,
ImageSourceType,
Inject,
Injector,
IUndoRedoService,
IUniverInstanceService,
IURLImageService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { IDrawingManagerService, UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, RenderManagerService, SpreadsheetSkeleton } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import {
InsertSheetDrawingCommand,
ISheetDrawingService,
RemoveSheetDrawingCommand,
SetSheetDrawingCommand,
UniverSheetsDrawingPlugin,
} from '@univerjs/sheets-drawing';
import {
BatchSaveImagesService,
IBatchSaveImagesService,
SheetCanvasFloatDomManagerService,
} from '@univerjs/sheets-drawing-ui';
import { ISheetSelectionRenderService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import sheetsEnUS from '@univerjs/sheets/locale/en-US';
import { CanvasFloatDomService, ComponentManager } from '@univerjs/ui';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-drawing-ui/facade';
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
};
function createDrawingUITestBed(dependencies: Dependency[] = []) {
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 {
this._injector.get(IUndoRedoService);
const baseDependencies: Dependency[] = [
[ComponentManager],
[CanvasFloatDomService],
[IBatchSaveImagesService, { useClass: BatchSaveImagesService }],
[SheetCanvasFloatDomManagerService],
...dependencies,
];
if (!hasDependency(dependencies, IRenderManagerService)) {
baseDependencies.unshift([IRenderManagerService, { useClass: RenderManagerService }]);
}
baseDependencies.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverDrawingPlugin);
univer.registerPlugin(UniverSheetsDrawingPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
Tools.deepClone(TEST_WORKBOOK_DATA)
);
get(IUniverInstanceService).focusUnit(workbook.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
const localeService = get(LocaleService);
localeService.load({ [LocaleType.EN_US]: sheetsEnUS });
localeService.setLocale(LocaleType.EN_US);
return {
univer,
injector,
workbook,
};
}
function hasDependency(dependencies: Dependency[], token: unknown): boolean {
for (const dependency of dependencies) {
if (Array.isArray(dependency) && dependency[0] === token) {
return true;
}
}
return false;
}
class TestRenderManagerService extends Disposable implements Partial<IRenderManagerService> {
private _render: IRender | null = null;
readonly created$ = new Subject<IRender>();
readonly disposed$ = new Subject<string>();
configure(render: IRender): void {
this._render = render;
}
getRenderUnitById(): IRender | null {
return this._render;
}
has(): boolean {
return this._render != null;
}
getRenderAll(): Map<string, IRender> {
const renderMap = new Map<string, IRender>();
if (this._render) {
renderMap.set(this._render.unitId, this._render);
}
return renderMap;
}
}
class TestSheetSkeletonManager {
readonly currentSkeleton$: BehaviorSubject<{ sheetId: string } | null>;
constructor(private readonly _skeleton: SpreadsheetSkeleton) {
this.currentSkeleton$ = new BehaviorSubject<{ sheetId: string } | null>({ sheetId: 'sheet1' });
}
getSkeletonParam() {
return {
sheetId: 'sheet1',
skeleton: this._skeleton,
};
}
}
class TestSheetSelectionRenderService {
getCellWithCoordByOffset(x: number, y: number) {
const column = Math.max(0, Math.floor(x / 72));
const row = Math.max(0, Math.floor(y / 24));
return {
actualColumn: column,
actualRow: row,
startColumn: column,
endColumn: column,
startRow: row,
endRow: row,
startX: column * 72,
endX: (column + 1) * 72,
startY: row * 24,
endY: (row + 1) * 24,
isMerged: false,
isMergedMainCell: false,
};
}
}
class TestRenderScene {
private readonly _objects = new Map<string, Rect>();
readonly onScrollAfter$ = new EventSubject();
private readonly _transformer = {
clearControlByIds: () => undefined,
clearSelectedObjects: () => undefined,
debounceRefreshControls: () => undefined,
};
getAncestorScale() {
return { scaleX: 1, scaleY: 1 };
}
getViewport() {
return {
left: 0,
top: 0,
right: 360,
bottom: 220,
viewportScrollX: 0,
viewportScrollY: 0,
onScrollAfter$: this.onScrollAfter$,
};
}
getMainViewport() {
return this.getViewport();
}
getTransformerByCreate() {
return this._transformer;
}
getTransformer() {
return this._transformer;
}
addObject(object: Rect) {
object.parent = this as never;
this._objects.set(object.oKey, object);
}
removeObject(object: Rect) {
this._objects.delete(object.oKey);
}
getObject(key: string) {
return this._objects.get(key);
}
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
}
attachTransformerTo() { }
detachTransformerFrom() { }
}
function createRender(skeleton: SpreadsheetSkeleton, scene: Scene): IRender {
const sheetSkeletonManager = new TestSheetSkeletonManager(skeleton);
const sheetSelectionRenderService = new TestSheetSelectionRenderService();
const canvasElement = document.createElement('div');
const activated$ = new BehaviorSubject(true);
return {
unitId: 'test',
type: UniverInstanceType.UNIVER_SHEET,
engine: {
clientRect$: new BehaviorSubject({ width: 360, height: 220 }),
getCanvasElement: () => canvasElement,
} as unknown as IRender['engine'],
scene,
mainComponent: null,
components: new Map(),
isMainScene: true,
render: { scene },
activated$,
with: (dependency: unknown) => {
if (dependency === SheetSkeletonManagerService) {
return sheetSkeletonManager;
}
if (dependency === ISheetSelectionRenderService) {
return sheetSelectionRenderService;
}
return null;
},
deactivate: () => activated$.next(false),
activate: () => activated$.next(true),
isDisposed: () => false,
} as unknown as IRender;
}
function createDrawingUITestBedWithRender() {
const testBed = createDrawingUITestBed([
[IRenderManagerService, { useClass: TestRenderManagerService }],
]);
const modelWorksheet = testBed.workbook.getActiveSheet();
const skeleton = testBed.injector.createInstance(
SpreadsheetSkeleton,
modelWorksheet,
testBed.workbook.getStyles()
).calculate() as SpreadsheetSkeleton;
const renderManager = testBed.injector.get(IRenderManagerService) as unknown as TestRenderManagerService;
renderManager.configure(createRender(skeleton, new TestRenderScene() as unknown as Scene));
return testBed;
}
describe('sheets-drawing-ui facade', () => {
let univer: Univer;
let injector: Injector;
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createDrawingUITestBed();
univer = testBed.univer;
injector = testBed.injector;
univerAPI = FUniver.newAPI(injector);
});
afterEach(() => {
univer?.dispose();
});
it('uses real facade instances for empty float dom and cell image flows', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const range = worksheet.getRange('A1:B2');
expect(worksheet.getFloatDomById('missing')).toBeNull();
expect(worksheet.getAllFloatDoms()).toEqual([]);
expect(worksheet.removeFloatDom('missing')).toBe(worksheet);
await expect(range.insertCellImageAsync('https://univer.ai/image.png')).resolves.toBe(false);
await expect(range.saveCellImagesAsync()).resolves.toBe(false);
await expect(worksheet.saveCellImagesAsync()).resolves.toBe(false);
});
it('registers a URL image downloader through the facade', async () => {
const downloader = async (url: string) => `downloaded:${url}`;
const disposable = univerAPI.registerURLImageDownloader(downloader);
const urlImageService = injector.get(IURLImageService);
await expect(urlImageService.getImage('https://univer.ai/image.png')).resolves.toBe('downloaded:https://univer.ai/image.png');
disposable.dispose();
await expect(urlImageService.getImage('https://univer.ai/image.png')).resolves.toBe('https://univer.ai/image.png');
});
it('adds, updates and removes float doms through worksheet facade state', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const added = worksheet.addFloatDomToPosition({
componentKey: 'OrderCard',
initPosition: {
startX: 20,
startY: 30,
endX: 140,
endY: 90,
},
data: { label: 'Draft order' },
allowTransform: true,
}, 'order-card');
await Promise.resolve();
expect(added).toBeNull();
expect(injector.get(IDrawingManagerService).getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'order-card',
})).toBeUndefined();
expect(worksheet.addFloatDomToRange(worksheet.getRange('A1:B2'), {
componentKey: 'RangeCard',
data: { label: 'Range order' },
}, {}, 'range-card')).toBeNull();
expect(worksheet.addFloatDomToColumnHeader(1, {
componentKey: 'HeaderCard',
data: { label: 'Header order' },
}, { width: 80, height: 24, marginX: 0, marginY: 0 }, 'header-card')).toBeNull();
expect(worksheet.updateFloatDom('missing-dom', { data: { label: 'Ignored' } })).toBe(worksheet);
expect(worksheet.batchUpdateFloatDoms([{ id: 'missing-dom', config: { data: { label: 'Ignored' } } }])).toBe(worksheet);
worksheet.removeFloatDom('order-card');
});
it('adds, reads, updates, batch updates and disposes float doms through the worksheet facade', async () => {
univer.dispose();
const testBed = createDrawingUITestBedWithRender();
univer = testBed.univer;
injector = testBed.injector;
univerAPI = FUniver.newAPI(injector);
const sheetDrawingService = injector.get(ISheetDrawingService);
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const primary = worksheet.addFloatDomToPosition({
componentKey: 'OrderCard',
initPosition: {
startX: 72,
startY: 24,
endX: 172,
endY: 64,
},
data: { label: 'Draft order' },
allowTransform: true,
}, 'order-card')!;
const secondary = worksheet.addFloatDomToPosition({
componentKey: 'StatusCard',
initPosition: {
startX: 144,
startY: 48,
endX: 244,
endY: 88,
},
data: { label: 'Pending' },
allowTransform: true,
}, 'status-card')!;
await Promise.resolve();
expect(primary.id).toBe('order-card');
expect(secondary.id).toBe('status-card');
expect(worksheet.getFloatDomById('order-card')).toEqual(expect.objectContaining({
id: 'order-card',
componentKey: 'OrderCard',
allowTransform: true,
data: { label: 'Draft order' },
position: expect.objectContaining({
left: 72,
top: 24,
width: 100,
height: 40,
}),
}));
const allDoms = worksheet.getAllFloatDoms();
const allDomIds: string[] = [];
for (const dom of allDoms) {
allDomIds.push(dom.id);
}
expect(allDomIds).toEqual(['order-card', 'status-card']);
expect(worksheet.updateFloatDom('order-card', {
componentKey: 'OrderCardUpdated',
allowTransform: false,
data: { label: 'Approved' },
position: {
left: 90,
top: 36,
width: 120,
height: 50,
},
})).toBe(worksheet);
expect(sheetDrawingService.getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'order-card',
})).toEqual(expect.objectContaining({
componentKey: 'OrderCardUpdated',
allowTransform: false,
data: { label: 'Approved' },
transform: expect.objectContaining({
left: 90,
top: 36,
width: 120,
height: 50,
}),
}));
expect(worksheet.batchUpdateFloatDoms([
{
id: 'order-card',
config: {
data: { label: 'Shipped' },
position: {
left: 100,
top: 40,
width: 130,
height: 54,
},
},
},
{
id: 'status-card',
config: {
componentKey: 'StatusCardUpdated',
data: { label: 'Done' },
allowTransform: false,
},
},
{
id: 'missing-card',
config: {
data: { label: 'Ignored' },
},
},
])).toBe(worksheet);
expect(worksheet.getFloatDomById('order-card')).toEqual(expect.objectContaining({
componentKey: 'OrderCardUpdated',
data: { label: 'Shipped' },
}));
expect(sheetDrawingService.getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'order-card',
})).toEqual(expect.objectContaining({
transform: expect.objectContaining({
left: 100,
top: 40,
width: 130,
height: 54,
}),
}));
expect(worksheet.getFloatDomById('status-card')).toEqual(expect.objectContaining({
componentKey: 'StatusCardUpdated',
allowTransform: false,
data: { label: 'Done' },
}));
primary.dispose();
expect(worksheet.getFloatDomById('order-card')).toBeNull();
worksheet.removeFloatDom('status-card');
expect(worksheet.getAllFloatDoms()).toEqual([]);
});
it('omits float doms without backing drawing params', async () => {
univer.dispose();
const testBed = createDrawingUITestBedWithRender();
univer = testBed.univer;
injector = testBed.injector;
univerAPI = FUniver.newAPI(injector);
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const sheetDrawingService = injector.get(ISheetDrawingService);
worksheet.addFloatDomToPosition({
componentKey: 'OrderCard',
initPosition: { startX: 20, startY: 30, endX: 140, endY: 90 },
}, 'order-card');
worksheet.addFloatDomToPosition({
componentKey: 'MissingCard',
initPosition: { startX: 40, startY: 50, endX: 160, endY: 110 },
}, 'missing-card');
await Promise.resolve();
const drawingData = sheetDrawingService.getDrawingData('test', 'sheet1');
delete drawingData['missing-card'];
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'missing-card' })).toBeUndefined();
expect(worksheet.getAllFloatDoms().map((dom) => dom.id)).toEqual(['order-card']);
});
it('saves cell images from range and worksheet selections', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const savedFiles: string[] = [];
const clickedDownloads: string[] = [];
const originalShowDirectoryPicker = window.showDirectoryPicker;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const originalClick = HTMLAnchorElement.prototype.click;
window.showDirectoryPicker = async () => ({
getFileHandle: async (name: string) => {
savedFiles.push(name);
return {
createWritable: async () => ({
write: async () => {},
close: async () => {},
}),
};
},
}) as unknown as FileSystemDirectoryHandle;
URL.createObjectURL = () => 'blob:cell-image';
URL.revokeObjectURL = () => {};
HTMLAnchorElement.prototype.click = function click() {
clickedDownloads.push(this.download);
};
const model = worksheet.getWorkbook().getSheetBySheetId(worksheet.getSheetId())!;
model.getCellMatrix().setValue(0, 0, createCellImage('image-a', 'data:image/png;base64,aGVsbG8='));
model.getCellMatrix().setValue(1, 1, createCellImage('image-b', 'data:image/jpeg;base64,aGVsbG8='));
model.getCellMatrix().setValue(0, 2, { v: 'Customer/West' });
await expect(worksheet.getRange('A1').saveCellImagesAsync()).resolves.toBe(true);
expect(clickedDownloads).toEqual(['A1.png']);
await expect(worksheet.saveCellImagesAsync(undefined, [worksheet.getRange('A1')])).resolves.toBe(true);
expect(clickedDownloads).toEqual(['A1.png', 'A1.png']);
await expect(worksheet.getRange('A1:B2').saveCellImagesAsync({ useCellAddress: false, useColumnIndex: 2 })).resolves.toBe(true);
expect(savedFiles).toEqual(['Customer_West.png', 'B2.jpg']);
savedFiles.length = 0;
await expect(worksheet.saveCellImagesAsync({ useCellAddress: true, useColumnIndex: 2 }, [worksheet.getRange('A1:B2')])).resolves.toBe(true);
expect(savedFiles).toEqual(['A1_Customer_West.png', 'B2.jpg']);
URL.createObjectURL = () => {
throw new Error('download denied');
};
await expect(worksheet.getRange('A1').saveCellImagesAsync()).resolves.toBe(false);
await expect(worksheet.saveCellImagesAsync(undefined, [worksheet.getRange('A1')])).resolves.toBe(false);
URL.createObjectURL = () => 'blob:cell-image';
window.showDirectoryPicker = async () => {
throw new Error('directory denied');
};
await expect(worksheet.getRange('A1:B2').saveCellImagesAsync()).resolves.toBe(false);
await expect(worksheet.saveCellImagesAsync(undefined, [worksheet.getRange('A1:B2')])).resolves.toBe(false);
window.showDirectoryPicker = originalShowDirectoryPicker;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
HTMLAnchorElement.prototype.click = originalClick;
});
it('emits float dom add, update and delete events from drawing commands', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const commandService = injector.get(ICommandService);
const drawingManagerService = injector.get(IDrawingManagerService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const floatDom = createFloatDom('float-dom-1');
const logs: string[] = [];
const disposables = [
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomAdd, ({ drawings }) => logs.push(`before-add:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomAdded, ({ drawings }) => logs.push(`add:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomUpdate, ({ drawings }) => logs.push(`before-update:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomUpdated, ({ drawings }) => logs.push(`update:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomDelete, ({ drawings }) => logs.push(`before-delete:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomDeleted, ({ drawings }) => logs.push(`delete:${drawings[0]}`)),
];
commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, {
unitId: workbook.getId(),
drawings: [floatDom],
});
drawingManagerService.registerDrawingData('test', sheetDrawingService.getDrawingDataForUnit('test') as Record<string, { data: Record<string, IDrawingParam>; order: string[] }>);
drawingManagerService.initializeNotification('test');
expect(commandService.syncExecuteCommand(SetSheetDrawingCommand.id, {
unitId: workbook.getId(),
subUnitId: 'sheet1',
drawings: [{ ...floatDom, transform: { ...floatDom.transform, left: 24 }, data: { label: 'Updated order' } }],
})).toBe(true);
expect(commandService.syncExecuteCommand(RemoveSheetDrawingCommand.id, {
unitId: workbook.getId(),
subUnitId: 'sheet1',
drawings: [{ unitId: workbook.getId(), subUnitId: 'sheet1', drawingId: floatDom.drawingId, drawingType: DrawingTypeEnum.DRAWING_DOM }],
})).toBe(true);
expect(logs).toEqual([
'before-add:float-dom-1',
'add:float-dom-1',
'before-update:float-dom-1',
'update:float-dom-1',
'before-delete:float-dom-1',
'delete:float-dom-1',
]);
disposables.forEach((disposable) => disposable.dispose());
});
it('cancels float dom commands before they mutate drawing state', () => {
const commandService = injector.get(ICommandService);
const drawingManagerService = injector.get(IDrawingManagerService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const floatDom = createFloatDom('guarded-dom');
const disposables = [
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomAdd, (event) => {
if (event.drawings[0].drawingId === 'guarded-dom') event.cancel = true;
}),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomUpdate, (event) => {
if (event.drawings[0].drawingId === 'existing-dom') event.cancel = true;
}),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomDelete, (event) => {
if (event.drawings[0].drawingId === 'existing-dom') event.cancel = true;
}),
];
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [floatDom],
})).toBe(false);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'guarded-dom' })).toBeUndefined();
const existing = createFloatDom('existing-dom');
sheetDrawingService.registerDrawingData('test', {
sheet1: { data: { [existing.drawingId]: existing }, order: [existing.drawingId] },
});
drawingManagerService.registerDrawingData('test', {
sheet1: { data: { [existing.drawingId]: existing as unknown as IDrawingParam }, order: [existing.drawingId] },
});
drawingManagerService.initializeNotification('test');
expect(commandService.syncExecuteCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawings: [{ ...existing, data: { label: 'Blocked update' } }],
})).toBe(false);
expect((sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'existing-dom' }) as ISheetFloatDom | undefined)?.data)
.toEqual({ label: 'Order card' });
expect(commandService.syncExecuteCommand(RemoveSheetDrawingCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawings: [{ unitId: 'test', subUnitId: 'sheet1', drawingId: 'existing-dom', drawingType: DrawingTypeEnum.DRAWING_DOM }],
})).toBe(false);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'existing-dom' })).toBeDefined();
disposables.forEach((disposable) => disposable.dispose());
});
it('does not emit float dom events for over-grid image drawing commands', () => {
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const image = createOverGridImage('image-drawing');
const logs: string[] = [];
const disposables = [
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomAdd, ({ drawings }) => logs.push(`before-add:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomAdded, ({ drawings }) => logs.push(`add:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomUpdate, ({ drawings }) => logs.push(`before-update:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomUpdated, ({ drawings }) => logs.push(`update:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.BeforeFloatDomDelete, ({ drawings }) => logs.push(`before-delete:${drawings[0].drawingId}`)),
univerAPI.addEvent(univerAPI.Event.FloatDomDeleted, ({ drawings }) => logs.push(`delete:${drawings[0]}`)),
];
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id)).toBe(false);
expect(commandService.syncExecuteCommand(SetSheetDrawingCommand.id)).toBe(false);
expect(commandService.syncExecuteCommand(RemoveSheetDrawingCommand.id)).toBe(false);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [image],
})).toBe(true);
expect(commandService.syncExecuteCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawings: [{ ...image, source: 'data:image/png;base64,Zm9v' }],
})).toBe(true);
expect(commandService.syncExecuteCommand(RemoveSheetDrawingCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawings: [{ unitId: 'test', subUnitId: 'sheet1', drawingId: image.drawingId, drawingType: DrawingTypeEnum.DRAWING_IMAGE }],
})).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: image.drawingId })).toBeUndefined();
expect(logs).toEqual([]);
disposables.forEach((disposable) => disposable.dispose());
});
});
function createFloatDom(drawingId: string): ISheetFloatDom {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_DOM,
componentKey: 'FloatDom',
allowTransform: true,
data: { label: 'Order card' },
transform: {
left: 10,
top: 20,
width: 120,
height: 80,
},
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}
function createCellImage(drawingId: string, source: string): ICellData {
return {
p: {
id: `${drawingId}-doc`,
documentStyle: {},
drawingsOrder: [drawingId],
drawings: {
[drawingId]: {
drawingId,
source,
imageSourceType: ImageSourceType.BASE64,
},
},
} as unknown as ICellData['p'],
};
}
function createOverGridImage(drawingId: string): ISheetImage {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,aGVsbG8=',
transform: {
left: 10,
top: 10,
width: 20,
height: 20,
},
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 2, column: 2, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 2, column: 2, rowOffset: 0, columnOffset: 0 },
},
};
}

View File

@@ -0,0 +1,246 @@
/**
* 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 { IEventBase } from '@univerjs/core/facade';
import type { ISheetFloatDom } from '@univerjs/sheets-drawing';
import type { FWorkbook } from '@univerjs/sheets/facade';
import { FEventName } from '@univerjs/core/facade';
/**
* @ignore
*/
interface IFSheetsDrawingUIEventNameMixin {
/**
* Triggered before float dom insertion.
* @see {@link IBeforeFloatDomAddEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomAdd, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the insertion operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomAdd: 'BeforeFloatDomAdd';
/**
* Triggered after float dom insertion.
* @see {@link IFloatDomAddedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomAdded, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomAdded: 'FloatDomAdded';
/**
* Triggered before float dom update.
* @see {@link IBeforeFloatDomUpdateEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomUpdate, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the update operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomUpdate: 'BeforeFloatDomUpdate';
/**
* Triggered after float dom update.
* @see {@link IFloatDomUpdatedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomUpdated, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomUpdated: 'FloatDomUpdated';
/**
* Triggered before float dom deletion.
* @see {@link IBeforeFloatDomDeleteEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomDelete, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the deletion operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomDelete: 'BeforeFloatDomDelete';
/**
* Triggered after float dom deletion.
* @see {@link IFloatDomDeletedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomDeleted, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomDeleted: 'FloatDomDeleted';
}
export class FSheetsDrawingUIEventNameMixin extends FEventName implements IFSheetsDrawingUIEventNameMixin {
override get BeforeFloatDomAdd(): 'BeforeFloatDomAdd' {
return 'BeforeFloatDomAdd' as const;
}
override get FloatDomAdded(): 'FloatDomAdded' {
return 'FloatDomAdded' as const;
}
override get BeforeFloatDomUpdate(): 'BeforeFloatDomUpdate' {
return 'BeforeFloatDomUpdate' as const;
}
override get FloatDomUpdated(): 'FloatDomUpdated' {
return 'FloatDomUpdated' as const;
}
override get BeforeFloatDomDelete(): 'BeforeFloatDomDelete' {
return 'BeforeFloatDomDelete' as const;
}
override get FloatDomDeleted(): 'FloatDomDeleted' {
return 'FloatDomDeleted' as const;
}
}
/**
* @ignore
*/
export interface IBeforeFloatDomAddEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawings to be added.
*/
drawings: ISheetFloatDom[];
}
export interface IFloatDomAddedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawings that were added.
*/
drawings: ISheetFloatDom[];
}
export interface IBeforeFloatDomUpdateEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawings to be updated.
*/
drawings: ISheetFloatDom[];
}
export interface IFloatDomUpdatedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawings that were updated.
*/
drawings: ISheetFloatDom[];
}
export interface IBeforeFloatDomDeleteEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawings to be deleted.
*/
drawings: ISheetFloatDom[];
}
export interface IFloatDomDeletedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of float dom drawing ids that were deleted.
*/
drawings: string[];
}
interface ISheetsDrawingUIEventParamConfig {
BeforeFloatDomAdd: IBeforeFloatDomAddEventParams;
FloatDomAdded: IFloatDomAddedEventParams;
BeforeFloatDomUpdate: IBeforeFloatDomUpdateEventParams;
FloatDomUpdated: IFloatDomUpdatedEventParams;
BeforeFloatDomDelete: IBeforeFloatDomDeleteEventParams;
FloatDomDeleted: IFloatDomDeletedEventParams;
}
FEventName.extend(FSheetsDrawingUIEventNameMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEventName extends IFSheetsDrawingUIEventNameMixin { }
interface IEventParamConfig extends ISheetsDrawingUIEventParamConfig { }
}

View File

@@ -0,0 +1,165 @@
/**
* 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 { ISheetLocationBase } from '@univerjs/sheets';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { FileNamePart, IBatchSaveImagesService, SheetDrawingUpdateController } from '@univerjs/sheets-drawing-ui';
import { FRange } from '@univerjs/sheets/facade';
/**
* Options for saving cell images
*/
export interface ISaveCellImagesOptions {
/**
* Whether to use cell address in file name (e.g., A1, B2)
* @default true
*/
useCellAddress?: boolean;
/**
* Column index to use for file name (0-based). If specified, the value from this column will be used in file name.
*/
useColumnIndex?: number;
}
export interface IFRangeSheetsDrawingUIMixin {
/**
* Inserts an image into the current cell.
*
* @param {string | File} file File or URL string
* @returns {Promise<boolean>} True if the image is inserted successfully, otherwise false
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Insert an image into the cell A10
* const fRange = fWorksheet.getRange('A10');
* const result = await fRange.insertCellImageAsync('https://avatars.githubusercontent.com/u/61444807?s=48&v=4');
* console.log(result);
* ```
*/
insertCellImageAsync(file: File | string): Promise<boolean>;
/**
* Save all cell images in this range to the file system.
* This method will open a directory picker dialog and save all images to the selected directory.
*
* @param {ISaveCellImagesOptions} [options] Options for saving images
* @returns {Promise<boolean>} True if images are saved successfully, otherwise false
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Save all cell images in range A1:D10
* const fRange = fWorksheet.getRange('A1:D10');
*
* // Save with default options (using cell address as file name)
* await fRange.saveCellImagesAsync();
*
* // Save with custom options
* await fRange.saveCellImagesAsync({
* useCellAddress: true,
* useColumnIndex: 0, // Use values from column A for file names
* });
* ```
*/
saveCellImagesAsync(options?: ISaveCellImagesOptions): Promise<boolean>;
}
export class FRangeSheetsDrawingUIMixin extends FRange implements IFRangeSheetsDrawingUIMixin {
override async insertCellImageAsync(file: File | string): Promise<boolean> {
const renderManagerService = this._injector.get(IRenderManagerService);
const controller = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._injector.get(IUniverInstanceService), renderManagerService)
?.with(SheetDrawingUpdateController);
if (!controller) {
return false;
}
const location: ISheetLocationBase = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
row: this.getRow(),
col: this.getColumn(),
};
if (typeof file === 'string') {
return controller.insertCellImageByUrl(file, location);
} else {
return controller.insertCellImageByFile(file, location);
}
}
override async saveCellImagesAsync(options?: ISaveCellImagesOptions): Promise<boolean> {
const batchSaveService = this._injector.get(IBatchSaveImagesService);
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const range = this.getRange();
// Get images in the range
const images = batchSaveService.getCellImagesFromRanges(unitId, subUnitId, [range]);
if (images.length === 0) {
return false;
}
// If only one image, download directly
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Build config from options
const fileNameParts: FileNamePart[] = [];
const useCellAddress = options?.useCellAddress ?? true;
const useColumnIndex = options?.useColumnIndex;
if (useCellAddress) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
if (useColumnIndex !== undefined) {
fileNameParts.push(FileNamePart.COLUMN_VALUE);
}
// Ensure at least one naming option
if (fileNameParts.length === 0) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
try {
await batchSaveService.saveImagesWithContext(images, {
fileNameParts,
columnIndex: useColumnIndex,
}, unitId, subUnitId);
return true;
} catch (error) {
console.error('Failed to save images:', error);
return false;
}
}
}
FRange.extend(FRangeSheetsDrawingUIMixin);
declare module '@univerjs/sheets/facade' {
interface FRange extends IFRangeSheetsDrawingUIMixin { }
}

View File

@@ -0,0 +1,289 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IDrawingSearch, Injector } from '@univerjs/core';
import type { IInsertSheetDrawingCommandParams, IRemoveSheetDrawingCommandParams, ISetDrawingCommandParams, ISheetFloatDom } from '@univerjs/sheets-drawing';
import type { IBeforeFloatDomAddEventParams, IBeforeFloatDomDeleteEventParams, IBeforeFloatDomUpdateEventParams, IFloatDomAddedEventParams, IFloatDomDeletedEventParams, IFloatDomUpdatedEventParams } from './f-event';
import { CanceledError, DrawingTypeEnum, ICommandService, IURLImageService } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { IDrawingManagerService } from '@univerjs/drawing';
import { InsertSheetDrawingCommand, RemoveSheetDrawingCommand, SetSheetDrawingCommand } from '@univerjs/sheets-drawing';
export interface IFUniverSheetsDrawingUIMixin {
/**
* Register a custom image downloader for URL images
* @param downloader The downloader function that takes a URL and returns a base64 string
* @returns A disposable object to unregister the downloader
* @example
* ```ts
* const disposable = univerAPI.registerURLImageDownloader(async (url) => {
* const response = await fetch(url);
* const blob = await response.blob();
* const base64 = await new Promise<string>((resolve) => {
* const reader = new FileReader();
* reader.onloadend = () => resolve(reader.result as string);
* reader.readAsDataURL(blob);
* });
* return base64;
* });
* ```
*/
registerURLImageDownloader(downloader: (url: string) => Promise<string>): IDisposable;
}
/**
* @ignore
*/
export class FUniverSheetsDrawingUIMixin extends FUniver implements IFUniverSheetsDrawingUIMixin {
/**
* @ignore
*/
// eslint-disable-next-line max-lines-per-function
override _initialize(injector: Injector): void {
const commandService = injector.get(ICommandService);
// Float DOM Add Events
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeFloatDomAdd,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== InsertSheetDrawingCommand.id) return;
const params = commandInfo.params as IInsertSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const floatDomDrawings = drawings.filter(
(drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_DOM
) as ISheetFloatDom[];
if (floatDomDrawings.length === 0) {
return;
}
const eventParams: IBeforeFloatDomAddEventParams = {
workbook,
drawings: floatDomDrawings,
};
this.fireEvent(this.Event.BeforeFloatDomAdd, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.FloatDomAdded,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== InsertSheetDrawingCommand.id) return;
const params = commandInfo.params as IInsertSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const floatDomDrawings = drawings.filter(
(drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_DOM
) as ISheetFloatDom[];
if (floatDomDrawings.length === 0) {
return;
}
const eventParams: IFloatDomAddedEventParams = {
workbook,
drawings: floatDomDrawings,
};
this.fireEvent(this.Event.FloatDomAdded, eventParams);
})
)
);
// Float DOM Update Events
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeFloatDomUpdate,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetSheetDrawingCommand.id) return;
const params = commandInfo.params as ISetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const drawingManagerService = injector.get(IDrawingManagerService);
const floatDomDrawings: ISheetFloatDom[] = [];
drawings.forEach((drawing) => {
const dom = drawingManagerService.getDrawingByParam(drawing as IDrawingSearch) as ISheetFloatDom;
if (dom?.drawingType === DrawingTypeEnum.DRAWING_DOM) {
floatDomDrawings.push(dom);
}
});
if (floatDomDrawings.length === 0) {
return;
}
const eventParams: IBeforeFloatDomUpdateEventParams = {
workbook,
drawings: floatDomDrawings,
};
this.fireEvent(this.Event.BeforeFloatDomUpdate, eventParams);
if (eventParams.cancel) {
drawingManagerService.updateNotification(drawings as IDrawingSearch[]);
throw new CanceledError();
}
})
)
);
// Float DOM Update Events
this.disposeWithMe(
this.registerEventHandler(
this.Event.FloatDomUpdated,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetSheetDrawingCommand.id) return;
const params = commandInfo.params as ISetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const drawingManagerService = injector.get(IDrawingManagerService);
const floatDomDrawings: ISheetFloatDom[] = [];
drawings.forEach((drawing) => {
const dom = drawingManagerService.getDrawingByParam(drawing as IDrawingSearch) as ISheetFloatDom;
if (dom?.drawingType === DrawingTypeEnum.DRAWING_DOM) {
floatDomDrawings.push(dom);
}
});
if (floatDomDrawings.length === 0) {
return;
}
const eventParams: IFloatDomUpdatedEventParams = {
workbook,
drawings: floatDomDrawings,
};
this.fireEvent(this.Event.FloatDomUpdated, eventParams);
})
)
);
// Float DOM Delete Events
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeFloatDomDelete,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== RemoveSheetDrawingCommand.id) return;
const params = commandInfo.params as IRemoveSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const drawingManagerService = injector.get(IDrawingManagerService);
const { drawings } = params;
const floatDomDrawings = drawings
.map((drawing) => drawingManagerService.getDrawingByParam(drawing))
.filter((drawing): drawing is ISheetFloatDom =>
drawing?.drawingType === DrawingTypeEnum.DRAWING_DOM
);
if (floatDomDrawings.length === 0) {
return;
}
const eventParams: IBeforeFloatDomDeleteEventParams = {
workbook,
drawings: floatDomDrawings,
};
this.fireEvent(this.Event.BeforeFloatDomDelete, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.FloatDomDeleted,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== RemoveSheetDrawingCommand.id) return;
const params = commandInfo.params as IRemoveSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const floatDomDrawingIds: string[] = [];
for (let i = 0; i < drawings.length; i++) {
const drawing = drawings[i];
if (drawing.drawingType === DrawingTypeEnum.DRAWING_DOM) {
floatDomDrawingIds.push(drawing.drawingId);
}
}
if (floatDomDrawingIds.length === 0) {
return;
}
const eventParams: IFloatDomDeletedEventParams = {
workbook,
drawings: floatDomDrawingIds,
};
this.fireEvent(this.Event.FloatDomDeleted, eventParams);
})
)
);
}
override registerURLImageDownloader(downloader: (url: string) => Promise<string>): IDisposable {
const urlImageService = this._injector.get(IURLImageService);
return urlImageService.registerURLImageDownloader(downloader);
}
}
FUniver.extend(FUniverSheetsDrawingUIMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FUniver extends IFUniverSheetsDrawingUIMixin { }
}

View File

@@ -0,0 +1,733 @@
/**
* 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 { ITransformState, Nullable } from '@univerjs/core';
import type { ISheetFloatDom } from '@univerjs/sheets-drawing';
import type { ICanvasFloatDom, ICanvasFloatDomInfo, IDOMAnchor } from '@univerjs/sheets-drawing-ui';
import type { IFComponentKey } from '@univerjs/sheets-ui/facade';
import type { FRange } from '@univerjs/sheets/facade';
import type { ISaveCellImagesOptions } from './f-range';
import { SheetSkeletonService } from '@univerjs/sheets';
import { ISheetDrawingService, SetSheetDrawingCommand, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing';
import { FileNamePart, IBatchSaveImagesService, SheetCanvasFloatDomManagerService } from '@univerjs/sheets-drawing-ui';
import { transformComponentKey } from '@univerjs/sheets-ui/facade';
import { FWorksheet } from '@univerjs/sheets/facade';
import { ComponentManager } from '@univerjs/ui';
// why omit this key? if componentKey is missing, then which component should be used?
export interface IFICanvasFloatDom extends Omit<ICanvasFloatDom, 'componentKey' | 'unitId' | 'subUnitId'>, IFComponentKey { }
export interface IFCanvasFloatDomResult extends Omit<ICanvasFloatDom, 'componentKey' | 'unitId' | 'subUnitId' | 'initPosition'>,
IFComponentKey,
Pick<ICanvasFloatDomInfo, 'id'> {
position: ITransformState;
id: string;
}
/**
* @ignore
*/
export interface IFWorksheetDrawingUIMixin {
/**
* Get float dom by id
* @param {string} id - float dom id
* @returns {IFCanvasFloatDomResult | null} float dom info or null if not found
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const floatDom = fWorksheet.getFloatDomById('myFloatDomId');
* if (floatDom) {
* console.log('Float dom position:', floatDom.position);
* console.log('Component key:', floatDom.componentKey);
* console.log('Custom data:', floatDom.data);
* }
* ```
*/
getFloatDomById(id: string): Nullable<IFCanvasFloatDomResult>;
/**
* Get all float doms in current worksheet
* @returns {IFCanvasFloatDomResult[]} array of float dom info
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const allFloatDoms = fWorksheet.getAllFloatDoms();
* allFloatDoms.forEach(floatDom => {
* console.log('Float dom ID:', floatDom.id);
* console.log('Position:', floatDom.position);
* });
* ```
*/
getAllFloatDoms(): IFCanvasFloatDomResult[];
/**
* Update float dom position and properties
* @param {string} id - float dom id
* @param {Partial<IFCanvasFloatDomResult>} config - new float dom config
* @returns {FWorksheet} The worksheet instance for chaining
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const firstFloatDom = fWorksheet.getAllFloatDoms()[0];
*
* if (!firstFloatDom) return;
*
* // Update first float dom position and size
* fWorksheet.updateFloatDom(firstFloatDom.id, {
* position: {
* left: 100,
* top: 100,
* width: 200,
* height: 150,
* angle: 45, // rotate 45 degrees
* }
* });
*
* // Update first float dom data
* fWorksheet.updateFloatDom(firstFloatDom.id, {
* data: {
* label: 'Updated Label',
* color: '#ff0000'
* }
* });
*
* // Disable the first float dom from transform
* fWorksheet.updateFloatDom(firstFloatDom.id, {
* allowTransform: false
* });
* ```
*/
updateFloatDom(id: string, config: Partial<IFCanvasFloatDomResult>): this;
/**
* Batch update float doms
* @param {Array<{id: string, config: Partial<IFCanvasFloatDomResult>}>} updates - array of update configs
* @returns {FWorksheet} The worksheet instance for chaining
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Update multiple float doms at once
* const allFloatDoms = fWorksheet.getAllFloatDoms();
* fWorksheet.batchUpdateFloatDoms(allFloatDoms.map((floatDom, index) => {
* if (floatDom.id === 'MyFloatDomId') {
* return {
* id: floatDom.id,
* config: {
* position: {
* left: 100,
* top: 100
* },
* data: {
* label: 'Updated'
* }
* }
* }
* }
*
* return {
* id: floatDom.id,
* config: {
* position: {
* left: 300,
* top: 100
* }
* }
* }
* }));
* ```
*/
batchUpdateFloatDoms(updates: Array<{ id: string; config: Partial<IFCanvasFloatDomResult> }>): this;
/**
* Remove float dom by id
* @param {string} id - float dom id
* @returns {FWorksheet} The worksheet instance for chaining
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const firstFloatDom = fWorksheet.getAllFloatDoms()[0];
*
* if (!firstFloatDom) return;
*
* // Remove the first float dom
* fWorksheet.removeFloatDom(firstFloatDom.id);
* ```
*/
removeFloatDom(id: string): this;
/**
* Add a float dom to position.
* @param {IFICanvasFloatDom} layer - The float dom layer configuration.
* @param {string} [id] - The float dom id, if not given will be auto generated.
* @returns float dom id and dispose function
* @example
* ```tsx
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // You should register components at an appropriate time (e.g., when Univer is loaded).
* // This is a React component. For other frameworks, pass a matching adapter option, such as `{ framework: 'vue3' }`.
* univerAPI.registerComponent(
* 'myFloatDom',
* ({ data }) => (
* <div style={{ width: '100%', height: '100%', background: '#fff', border: '1px solid #ccc', boxSizing: 'border-box' }}>
* popup content:
* {' '}
* {data?.label}
* </div>
* ),
* );
*
* // Add a floating DOM
* // If disposable is null, floating DOM addition failed
* const disposable = fWorksheet.addFloatDomToPosition({
* componentKey: 'myFloatDom',
* initPosition: {
* startX: 100,
* endX: 300,
* startY: 100,
* endY: 200,
* },
*
* // Component data
* data: {
* label: 'hahah',
* },
* });
* console.log(disposable?.id); // The id of the floating DOM
*
* // Remove the floating DOM after 2 seconds
* setTimeout(() => {
* disposable?.dispose();
* }, 2000);
* ```
*/
addFloatDomToPosition(layer: IFICanvasFloatDom, id?: string): Nullable<{
id: string;
dispose: () => void;
}>;
/**
* Add dom over range to FloatDOM, And FloatDOM is registerComponent(BuiltInUIPart.CONTENT)
* @param {FRange} range - The range to add the float dom.
* @param {Partial<IFICanvasFloatDom>} layer - The float dom layer configuration.
* @param {Partial<IDOMAnchor>} domLayout - The anchor configuration of the float dom.
* @param {string} [id] - The float dom id, if not given will be auto generated
* @returns float dom id and dispose function
* @example
* ```tsx
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Register a range loading component
* const RangeLoading = () => {
* const divStyle = {
* width: '100%',
* height: '100%',
* backgroundColor: '#fff',
* border: '1px solid #ccc',
* boxSizing: 'border-box' as const,
* display: 'flex',
* justifyContent: 'center',
* alignItems: 'center',
* textAlign: 'center' as const,
* transformOrigin: 'top left',
* };
*
* return (
* <div style={divStyle}>
* Loading...
* </div>
* );
* };
* univerAPI.registerComponent('RangeLoading', RangeLoading);
*
* // Add the range loading component covering the range A1:C3
* const fRange = fWorksheet.getRange('A1:C3');
* const disposable = fWorksheet.addFloatDomToRange(fRange, { componentKey: 'RangeLoading' }, {}, 'myRangeLoading');
* console.log(disposable?.id); // The id of the floating DOM
*
* // Remove the floating DOM after 2 seconds
* setTimeout(() => {
* disposable?.dispose();
* }, 2000);
*
* // another example-------------------
* // Register a float button component
* const FloatButton = () => {
* const divStyle = {
* width: '100px',
* height: '30px',
* backgroundColor: '#fff',
* border: '1px solid #ccc',
* boxSizing: 'border-box' as const,
* display: 'flex',
* justifyContent: 'center',
* alignItems: 'center',
* textAlign: 'center' as const,
* cursor: 'pointer',
* };
*
* const clickHandler = () => {
* console.warn('click');
* };
*
* return (
* <div style={divStyle} onClick={clickHandler}>
* FloatButton
* </div>
* );
* };
* univerAPI.registerComponent('FloatButton', FloatButton);
*
* // Add the float button to the range A5:C7, position is start from A5 cell, and width is 100px, height is 30px, margin is 100% of range width and height
* const fRange2 = fWorksheet.getRange('A5:C7');
* const disposable2 = fWorksheet.addFloatDomToRange(
* fRange2,
* {
* componentKey: 'FloatButton',
* },
* {
* width: 100,
* height: 30,
* marginX: '100%', // margin percent to range width, or pixel
* marginY: '100%'
* },
* 'myFloatButton'
* );
* console.log(disposable2?.id); // The id of the floating DOM
* ```
*/
addFloatDomToRange(range: FRange, layer: Partial<IFICanvasFloatDom>, domLayout: Partial<IDOMAnchor>, id?: string): Nullable<{
id: string;
dispose: () => void;
}>;
/**
* Add dom at column header, And FloatDOM is registerComponent(BuiltInUIPart.CONTENT)
* @param {number} column - The column index to add the float dom.
* @param {Partial<IFICanvasFloatDom>} layer - The float dom layer configuration.
* @param {IDOMAnchor} domPos - The anchor configuration of the float dom.
* @param {string} [id] - The float dom id, if not given will be auto generated
* @returns float dom id and dispose function
* @example
* ```tsx
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Register a float button component
* const FloatButton = () => {
* const divStyle = {
* width: '100px',
* height: '30px',
* backgroundColor: '#fff',
* border: '1px solid #ccc',
* boxSizing: 'border-box' as const,
* display: 'flex',
* justifyContent: 'center',
* alignItems: 'center',
* textAlign: 'center' as const,
* cursor: 'pointer',
* };
*
* const clickHandler = () => {
* console.warn('click');
* };
*
* return (
* <div style={divStyle} onClick={clickHandler}>
* FloatButton
* </div>
* );
* };
* univerAPI.registerComponent('FloatButton', FloatButton);
*
* // Add the float button to the column D header, position is right align, width is 100px, height is 30px, margin is 0
* const disposable = fWorksheet.addFloatDomToColumnHeader(
* 3,
* {
* componentKey: 'FloatButton',
* allowTransform: false,
* },
* {
* width: 100,
* height: 30,
* marginX: 0,
* marginY: 0,
* horizonOffsetAlign: 'right',
* },
* 'myFloatButton'
* );
* console.log(disposable?.id); // The id of the floating DOM
*
* // Remove the float button after 2 seconds
* setTimeout(() => {
* disposable?.dispose();
* }, 2000);
* ```
*/
addFloatDomToColumnHeader(column: number, layer: Partial<IFICanvasFloatDom>, domPos: IDOMAnchor, id?: string): Nullable<{
id: string;
dispose: () => void;
}>;
/**
* Save all cell images from specified ranges to the file system.
* This method will open a directory picker dialog and save all images to the selected directory.
*
* @param {ISaveCellImagesOptions} [options] - Options for saving images
* @param {FRange[]} [ranges] - The ranges to get cell images from. If not provided, all images in the worksheet will be saved.
* @returns {Promise<boolean>} True if images are saved successfully, otherwise false
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Save cell images from multiple ranges
* const range1 = fWorksheet.getRange('A1:B10');
* const range2 = fWorksheet.getRange('D1:E10');
*
* // Save with default options (using cell address as file name)
* await fWorksheet.saveCellImagesAsync(undefined, [range1, range2]);
*
* // Save with custom options
* await fWorksheet.saveCellImagesAsync({
* useCellAddress: true,
* useColumnIndex: 2, // Use values from column C for file names
* }, [range1, range2]);
* ```
*/
saveCellImagesAsync(options?: ISaveCellImagesOptions, ranges?: FRange[]): Promise<boolean>;
}
export class FWorksheetDrawingUIMixin extends FWorksheet implements IFWorksheetDrawingUIMixin {
override getFloatDomById(id: string): Nullable<IFCanvasFloatDomResult> {
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const info = floatDomService.getFloatDomInfo(id);
if (!info) return null;
const { unitId, subUnitId } = info;
const { rect } = info;
const state = rect.getState();
const { left = 0, top = 0, width = 0, height = 0, flipX = false, flipY = false, angle = 0, skewX = 0, skewY = 0 } = state;
const drawingParm = this._injector.get(ISheetDrawingService).getDrawingByParam({
drawingId: info.id,
unitId,
subUnitId,
})! as ISheetFloatDom;
if (!drawingParm) return null;
return {
position: {
left,
top,
width,
height,
flipX,
flipY,
angle,
skewX,
skewY,
},
componentKey: drawingParm.componentKey,
allowTransform: drawingParm.allowTransform,
data: drawingParm.data,
id: info.id,
};
}
override getAllFloatDoms(): IFCanvasFloatDomResult[] {
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
return Array.from(floatDomService.getFloatDomsBySubUnitId(unitId, subUnitId).values())
.map((info): IFCanvasFloatDomResult | null => {
const { rect } = info;
const drawingParm = this._injector.get(ISheetDrawingService).getDrawingByParam({
drawingId: info.id,
unitId,
subUnitId,
})! as ISheetFloatDom;
if (!drawingParm) return null;
const { left, top, width, height, flipX, flipY, angle, skewX, skewY } = rect.getState();
return {
position: {
left,
top,
width,
height,
flipX,
flipY,
angle,
skewX,
skewY,
},
componentKey: drawingParm.componentKey,
allowTransform: drawingParm.allowTransform,
data: drawingParm.data,
id: info.id,
};
})
.filter((dom): dom is IFCanvasFloatDomResult => dom != null);
}
override updateFloatDom(id: string, config: Partial<Omit<IFCanvasFloatDomResult, 'id'>>): this {
const sheetSkeletonService = this._injector.get(SheetSkeletonService);
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const info = floatDomService.getFloatDomInfo(id);
if (!info) return this;
const { unitId, subUnitId } = info;
const skeleton = sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!skeleton) return this;
const drawingParm = this._injector.get(ISheetDrawingService).getDrawingByParam({
unitId,
subUnitId,
drawingId: id,
})! as ISheetFloatDom;
const newParam: ISheetFloatDom = {
...drawingParm,
componentKey: config.componentKey || drawingParm.componentKey,
allowTransform: config.allowTransform !== undefined ? config.allowTransform : drawingParm.allowTransform,
data: config.data || drawingParm.data,
sheetTransform: config.position
? transformToDrawingPosition(config.position, skeleton) ?? drawingParm.sheetTransform
: drawingParm.sheetTransform,
transform: {
...drawingParm.transform,
...config.position, // Merge with existing transform
},
axisAlignSheetTransform: config.position
? transformToAxisAlignPosition(config.position, skeleton) ?? drawingParm.sheetTransform
: drawingParm.sheetTransform,
};
const res = this._commandService.syncExecuteCommand(SetSheetDrawingCommand.id, { unitId, subUnitId, drawings: [newParam] });
if (!res) {
throw new Error('updateFloatDom failed');
}
return this;
}
override batchUpdateFloatDoms(updates: Array<{ id: string; config: Partial<Omit<IFCanvasFloatDomResult, 'id'>> }>): this {
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const drawingService = this._injector.get(ISheetDrawingService);
const sheetSkeletonService = this._injector.get(SheetSkeletonService);
const drawings: ISheetFloatDom[] = [];
for (const update of updates) {
const info = floatDomService.getFloatDomInfo(update.id);
if (!info) continue;
const { unitId, subUnitId } = info;
const skeleton = sheetSkeletonService.getSkeleton(unitId, subUnitId);
if (!skeleton) continue;
const drawingParm = drawingService.getDrawingByParam({
unitId,
subUnitId,
drawingId: update.id,
}) as ISheetFloatDom;
if (!drawingParm) continue;
const newParam: ISheetFloatDom = {
...drawingParm,
componentKey: update.config.componentKey || drawingParm.componentKey,
allowTransform: update.config.allowTransform !== undefined ? update.config.allowTransform : drawingParm.allowTransform,
data: update.config.data || drawingParm.data,
sheetTransform: update.config.position
? transformToDrawingPosition(update.config.position, skeleton) ?? drawingParm.sheetTransform
: drawingParm.sheetTransform,
transform: {
...drawingParm.transform,
...update.config.position, // Merge with existing transform
},
axisAlignSheetTransform: update.config.position
? transformToAxisAlignPosition(update.config.position, skeleton) ?? drawingParm.sheetTransform
: drawingParm.sheetTransform,
};
drawings.push(newParam);
}
if (drawings.length > 0) {
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const res = this._commandService.syncExecuteCommand(SetSheetDrawingCommand.id, { unitId, subUnitId, drawings });
if (!res) {
throw new Error('batchUpdateFloatDoms failed');
}
}
return this;
}
override removeFloatDom(id: string): this {
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
floatDomService.removeFloatDom(id);
return this;
}
override addFloatDomToPosition(layer: IFICanvasFloatDom, id?: string): Nullable<{
id: string;
dispose: () => void;
}> {
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const { key, disposableCollection } = transformComponentKey(layer, this._injector.get(ComponentManager));
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const res = floatDomService.addFloatDomToPosition({ ...layer, componentKey: key, unitId, subUnitId }, id);
if (res) {
disposableCollection.add(res.dispose);
return {
id: res.id,
dispose: (): void => {
disposableCollection.dispose();
res.dispose();
},
};
}
disposableCollection.dispose();
return null;
}
override addFloatDomToRange(fRange: FRange, layer: IFICanvasFloatDom, domLayout: IDOMAnchor, id?: string): Nullable<{
id: string;
dispose: () => void;
}> {
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const { key, disposableCollection } = transformComponentKey(layer, this._injector.get(ComponentManager));
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const res = floatDomService.addFloatDomToRange(fRange.getRange(), { ...layer, componentKey: key, unitId, subUnitId }, domLayout, id);
if (res) {
disposableCollection.add(res.dispose);
return {
id: res.id,
dispose: (): void => {
disposableCollection.dispose();
res.dispose();
},
};
}
disposableCollection.dispose();
return null;
}
override addFloatDomToColumnHeader(column: number, layer: IFICanvasFloatDom, domLayout: IDOMAnchor, id?: string): Nullable<{
id: string;
dispose: () => void;
}> {
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const { key, disposableCollection } = transformComponentKey(layer, this._injector.get(ComponentManager));
const floatDomService = this._injector.get(SheetCanvasFloatDomManagerService);
const domRangeDispose = floatDomService.addFloatDomToColumnHeader(column, { ...layer, componentKey: key, unitId, subUnitId }, domLayout, id);
if (domRangeDispose) {
disposableCollection.add(domRangeDispose.dispose);
return {
id: domRangeDispose.id,
dispose: (): void => {
disposableCollection.dispose();
domRangeDispose.dispose();
},
};
}
disposableCollection.dispose();
return null;
}
override async saveCellImagesAsync(options?: ISaveCellImagesOptions, ranges?: FRange[]): Promise<boolean> {
const batchSaveService = this._injector.get(IBatchSaveImagesService);
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
// Get all ranges as IRange array
const iRanges = ranges ? ranges.map((r) => r.getRange()) : [this._worksheet.getCellMatrix().getDataRange()];
// Get images from all ranges
const images = batchSaveService.getCellImagesFromRanges(unitId, subUnitId, iRanges);
if (images.length === 0) {
return false;
}
// If only one image, download directly
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Build config from options
const fileNameParts: FileNamePart[] = [];
const useCellAddress = options?.useCellAddress ?? true;
const useColumnIndex = options?.useColumnIndex;
if (useCellAddress) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
if (useColumnIndex !== undefined) {
fileNameParts.push(FileNamePart.COLUMN_VALUE);
}
// Ensure at least one naming option
if (fileNameParts.length === 0) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
try {
await batchSaveService.saveImagesWithContext(images, {
fileNameParts,
columnIndex: useColumnIndex,
}, unitId, subUnitId);
return true;
} catch (error) {
console.error('Failed to save images:', error);
return false;
}
}
}
FWorksheet.extend(FWorksheetDrawingUIMixin);
declare module '@univerjs/sheets/facade' {
interface FWorksheet extends IFWorksheetDrawingUIMixin { }
}

View File

@@ -0,0 +1,25 @@
/**
* 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 './f-worksheet';
import './f-event';
import './f-univer';
import './f-range';
export type * from './f-event';
export type * from './f-range';
export type * from './f-univer';
export type * from './f-worksheet';

View File

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

View File

@@ -0,0 +1,49 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export { DeleteDrawingsCommand } from './commands/commands/delete-drawings.command';
export { GroupSheetDrawingCommand } from './commands/commands/group-sheet-drawing.command';
export { InsertFloatImageCommand } from './commands/commands/insert-image.command';
export type { IInsertImageCommandParams } from './commands/commands/insert-image.command';
export { MoveDrawingsCommand } from './commands/commands/move-drawings.command';
export { SaveCellImagesCommand } from './commands/commands/save-cell-images.command';
export { UngroupSheetDrawingCommand } from './commands/commands/ungroup-sheet-drawing.command';
export { EditSheetDrawingOperation } from './commands/operations/edit-sheet-drawing.operation';
export { SidebarSheetDrawingOperation } from './commands/operations/open-drawing-panel.operation';
export type { IUniverSheetsDrawingUIConfig } from './config/config';
export { SheetsDrawingGroupCopyPasteController } from './controllers/sheet-drawing-group-copy-paste.controller';
export { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller';
export { registerSheetsDrawingFloatingHostCapability, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES } from './embed';
export { SHEETS_IMAGE_MENU_ID } from './menu/image.menu';
export { menuSchema as SheetsDrawingUIMenuSchema } from './menu/schema';
export { UniverSheetsDrawingMobileUIPlugin } from './mobile-plugin';
export { UniverSheetsDrawingUIPlugin } from './plugin';
export { BatchSaveImagesService, FileNamePart, IBatchSaveImagesService } from './services/batch-save-images.service';
export type { IBatchSaveImagesConfig, ICellImageInfo } from './services/batch-save-images.service';
export {
calcSheetFloatDomPosition,
SHEET_FLOAT_DOM_PREFIX,
SheetCanvasFloatDomManagerService,
} from './services/canvas-float-dom-manager.service';
export type { ICanvasFloatDom, ICanvasFloatDomInfo, IDOMAnchor } from './services/canvas-float-dom-manager.service';
export { DrawingContextMenuService, IDrawingContextMenuService } from './services/drawing-context-menu.service';
export type { IDrawingContextMenuProvider, IDrawingContextMenuProviderContext } from './services/drawing-context-menu.service';
export { SHEET_CHART_RENDER_OBJECT_CONFIG } from './services/sheet-chart-render-object.config';
export { SheetDrawingHitTestService } from './services/sheet-drawing-hit-test.service';
export type { ISheetDrawingDoubleClickEvent, ISheetDrawingHitTestResult } from './services/sheet-drawing-hit-test.service';
export { SheetDrawingAnchor } from './views/sheet-image-panel/SheetDrawingAnchor';

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'صورة',
uploadLoading: {
loading: 'جارٍ التحميل..., المتبقي',
},
upload: {
float: 'صورة عائمة',
cell: 'صورة خلية',
},
panel: {
title: 'تحرير الصورة',
},
save: {
title: 'حفظ صور الخلايا',
menuLabel: 'حفظ صور الخلايا',
imageCount: 'عدد الصور',
fileNameConfig: 'اسم الملف',
useRowCol: 'استخدام عنوان الخلية (A1، B2...)',
useColumnValue: 'استخدام قيمة العمود',
selectColumn: 'تحديد العمود',
cancel: 'إلغاء',
confirm: 'حفظ',
saving: 'جارٍ الحفظ...',
error: 'فشل في حفظ صور الخلايا',
},
'image-popup': {
delete: 'حذف',
edit: 'تحرير',
crop: 'قص',
reset: 'إعادة تعيين الحجم',
flipH: 'قلب أفقي',
flipV: 'قلب عمودي',
},
'update-status': {
exceedMaxSize: 'حجم الصورة يتجاوز الحد، الحد هو {0}م',
invalidImageType: 'نوع الصورة غير صالح',
exceedMaxCount: 'يمكن رفع {0} صور فقط في المرة الواحدة',
invalidImage: 'صورة غير صالحة',
},
'drawing-anchor': {
title: 'خصائص المرساة',
both: 'النقل والتحجيم مع الخلايا',
position: 'النقل دون التحجيم مع الخلايا',
none: 'عدم النقل أو التحجيم مع الخلايا',
},
'cell-image': {
pasteTitle: 'لصق كصورة خلية',
pasteContent: 'سيؤدي لصق صورة خلية إلى استبدال المحتوى الموجود في الخلية، هل تريد المتابعة؟',
},
permission: {
dialog: {
editErr: 'النطاق محمي، وليس لديك إذن التحرير. للتحرير، يرجى التواصل مع المنشئ.',
},
},
shortcut: {
'drawing-view': 'عرض الرسم',
'drawing-move-down': 'نقل الرسم لأسفل',
'drawing-move-up': 'نقل الرسم لأعلى',
'drawing-move-left': 'نقل الرسم لليسار',
'drawing-move-right': 'نقل الرسم لليمين',
'drawing-delete': 'حذف الرسم',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Imatge',
uploadLoading: {
loading: 'S\'està carregant..., restant',
},
upload: {
float: 'Imatge flotant',
cell: 'Imatge de cel·la',
},
panel: {
title: 'Edita la imatge',
},
save: {
title: 'Desa les imatges de cel·la',
menuLabel: 'Desa les imatges de cel·la',
imageCount: 'Nombre d\'imatges',
fileNameConfig: 'Nom del fitxer',
useRowCol: 'Utilitza l\'adreça de la cel·la (A1, B2...)',
useColumnValue: 'Utilitza el valor de la columna',
selectColumn: 'Selecciona la columna',
cancel: 'Cancel·la',
confirm: 'Desa',
saving: 'Desant...',
error: 'No s\'han pogut desar les imatges de cel·la',
},
'image-popup': {
delete: 'Elimina',
edit: 'Edita',
crop: 'Retalla',
reset: 'Restableix la mida',
flipH: 'Gira horizontalmente',
flipV: 'Gira verticalmente',
},
'update-status': {
exceedMaxSize: 'La mida de la imatge supera el límit, el límit és {0}M',
invalidImageType: 'Tipus d’imatge no vàlid',
exceedMaxCount: 'Només es poden pujar {0} imatges alhora',
invalidImage: 'Imatge no vàlida',
},
'drawing-anchor': {
title: 'Propietats d’ancoratge',
both: 'Mou i canvia la mida amb les cel·les',
position: 'Mou però no canviïs la mida amb les cel·les',
none: 'No moguis ni canviïs la mida amb les cel·les',
},
'cell-image': {
pasteTitle: 'Enganxa com a imatge de cel·la',
pasteContent: 'Enganxar una imatge de cel·la sobreescriurà el contingut existent de la cel·la, continuar enganxant',
},
permission: {
dialog: {
editErr: 'L\'interval està protegit i no teniu permís d\'edició. Per editar, contacteu amb el creador.',
},
},
shortcut: {
'drawing-view': 'Vista del dibuix',
'drawing-move-down': 'Mou el dibuix cap avall',
'drawing-move-up': 'Mou el dibuix cap amunt',
'drawing-move-left': 'Mou el dibuix a l’esquerra',
'drawing-move-right': 'Mou el dibuix a la dreta',
'drawing-delete': 'Elimina el dibuix',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Bild',
uploadLoading: {
loading: 'Wird geladen..., verbleibend',
},
upload: {
float: 'Schwebendes Bild',
cell: 'Zellenbild',
},
panel: {
title: 'Bild bearbeiten',
},
save: {
title: 'Zellenbilder speichern',
menuLabel: 'Zellenbilder speichern',
imageCount: 'Bildanzahl',
fileNameConfig: 'Dateiname',
useRowCol: 'Zellenadresse verwenden (A1, B2...)',
useColumnValue: 'Spaltenwert verwenden',
selectColumn: 'Spalte auswählen',
cancel: 'Abbrechen',
confirm: 'Speichern',
saving: 'Speichern...',
error: 'Zellenbilder konnten nicht gespeichert werden',
},
'image-popup': {
delete: 'Löschen',
edit: 'Bearbeiten',
crop: 'Zuschneiden',
reset: 'Größe zurücksetzen',
flipH: 'Horizontal spiegeln',
flipV: 'Vertikal spiegeln',
},
'update-status': {
exceedMaxSize: 'Bildgröße überschreitet das Limit, Limit ist {0}M',
invalidImageType: 'Ungültiger Bildtyp',
exceedMaxCount: 'Es können nur {0} Bilder gleichzeitig hochgeladen werden',
invalidImage: 'Ungültiges Bild',
},
'drawing-anchor': {
title: 'Anker-Eigenschaften',
both: 'Mit Zellen verschieben und skalieren',
position: 'Mit Zellen verschieben, aber nicht skalieren',
none: 'Weder verschieben noch skalieren mit Zellen',
},
'cell-image': {
pasteTitle: 'Als Zellenbild einfügen',
pasteContent: 'Das Einfügen eines Zellenbilds überschreibt den bestehenden Inhalt der Zelle, mit dem Einfügen fortfahren?',
},
permission: {
dialog: {
editErr: 'Der Bereich ist geschützt, und Sie haben keine Bearbeitungsberechtigung. Um zu bearbeiten, wenden Sie sich bitte an den Ersteller.',
},
},
shortcut: {
'drawing-view': 'Zeichnungsansicht',
'drawing-move-down': 'Zeichnung nach unten verschieben',
'drawing-move-up': 'Zeichnung nach oben verschieben',
'drawing-move-left': 'Zeichnung nach links verschieben',
'drawing-move-right': 'Zeichnung nach rechts verschieben',
'drawing-delete': 'Zeichnung löschen',
},
},
};
export default locale;

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.
*/
const locale = {
'sheets-drawing-ui': {
title: 'Image',
uploadLoading: {
loading: 'Loading..., remaining',
},
upload: {
float: 'Float Image',
cell: 'Cell Image',
},
panel: {
title: 'Edit Image',
},
save: {
title: 'Save Cell Images',
menuLabel: 'Save Cell Images',
imageCount: 'Image Count',
fileNameConfig: 'File Name',
useRowCol: 'Use Cell Address (A1, B2...)',
useColumnValue: 'Use Column Value',
selectColumn: 'Select Column',
cancel: 'Cancel',
confirm: 'Save',
saving: 'Saving...',
error: 'Failed to save cell images',
},
'image-popup': {
delete: 'Delete',
edit: 'Edit',
crop: 'Crop',
reset: 'Reset Size',
flipH: 'Flip Horizontal',
flipV: 'Flip Vertical',
},
'update-status': {
exceedMaxSize: 'Image size exceeds limit, limit is {0}M',
invalidImageType: 'Invalid image type',
exceedMaxCount: 'Only {0} images can be uploaded at a time',
invalidImage: 'Invalid image',
},
'drawing-anchor': {
title: 'Anchor Properties',
both: 'Move and size with cells',
position: "Move but don't size with cells",
none: "Don't move or size with cells",
},
'cell-image': {
pasteTitle: 'Paste as cell image',
pasteContent: 'Pasting a cell image will overwrite the existing content of the cell, continue pasting',
},
permission: {
dialog: {
editErr: 'The range is protected, and you do not have edit permission. To edit, please contact the creator.',
},
},
shortcut: {
'drawing-view': 'Drawing View',
'drawing-move-down': 'Move Drawing down',
'drawing-move-up': 'Move Drawing up',
'drawing-move-left': 'Move Drawing left',
'drawing-move-right': 'Move Drawing right',
'drawing-delete': 'Delete Drawing',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Imagen',
uploadLoading: {
loading: 'Cargando..., restante',
},
upload: {
float: 'Imagen flotante',
cell: 'Imagen de celda',
},
panel: {
title: 'Editar imagen',
},
save: {
title: 'Guardar imágenes de celda',
menuLabel: 'Guardar imágenes de celda',
imageCount: 'Cantidad de imágenes',
fileNameConfig: 'Nombre del archivo',
useRowCol: 'Usar dirección de celda (A1, B2...)',
useColumnValue: 'Usar valor de columna',
selectColumn: 'Seleccionar columna',
cancel: 'Cancelar',
confirm: 'Guardar',
saving: 'Guardando...',
error: 'Error al guardar las imágenes de celda',
},
'image-popup': {
delete: 'Eliminar',
edit: 'Editar',
crop: 'Recortar',
reset: 'Restablecer tamaño',
flipH: 'Voltear horizontalmente',
flipV: 'Voltear verticalmente',
},
'update-status': {
exceedMaxSize: 'El tamaño de la imagen supera el límite, el límite es {0}M',
invalidImageType: 'Tipo de imagen no válido',
exceedMaxCount: 'Solo se pueden subir {0} imágenes a la vez',
invalidImage: 'Imagen no válida',
},
'drawing-anchor': {
title: 'Propiedades de anclaje',
both: 'Mover y cambiar tamaño con las celdas',
position: 'Mover pero no cambiar tamaño con las celdas',
none: 'No mover ni cambiar tamaño con las celdas',
},
'cell-image': {
pasteTitle: 'Pegar como imagen de celda',
pasteContent: 'Pegar una imagen de celda sobrescribirá el contenido existente de la celda, continuar pegando',
},
permission: {
dialog: {
editErr: 'El rango está protegido y no tienes permiso de edición. Para editar, contacta al creador.',
},
},
shortcut: {
'drawing-view': 'Vista de dibujo',
'drawing-move-down': 'Mover dibujo hacia abajo',
'drawing-move-up': 'Mover dibujo hacia arriba',
'drawing-move-left': 'Mover dibujo a la izquierda',
'drawing-move-right': 'Mover dibujo a la derecha',
'drawing-delete': 'Eliminar dibujo',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'تصویر',
uploadLoading: {
loading: 'در حال بارگیری...، باقی مانده',
},
upload: {
float: 'شناور کردن تصویر',
cell: 'تصویر سلولی',
},
panel: {
title: 'ویرایش تصویر',
},
save: {
title: 'ذخیره تصاویر سلول',
menuLabel: 'ذخیره تصاویر سلول',
imageCount: 'تعداد تصاویر',
fileNameConfig: 'نام فایل',
useRowCol: 'استفاده از آدرس سلول (A1, B2...)',
useColumnValue: 'استفاده از مقدار ستون',
selectColumn: 'انتخاب ستون',
cancel: 'لغو',
confirm: 'ذخیره',
saving: 'در حال ذخیره...',
error: 'ذخیره تصاویر سلول ناموفق بود',
},
'image-popup': {
delete: 'حذف',
edit: 'ویرایش',
crop: 'کراپ',
reset: 'بازنشانی اندازه',
flipH: 'چرخش افقی',
flipV: 'چرخش عمودی',
},
'update-status': {
exceedMaxSize: 'اندازه تصویر از حد مجاز فراتر رفته است، حد مجاز {0}M است',
invalidImageType: 'نوع تصویر نامعتبر است',
exceedMaxCount: 'فقط {0} تصویر می‌توانند همزمان آپلود شوند',
invalidImage: 'تصویر نامعتبر است',
},
'drawing-anchor': {
title: 'خواص لنگر',
both: 'جابه‌جایی و تغییر اندازه با سلول‌ها',
position: 'جابه‌جایی اما تغییر اندازه نکردن با سلول‌ها',
none: 'جابه‌جایی یا تغییر اندازه نکردن با سلول‌ها',
},
'cell-image': {
pasteTitle: 'چسباندن به عنوان تصویر سلول',
pasteContent: 'چسباندن تصویر سلول محتوای موجود سلول را پاک خواهد کرد، ادامه چسباندن',
},
permission: {
dialog: {
editErr: 'محدوده محافظت شده است و شما مجوز ویرایش را ندارید. برای ویرایش، لطفا با سازنده تماس بگیرید.',
},
},
shortcut: {
'drawing-view': 'نمای طراحی',
'drawing-move-down': 'جابه‌جایی طراحی به پایین',
'drawing-move-up': 'جابه‌جایی طراحی به بالا',
'drawing-move-left': 'جابه‌جایی طراحی به چپ',
'drawing-move-right': 'جابه‌جایی طراحی به راست',
'drawing-delete': 'حذف طراحی',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Image',
uploadLoading: {
loading: 'Chargement..., restant',
},
upload: {
float: 'Image flottante',
cell: 'Image de cellule',
},
panel: {
title: 'Modifier l\'image',
},
save: {
title: 'Enregistrer les images de cellule',
menuLabel: 'Enregistrer les images de cellule',
imageCount: 'Nombre d\'images',
fileNameConfig: 'Nom du fichier',
useRowCol: 'Utiliser l\'adresse de cellule (A1, B2...)',
useColumnValue: 'Utiliser la valeur de la colonne',
selectColumn: 'Sélectionner la colonne',
cancel: 'Annuler',
confirm: 'Enregistrer',
saving: 'Enregistrement...',
error: 'Échec de l\'enregistrement des images de cellule',
},
'image-popup': {
delete: 'Supprimer',
edit: 'Éditer',
crop: 'Rogner',
reset: 'Réinitialiser la taille',
flipH: 'Retournement horizontal',
flipV: 'Retournement vertical',
},
'update-status': {
exceedMaxSize: 'La taille de l\'image dépasse la limite, la limite est de {0}M',
invalidImageType: 'Type d\'image invalide',
exceedMaxCount: 'Seulement {0} images peuvent être téléchargées à la fois',
invalidImage: 'Image invalide',
},
'drawing-anchor': {
title: 'Propriétés de l\'ancre',
both: 'Déplacer et redimensionner avec les cellules',
position: 'Déplacer mais ne pas redimensionner avec les cellules',
none: 'Ne pas déplacer ni redimensionner avec les cellules',
},
'cell-image': {
pasteTitle: 'Coller comme image de cellule',
pasteContent: 'Coller une image de cellule écrasera le contenu existant de la cellule, continuer le collage',
},
permission: {
dialog: {
editErr: 'La plage est protégée, et vous n\'avez pas la permission de modifier. Pour modifier, veuillez contacter le créateur.',
},
},
shortcut: {
'drawing-view': 'Vue du dessin',
'drawing-move-down': 'Déplacer le dessin vers le bas',
'drawing-move-up': 'Déplacer le dessin vers le haut',
'drawing-move-left': 'Déplacer le dessin vers la gauche',
'drawing-move-right': 'Déplacer le dessin vers la droite',
'drawing-delete': 'Supprimer le dessin',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Gambar',
uploadLoading: {
loading: 'Memuat..., tersisa',
},
upload: {
float: 'Gambar Mengambang',
cell: 'Gambar Sel',
},
panel: {
title: 'Edit Gambar',
},
save: {
title: 'Simpan Gambar Sel',
menuLabel: 'Simpan Gambar Sel',
imageCount: 'Jumlah Gambar',
fileNameConfig: 'Nama File',
useRowCol: 'Gunakan Alamat Sel (A1, B2...)',
useColumnValue: 'Gunakan Nilai Kolom',
selectColumn: 'Pilih Kolom',
cancel: 'Batal',
confirm: 'Simpan',
saving: 'Menyimpan...',
error: 'Gagal menyimpan gambar sel',
},
'image-popup': {
delete: 'Hapus',
edit: 'Edit',
crop: 'Pangkas',
reset: 'Atur Ulang Ukuran',
flipH: 'Balik Horizontal',
flipV: 'Balik Vertikal',
},
'update-status': {
exceedMaxSize: 'Ukuran gambar melebihi batas, batasnya adalah {0}M',
invalidImageType: 'Tipe gambar tidak valid',
exceedMaxCount: 'Hanya {0} gambar yang dapat diunggah dalam satu waktu',
invalidImage: 'Gambar tidak valid',
},
'drawing-anchor': {
title: 'Properti Jangkar',
both: 'Pindah dan ukur bersama sel',
position: 'Pindah tapi tidak ukur bersama sel',
none: 'Jangan pindah atau ukur bersama sel',
},
'cell-image': {
pasteTitle: 'Tempel sebagai gambar sel',
pasteContent: 'Menempel gambar sel akan menimpa konten yang ada di sel, lanjutkan menempel',
},
permission: {
dialog: {
editErr: 'Rentang ini dilindungi, dan Anda tidak memiliki izin edit. Untuk mengedit, silakan hubungi pembuatnya.',
},
},
shortcut: {
'drawing-view': 'Tampilan Gambar',
'drawing-move-down': 'Pindahkan Gambar ke Bawah',
'drawing-move-up': 'Pindahkan Gambar ke Atas',
'drawing-move-left': 'Pindahkan Gambar ke Kiri',
'drawing-move-right': 'Pindahkan Gambar ke Kanan',
'drawing-delete': 'Hapus Gambar',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Immagine',
uploadLoading: {
loading: 'Caricamento..., rimanente',
},
upload: {
float: 'Immagine fluttuante',
cell: 'Immagine cella',
},
panel: {
title: 'Modifica immagine',
},
save: {
title: 'Salva immagini celle',
menuLabel: 'Salva immagini celle',
imageCount: 'Numero immagini',
fileNameConfig: 'Nome file',
useRowCol: 'Usa indirizzo cella (A1, B2...)',
useColumnValue: 'Usa valore colonna',
selectColumn: 'Seleziona colonna',
cancel: 'Annulla',
confirm: 'Salva',
saving: 'Salvataggio...',
error: 'Salvataggio immagini celle non riuscito',
},
'image-popup': {
delete: 'Elimina',
edit: 'Modifica',
crop: 'Ritaglia',
reset: 'Reimposta dimensione',
flipH: 'Capovolgi orizzontalmente',
flipV: 'Capovolgi verticalmente',
},
'update-status': {
exceedMaxSize: 'La dimensione dell\'immagine supera il limite, il limite è {0}M',
invalidImageType: 'Tipo di immagine non valido',
exceedMaxCount: 'È possibile caricare solo {0} immagini alla volta',
invalidImage: 'Immagine non valida',
},
'drawing-anchor': {
title: 'Proprietà ancoraggio',
both: 'Sposta e ridimensiona con le celle',
position: 'Sposta ma non ridimensionare con le celle',
none: 'Non spostare né ridimensionare con le celle',
},
'cell-image': {
pasteTitle: 'Incolla come immagine cella',
pasteContent: "L'incollaggio di un'immagine cella sovrascriverà il contenuto esistente della cella, continuare con l'incollaggio",
},
permission: {
dialog: {
editErr: "L'intervallo è protetto e non hai l'autorizzazione di modifica. Per modificare, contatta il creatore.",
},
},
shortcut: {
'drawing-view': 'Vista disegno',
'drawing-move-down': 'Sposta disegno in basso',
'drawing-move-up': 'Sposta disegno in alto',
'drawing-move-left': 'Sposta disegno a sinistra',
'drawing-move-right': 'Sposta disegno a destra',
'drawing-delete': 'Elimina disegno',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: '画像',
uploadLoading: {
loading: '読み込み中...',
},
upload: {
float: 'セル上に配置',
cell: 'セル内に配置',
},
panel: {
title: '画像の編集',
},
save: {
title: 'セル画像を保存',
menuLabel: 'セル画像を保存',
imageCount: '画像数',
fileNameConfig: 'ファイル名',
useRowCol: 'セルアドレスを使用 (A1, B2...)',
useColumnValue: '列の値を使用',
selectColumn: '列を選択',
cancel: 'キャンセル',
confirm: '保存',
saving: '保存中...',
error: 'セル画像の保存に失敗しました',
},
'image-popup': {
delete: '削除',
edit: '編集',
crop: 'トリミング',
reset: '元のサイズに戻す',
flipH: '左右反転',
flipV: '上下反転',
},
'update-status': {
exceedMaxSize: '画像サイズが制限({0}MB)を超えています',
invalidImageType: 'サポートされていない画像形式です',
exceedMaxCount: '一度にアップロードできるのは {0} 個までです',
invalidImage: '無効な画像です',
},
'drawing-anchor': {
title: 'プロパティ',
both: 'セルに合わせて移動やサイズ変更をする',
position: 'セルに合わせて移動するがサイズ変更はしない',
none: 'セルに合わせて移動やサイズ変更をしない',
},
'cell-image': {
pasteTitle: 'セル内画像として貼り付け',
pasteContent: 'セル内画像を貼り付けると、既存のセル内容が上書きされます。続行しますか?',
},
permission: {
dialog: {
editErr: 'この範囲は保護されており、編集権限がありません。編集するには作成者に連絡してください。',
},
},
shortcut: {
'drawing-view': '図形表示',
'drawing-move-down': '図形を下へ移動',
'drawing-move-up': '図形を上へ移動',
'drawing-move-left': '図形を左へ移動',
'drawing-move-right': '図形を右へ移動',
'drawing-delete': '図形を削除',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: '이미지',
uploadLoading: {
loading: '로딩중...',
},
upload: {
float: '플로팅 이미지',
cell: '셀 내 이미지',
},
panel: {
title: '이미지 편집',
},
save: {
title: '셀 이미지 저장',
menuLabel: '셀 이미지 저장',
imageCount: '이미지 수',
fileNameConfig: '파일 이름',
useRowCol: '셀 주소 사용 (A1, B2...)',
useColumnValue: '열 값 사용',
selectColumn: '열 선택',
cancel: '취소',
confirm: '저장',
saving: '저장 중...',
error: '셀 이미지 저장 실패',
},
'image-popup': {
delete: '삭제',
edit: '편집',
crop: '자르기',
reset: '원래 크기',
flipH: '가로 뒤집기',
flipV: '세로 뒤집기',
},
'update-status': {
exceedMaxSize: '이미지 크기가 제한({0}MB)을 초과했습니다',
invalidImageType: '지원하지 않는 이미지 형식입니다',
exceedMaxCount: '한 번에 {0}개까지만 업로드할 수 있습니다',
invalidImage: '유효하지 않은 이미지입니다',
},
'drawing-anchor': {
title: '고정 위치 설정',
both: '셀과 함께 이동 및 크기 조절',
position: '셀과 함께 이동하되 크기 변경 안 함',
none: '셀과 함께 이동 및 크기 조절 안 함',
},
'cell-image': {
pasteTitle: '셀 이미지로 붙여넣기',
pasteContent: '셀 이미지 붙여넣기는 기존 셀 내용을 덮어씁니다. 계속 진행하시겠습니까?',
},
permission: {
dialog: {
editErr: '해당 범위는 보호되어 있어 편집 권한이 없습니다. 편집하려면 작성자에게 문의하세요.',
},
},
shortcut: {
'drawing-view': '그리기 보기',
'drawing-move-down': '그리기 아래로 이동',
'drawing-move-up': '그리기 위로 이동',
'drawing-move-left': '그리기 왼쪽으로 이동',
'drawing-move-right': '그리기 오른쪽으로 이동',
'drawing-delete': '그리기 삭제',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Obraz',
uploadLoading: {
loading: 'Ładowanie..., pozostało',
},
upload: {
float: 'Obraz swobodny',
cell: 'Obraz w komórce',
},
panel: {
title: 'Edytuj obraz',
},
save: {
title: 'Zapisz obrazy w komórkach',
menuLabel: 'Zapisz obrazy w komórkach',
imageCount: 'Liczba obrazów',
fileNameConfig: 'Nazwa pliku',
useRowCol: 'Użyj adresu komórki (A1, B2...)',
useColumnValue: 'Użyj wartości kolumny',
selectColumn: 'Wybierz kolumnę',
cancel: 'Anuluj',
confirm: 'Zapisz',
saving: 'Zapisywanie...',
error: 'Nie udało się zapisać obrazów w komórkach',
},
'image-popup': {
delete: 'Usuń',
edit: 'Edytuj',
crop: 'Przytnij',
reset: 'Resetuj rozmiar',
flipH: 'Przerzuć w poziomie',
flipV: 'Przerzuć w pionie',
},
'update-status': {
exceedMaxSize: 'Rozmiar obrazu przekracza limit, limit wynosi {0}M',
invalidImageType: 'Nieprawidłowy typ obrazu',
exceedMaxCount: 'Jednocześnie można przesłać tylko {0} obrazów',
invalidImage: 'Nieprawidłowy obraz',
},
'drawing-anchor': {
title: 'Właściwości kotwicy',
both: 'Przesuwaj i zmieniaj rozmiar razem z komórkami',
position: 'Przesuwaj, ale nie zmieniaj rozmiaru razem z komórkami',
none: 'Nie przesuwaj ani nie zmieniaj rozmiaru razem z komórkami',
},
'cell-image': {
pasteTitle: 'Wklej jako obraz w komórce',
pasteContent: 'Wklejenie obrazu w komórce spowoduje zastąpienie istniejącej zawartości komórki, kontynuować wklejanie',
},
permission: {
dialog: {
editErr: 'Zakres jest chroniony i nie masz uprawnień do edycji. Aby edytować, skontaktuj się z twórcą.',
},
},
shortcut: {
'drawing-view': 'Widok rysunku',
'drawing-move-down': 'Przesuń rysunek w dół',
'drawing-move-up': 'Przesuń rysunek w górę',
'drawing-move-left': 'Przesuń rysunek w lewo',
'drawing-move-right': 'Przesuń rysunek w prawo',
'drawing-delete': 'Usuń rysunek',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Imagem',
uploadLoading: {
loading: 'Carregando..., restante',
},
upload: {
float: 'Imagem flutuante',
cell: 'Imagem de célula',
},
panel: {
title: 'Editar imagem',
},
save: {
title: 'Salvar imagens de célula',
menuLabel: 'Salvar imagens de célula',
imageCount: 'Contagem de imagens',
fileNameConfig: 'Nome do arquivo',
useRowCol: 'Usar endereço da célula (A1, B2...)',
useColumnValue: 'Usar valor da coluna',
selectColumn: 'Selecionar coluna',
cancel: 'Cancelar',
confirm: 'Salvar',
saving: 'Salvando...',
error: 'Falha ao salvar imagens de célula',
},
'image-popup': {
delete: 'Excluir',
edit: 'Editar',
crop: 'Cortar',
reset: 'Redefinir tamanho',
flipH: 'Inverter horizontalmente',
flipV: 'Inverter verticalmente',
},
'update-status': {
exceedMaxSize: 'O tamanho da imagem excede o limite, o limite é {0}M',
invalidImageType: 'Tipo de imagem inválido',
exceedMaxCount: 'Apenas {0} imagens podem ser enviadas de cada vez',
invalidImage: 'Imagem inválida',
},
'drawing-anchor': {
title: 'Propriedades da âncora',
both: 'Mover e dimensionar com as células',
position: 'Mover, mas não dimensionar com as células',
none: 'Não mover nem dimensionar com as células',
},
'cell-image': {
pasteTitle: 'Colar como imagem de célula',
pasteContent: 'Colar uma imagem de célula substituirá o conteúdo existente da célula, continuar colando',
},
permission: {
dialog: {
editErr: 'O intervalo está protegido e você não tem permissão de edição. Para editar, entre em contato com o criador.',
},
},
shortcut: {
'drawing-view': 'Visualização do desenho',
'drawing-move-down': 'Mover desenho para baixo',
'drawing-move-up': 'Mover desenho para cima',
'drawing-move-left': 'Mover desenho para esquerda',
'drawing-move-right': 'Mover desenho para direita',
'drawing-delete': 'Excluir desenho',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Изображение',
uploadLoading: {
loading: 'Загрузка...',
},
upload: {
float: 'Плавающее изображение',
cell: 'Изображение в ячейке',
},
panel: {
title: 'Редактировать изображение',
},
save: {
title: 'Сохранить изображения ячеек',
menuLabel: 'Сохранить изображения ячеек',
imageCount: 'Количество изображений',
fileNameConfig: 'Имя файла',
useRowCol: 'Использовать адрес ячейки (A1, B2...)',
useColumnValue: 'Использовать значение столбца',
selectColumn: 'Выбрать столбец',
cancel: 'Отмена',
confirm: 'Сохранить',
saving: 'Сохранение...',
error: 'Не удалось сохранить изображения ячеек',
},
'image-popup': {
delete: 'Удалить',
edit: 'Редактировать',
crop: 'Обрезать',
reset: 'Сбросить размер',
flipH: 'Отразить по горизонтали',
flipV: 'Отразить по вертикали',
},
'update-status': {
exceedMaxSize: 'Размер изображения превышает лимит, лимит составляет {0}М',
invalidImageType: 'Недопустимый тип изображения',
exceedMaxCount: 'За один раз можно загрузить только {0} изображений',
invalidImage: 'Недопустимое изображение',
},
'drawing-anchor': {
title: 'Свойства привязки',
both: 'Перемещать и изменять размер с ячейками',
position: 'Перемещать, но не изменять размер с ячейками',
none: 'Не перемещать и не изменять размер с ячейками',
},
'cell-image': {
pasteTitle: 'Вставить как изображение ячейки',
pasteContent: 'Вставка изображения ячейки перезапишет существующее содержимое ячейки, продолжить вставку',
},
permission: {
dialog: {
editErr: 'Диапазон защищен, и у вас нет разрешения на редактирование. Для редактирования свяжитесь с создателем.',
},
},
shortcut: {
'drawing-view': 'Вид изображения',
'drawing-move-down': 'Переместить изображение вниз',
'drawing-move-up': 'Переместить изображение вверх',
'drawing-move-left': 'Переместить изображение влево',
'drawing-move-right': 'Переместить изображение вправо',
'drawing-delete': 'Удалить изображение',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Obrázok',
uploadLoading: {
loading: 'Načítava sa..., zostáva',
},
upload: {
float: 'Plávajúci obrázok',
cell: 'Obrázok v bunke',
},
panel: {
title: 'Upraviť obrázok',
},
save: {
title: 'Uložiť obrázky buniek',
menuLabel: 'Uložiť obrázky buniek',
imageCount: 'Počet obrázkov',
fileNameConfig: 'Názov súboru',
useRowCol: 'Použiť adresu bunky (A1, B2...)',
useColumnValue: 'Použiť hodnotu stĺpca',
selectColumn: 'Vybrať stĺpec',
cancel: 'Zrušiť',
confirm: 'Uložiť',
saving: 'Ukladá sa...',
error: 'Nepodarilo sa uložiť obrázky buniek',
},
'image-popup': {
delete: 'Odstrániť',
edit: 'Upraviť',
crop: 'Orezať',
reset: 'Obnoviť veľkosť',
flipH: 'Prevrátiť vodorovne',
flipV: 'Prevrátiť zvisle',
},
'update-status': {
exceedMaxSize: 'Veľkosť obrázka prekračuje limit, limit je {0}M',
invalidImageType: 'Neplatný typ obrázka',
exceedMaxCount: 'Naraz možno nahrať iba {0} obrázkov',
invalidImage: 'Neplatný obrázok',
},
'drawing-anchor': {
title: 'Vlastnosti ukotvenia',
both: 'Presúvať a meniť veľkosť s bunkami',
position: 'Presúvať, ale nemeniť veľkosť s bunkami',
none: 'Nepresúvať ani nemeniť veľkosť s bunkami',
},
'cell-image': {
pasteTitle: 'Prilepiť ako obrázok bunky',
pasteContent: 'Prilepením obrázka bunky sa prepíše existujúci obsah bunky, pokračovať',
},
permission: {
dialog: {
editErr: 'Rozsah je chránený a nemáte oprávnenie na úpravy. Ak chcete upravovať, kontaktujte autora.',
},
},
shortcut: {
'drawing-view': 'Zobrazenie kresby',
'drawing-move-down': 'Posunúť kresbu nadol',
'drawing-move-up': 'Posunúť kresbu nahor',
'drawing-move-left': 'Posunúť kresbu doľava',
'drawing-move-right': 'Posunúť kresbu doprava',
'drawing-delete': 'Odstrániť kresbu',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: 'Hình ảnh',
uploadLoading: {
loading: 'Đang tải lên, hiện còn lại',
},
upload: {
float: 'Hình ảnh nổi',
cell: 'Hình ảnh trong ô',
},
panel: {
title: 'Chỉnh sửa hình ảnh',
},
save: {
title: 'Lưu hình ảnh ô',
menuLabel: 'Lưu hình ảnh ô',
imageCount: 'Số lượng hình ảnh',
fileNameConfig: 'Tên tệp',
useRowCol: 'Sử dụng địa chỉ ô (A1, B2...)',
useColumnValue: 'Sử dụng giá trị cột',
selectColumn: 'Chọn cột',
cancel: 'Hủy',
confirm: 'Lưu',
saving: 'Đang lưu...',
error: 'Lưu hình ảnh ô thất bại',
},
'image-popup': {
delete: 'Xóa',
edit: 'Chỉnh sửa',
crop: 'Cắt',
reset: 'Đặt lại kích thước',
flipH: 'Lật ngang',
flipV: 'Lật dọc',
},
'update-status': {
exceedMaxSize: 'Kích thước hình ảnh vượt quá giới hạn, giới hạn là {0}M',
invalidImageType: 'Loại hình ảnh không hợp lệ',
exceedMaxCount: 'Chỉ có thể tải lên {0} hình ảnh một lần',
invalidImage: 'Hình ảnh không hợp lệ',
},
'drawing-anchor': {
title: 'Thuộc tính neo',
both: 'Di chuyển và thay đổi kích thước cùng với ô',
position: 'Di chuyển nhưng không thay đổi kích thước với ô',
none: 'Không di chuyển hoặc thay đổi kích thước với ô',
},
'cell-image': {
pasteTitle: 'Dán như hình ảnh ô',
pasteContent: 'Dán hình ảnh ô sẽ ghi đè lên nội dung hiện có của ô, tiếp tục dán',
},
permission: {
dialog: {
editErr: 'Phạm vi này đã được bảo vệ, hiện không có quyền chỉnh sửa. Nếu cần chỉnh sửa, vui lòng liên hệ với người tạo.',
},
},
shortcut: {
'drawing-view': 'Chế độ xem bản vẽ',
'drawing-move-down': 'Di chuyển hình vẽ xuống',
'drawing-move-up': 'Di chuyển hình vẽ lên',
'drawing-move-left': 'Di chuyển hình vẽ sang trái',
'drawing-move-right': 'Di chuyển hình vẽ sang phải',
'drawing-delete': 'Xóa hình vẽ',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: '图片',
uploadLoading: {
loading: '正在上传,当前剩余',
},
upload: {
float: '浮动图片',
cell: '单元格图片',
},
panel: {
title: '编辑图片',
},
save: {
title: '保存单元格图片',
menuLabel: '保存单元格图片',
imageCount: '图片数量',
fileNameConfig: '文件名',
useRowCol: '使用单元格地址 (A1, B2...)',
useColumnValue: '使用某列的值',
selectColumn: '选择列',
cancel: '取消',
confirm: '保存',
saving: '保存中...',
error: '保存单元格图片失败',
},
'image-popup': {
delete: '删除',
edit: '编辑',
crop: '裁剪',
reset: '重置大小',
flipH: '水平翻转',
flipV: '垂直翻转',
},
'update-status': {
exceedMaxSize: '图片大小超过限制, 限制为{0}M',
invalidImageType: '图片类型错误',
exceedMaxCount: '图片只能一次上传{0}张',
invalidImage: '无效图片',
},
'drawing-anchor': {
title: '锚点属性',
both: '与单元格一起移动和调整大小',
position: '移动但不调整大小与单元格',
none: '不要移动或调整大小与单元格',
},
'cell-image': {
pasteTitle: '粘贴为单元格图片',
pasteContent: '粘贴单元格图片将覆盖单元格中的现有内容,继续粘贴',
},
permission: {
dialog: {
editErr: '该范围已被保护,目前无编辑权限。如需编辑,请联系创建者。',
},
},
shortcut: {
'drawing-view': '绘图视图',
'drawing-move-down': '下移绘图',
'drawing-move-up': '上移绘图',
'drawing-move-left': '左移绘图',
'drawing-move-right': '右移绘图',
'drawing-delete': '删除绘图',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: '圖片',
uploadLoading: {
loading: '正在上傳,目前剩餘',
},
upload: {
float: '浮動圖片',
cell: '儲存格圖片',
},
panel: {
title: '編圖',
},
save: {
title: '儲存儲存格圖片',
menuLabel: '儲存儲存格圖片',
imageCount: '圖片數量',
fileNameConfig: '檔案名稱',
useRowCol: '使用儲存格地址 (A1, B2...)',
useColumnValue: '使用某欄的值',
selectColumn: '選擇欄',
cancel: '取消',
confirm: '儲存',
saving: '儲存中...',
error: '儲存儲存格圖片失敗',
},
'image-popup': {
delete: '刪除',
edit: '編輯',
crop: '裁切',
reset: '重置大小',
flipH: '水平翻轉',
flipV: '垂直翻轉',
},
'update-status': {
exceedMaxSize: '圖片大小超過限制, 限制為{0}M',
invalidImageType: '圖片類型錯誤',
exceedMaxCount: '圖片只能一次上傳{0}張',
invalidImage: '無效圖片',
},
'drawing-anchor': {
title: '錨點屬性',
both: '與儲存格一起移動和調整大小',
position: '移動但不調整大小與儲存格',
none: '不要移動或調整大小與儲存格',
},
'cell-image': {
pasteTitle: '貼上為儲存格圖片',
pasteContent: '貼上儲存格圖片將覆蓋儲存格中的現有內容,繼續貼上',
},
permission: {
dialog: {
editErr: '該範圍已被保護,目前無編輯權限。如需編輯,請聯絡創建者。 ',
},
},
shortcut: {
'drawing-view': '繪圖檢視',
'drawing-move-down': '下移繪圖',
'drawing-move-up': '上移繪圖',
'drawing-move-left': '左移繪圖',
'drawing-move-right': '右移繪圖',
'drawing-delete': '刪除繪圖',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-drawing-ui': {
title: '圖片',
uploadLoading: {
loading: '正在上傳,目前剩餘',
},
upload: {
float: '浮動圖片',
cell: '儲存格圖片',
},
panel: {
title: '編圖',
},
save: {
title: '儲存儲存格圖片',
menuLabel: '儲存儲存格圖片',
imageCount: '圖片數量',
fileNameConfig: '檔案名稱',
useRowCol: '使用儲存格地址 (A1, B2...)',
useColumnValue: '使用某欄的值',
selectColumn: '選擇欄',
cancel: '取消',
confirm: '儲存',
saving: '儲存中...',
error: '儲存儲存格圖片失敗',
},
'image-popup': {
delete: '刪除',
edit: '編輯',
crop: '裁切',
reset: '重置大小',
flipH: '水平翻轉',
flipV: '垂直翻轉',
},
'update-status': {
exceedMaxSize: '圖片大小超過限制, 限制為{0}M',
invalidImageType: '圖片類型錯誤',
exceedMaxCount: '圖片只能一次上傳{0}張',
invalidImage: '無效圖片',
},
'drawing-anchor': {
title: '錨點屬性',
both: '與儲存格一起移動和調整大小',
position: '移動但不調整大小與儲存格',
none: '不要移動或調整大小與儲存格',
},
'cell-image': {
pasteTitle: '貼上為儲存格圖片',
pasteContent: '貼上儲存格圖片將覆蓋儲存格中的現有內容,繼續貼上',
},
permission: {
dialog: {
editErr: '該範圍已被保護,目前無編輯權限。如需編輯,請聯絡創建者。 ',
},
},
shortcut: {
'drawing-view': '繪圖檢視',
'drawing-move-down': '下移繪圖',
'drawing-move-up': '上移繪圖',
'drawing-move-left': '左移繪圖',
'drawing-move-right': '右移繪圖',
'drawing-delete': '刪除繪圖',
},
},
};
export default locale;

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 {
ContextService,
DrawingTypeEnum,
ICommandService,
IContextService,
IImageIoService,
Injector,
IUniverInstanceService,
LocaleService,
toDisposable,
} from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import { IDialogService, IMenuManagerService, IMessageService, MenuItemType } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DrawingPopupMenuController } from '../drawing-popup-menu.controller';
function createControllerHarness(initialDrawingType: DrawingTypeEnum) {
const injector = new Injector();
const createControl$ = new Subject<void>();
const clearControl$ = new Subject<void>();
const changing$ = new Subject<void>();
const imageIoChange$ = new Subject<number>();
const currentWorkbook$ = new Subject<never>();
const disposedWorkbook$ = new Subject<never>();
const dialogs$ = new Subject<never>();
const drawingObject = { oKey: 'drawing-1' };
const popupDisposable = {
dispose: vi.fn(),
canDispose: () => true,
};
let drawingType = initialDrawingType;
let attachedPopup: { extraProps?: Record<string, unknown> } | undefined;
const attachPopupToObject = vi.fn((_targetObject: unknown, popup: { extraProps?: Record<string, unknown> }) => {
attachedPopup = popup;
return popupDisposable;
});
injector.add([LocaleService]);
injector.add([IDialogService, {
useValue: {
open: () => toDisposable(() => undefined),
close: () => undefined,
closeAll: () => undefined,
getDialogs$: () => dialogs$,
},
}]);
injector.add([IDrawingManagerService, {
useValue: {
getDrawingOKey: () => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-1',
drawingType,
}),
} as never,
}]);
injector.add([SheetCanvasPopManagerService, {
useValue: {
attachPopupToObject,
getFeatureMenu: () => undefined,
} as never,
}]);
injector.add([IRenderManagerService, {
useValue: {
has: () => true,
getRenderUnitById: () => ({
scene: {
getAllObjectsByOrder: () => [],
getTransformerByCreate: () => ({
createControl$,
clearControl$,
changing$,
getSelectedObjectMap: () => new Map([['drawing-1', drawingObject]]),
}),
},
}),
} as never,
}]);
injector.add([IUniverInstanceService, {
useValue: {
getCurrentTypeOfUnit$: () => currentWorkbook$,
getTypeOfUnitDisposed$: () => disposedWorkbook$,
getAllUnitsForType: () => [{ getUnitId: () => 'unit-1' }],
} as never,
}]);
injector.add([IMessageService, { useValue: { show: () => toDisposable(() => {}) } as never }]);
injector.add([IMenuManagerService, {
useValue: {
getFlatMenuByPositionKey: () => [{
key: 'add-comment',
order: 0,
item: {
id: 'sheet.operation.add-drawing-comment',
type: MenuItemType.BUTTON,
title: 'sheets-thread-comment-ui.menu.addComment',
},
}],
} as never,
}]);
injector.add([IContextService, { useValue: new ContextService() }]);
injector.add([IImageIoService, { useValue: { change$: imageIoChange$ } as never }]);
injector.add([ICommandService, { useValue: { syncExecuteCommand: vi.fn() } as never }]);
injector.add([DrawingPopupMenuController]);
return {
attachPopupToObject,
createControl$,
drawingObject,
getAttachedPopup: () => attachedPopup,
injector,
popupDisposable,
setDrawingType: (nextDrawingType: DrawingTypeEnum) => {
drawingType = nextDrawingType;
},
};
}
describe('DrawingPopupMenuController', () => {
it.each([
{ drawingType: DrawingTypeEnum.DRAWING_IMAGE, label: 'image' },
{ drawingType: DrawingTypeEnum.DRAWING_DOM, label: 'Float DOM' },
])('constrains the $label popup to the canvas and places it on the left in RTL', ({ drawingType }) => {
const { attachPopupToObject, createControl$, drawingObject, getAttachedPopup, injector } = createControllerHarness(drawingType);
injector.get(LocaleService).setDirection('rtl');
const controller = injector.get(DrawingPopupMenuController);
createControl$.next();
expect(attachPopupToObject).toHaveBeenCalledWith(
drawingObject,
expect.objectContaining({
constrainToCanvas: true,
direction: 'left',
})
);
expect(getAttachedPopup()?.extraProps?.menuItems).toEqual(expect.arrayContaining([
expect.objectContaining({
commandId: 'sheet.operation.add-drawing-comment',
label: 'sheets-thread-comment-ui.menu.addComment',
}),
]));
controller.dispose();
injector.dispose();
});
it('removes a chart popup when focus moves to a shape', () => {
const {
attachPopupToObject,
createControl$,
injector,
popupDisposable,
setDrawingType,
} = createControllerHarness(DrawingTypeEnum.DRAWING_CHART);
const controller = injector.get(DrawingPopupMenuController);
createControl$.next();
setDrawingType(DrawingTypeEnum.DRAWING_SHAPE);
createControl$.next();
expect(popupDisposable.dispose).toHaveBeenCalledOnce();
expect(attachPopupToObject).toHaveBeenCalledOnce();
controller.dispose();
injector.dispose();
});
});

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 { IDisposable, Nullable, Workbook } from '@univerjs/core';
import type { ImageIoService } from '@univerjs/drawing';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import type { ISheetFloatDom } from '@univerjs/sheets-drawing';
import type { LocaleKey } from '../locale/types';
import {
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
IImageIoService,
Inject,
IUniverInstanceService,
LocaleService,
RxDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import {
COMPONENT_IMAGE_POPUP_MENU,
ImageCropperObject,
ImageResetSizeOperation,
OpenImageCropOperation,
} from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import {
FloatingObjectToolbarPosition,
IMenuManagerService,
IMessageService,
MenuItemType,
} from '@univerjs/ui';
import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command';
import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation';
export class DrawingPopupMenuController extends RxDisposable {
private _initImagePopupMenu = new Set<string>();
constructor(
@Inject(LocaleService) protected readonly _localeService: LocaleService,
@IDrawingManagerService protected readonly _drawingManagerService: IDrawingManagerService,
@Inject(SheetCanvasPopManagerService) protected readonly _canvasPopManagerService: SheetCanvasPopManagerService,
@IRenderManagerService protected readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMessageService protected readonly _messageService: IMessageService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IContextService protected readonly _contextService: IContextService,
@IImageIoService private readonly _ioService: ImageIoService,
@ICommandService protected readonly _commandService: ICommandService
) {
super();
this._init();
}
private _init(): void {
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => this._create(workbook));
this._univerInstanceService.getTypeOfUnitDisposed$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => this._dispose(workbook));
this._univerInstanceService.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET).forEach((workbook) => this._create(workbook));
this._setupLoadingStatus();
}
private _setupLoadingStatus() {
const MESSAGE_ID = 'image-upload-loading';
let messageDisposable: IDisposable | undefined;
this.disposeWithMe(this._ioService.change$.subscribe((status) => {
if (status > 0 && !messageDisposable) {
messageDisposable = this._messageService.show({
id: MESSAGE_ID,
type: MessageType.Loading,
content: `${this._localeService.t<LocaleKey>('sheets-drawing-ui.uploadLoading.loading')}: ${status}`,
duration: 0,
});
} else if (status === 0) {
messageDisposable?.dispose();
messageDisposable = undefined;
}
}));
}
private _dispose(workbook: Workbook) {
super.dispose();
const unitId = workbook.getUnitId();
this._renderManagerService.removeRender(unitId);
this._initImagePopupMenu.delete(unitId);
}
private _create(workbook: Nullable<Workbook>) {
if (!workbook) {
return;
}
const unitId = workbook.getUnitId();
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
this._popupMenuListener(unitId);
this._initImagePopupMenu.add(unitId);
}
}
private _hasCropObject(scene: Scene) {
const objects = scene.getAllObjectsByOrder();
for (const object of objects) {
if (object instanceof ImageCropperObject) {
return true;
}
}
return false;
}
private _popupMenuListener(unitId: string) {
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (!scene) {
return;
}
const transformer = scene.getTransformerByCreate();
if (!transformer) {
return;
}
let singletonPopupDisposer: IDisposable | undefined;
this.disposeWithMe(transformer.createControl$.subscribe(() => {
singletonPopupDisposer?.dispose();
singletonPopupDisposer = this._createPopup(scene, transformer);
}));
this.disposeWithMe(
transformer.clearControl$.subscribe(() => {
singletonPopupDisposer?.dispose();
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, []);
})
);
this.disposeWithMe(
this._contextService.contextChanged$.subscribe((event) => {
if (event[FOCUSING_COMMON_DRAWINGS] === false) {
singletonPopupDisposer?.dispose();
}
})
);
this.disposeWithMe(
transformer.changing$.subscribe(() => {
singletonPopupDisposer?.dispose();
})
);
}
private _createPopup(scene: Scene, transformer: ReturnType<Scene['getTransformerByCreate']>): IDisposable | undefined {
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true);
if (this._hasCropObject(scene)) {
return;
}
const selectedObjects = transformer.getSelectedObjectMap();
if (selectedObjects.size > 1) {
return;
}
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
return;
}
const drawingParam = this._drawingManagerService.getDrawingOKey(object.oKey);
if (!drawingParam || drawingParam.drawingType === DrawingTypeEnum.DRAWING_SHAPE) {
return;
}
const { unitId, subUnitId, drawingId, drawingType } = drawingParam;
const data = (drawingParam as ISheetFloatDom).data as Record<string, unknown> | undefined;
if (data && (data.disablePopup || (data.version === 1 && typeof data.embedId === 'string'))) {
return;
}
const menus = this._canvasPopManagerService.getFeatureMenu(unitId, subUnitId, drawingId, drawingType);
const menuItems = [
...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)),
...this._getFloatingObjectMenuItems(),
];
return this.disposeWithMe(this._canvasPopManagerService.attachPopupToObject(object, {
componentKey: COMPONENT_IMAGE_POPUP_MENU,
constrainToCanvas: true,
direction: this._localeService.getDirection() === 'rtl' ? 'left' : 'horizontal',
offset: [2, 0],
extraProps: {
menuItems,
},
}));
}
private _getImageMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) {
return [
{
label: 'sheets-drawing-ui.image-popup.edit',
index: 0,
commandId: EditSheetDrawingOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.delete',
index: 1,
commandId: RemoveSheetDrawingCommand.id,
commandParams: { unitId, drawings: [{ unitId, subUnitId, drawingId }] },
disable: false,
},
{
label: 'sheets-drawing-ui.image-popup.crop',
index: 2,
commandId: OpenImageCropOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.flipH',
index: 2,
commandId: FlipSheetDrawingCommand.id,
commandParams: { unitId, flipH: true, drawings: [{ unitId, subUnitId, drawingId }] },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.flipV',
index: 2,
commandId: FlipSheetDrawingCommand.id,
commandParams: { unitId, flipV: true, drawings: [{ unitId, subUnitId, drawingId }] },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.reset',
index: 3,
commandId: ImageResetSizeOperation.id,
commandParams: [{ unitId, subUnitId, drawingId }],
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.SHEET)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title) {
return [];
}
return [{
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
}];
});
}
}

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 { IAccessor } from '@univerjs/core';
import type { IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import {
RangeProtectionPermissionEditPoint,
WorkbookEditablePermission,
WorksheetEditPermission,
} from '@univerjs/sheets';
import { getCurrentRangeDisable$ } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { InsertCellImageCommand, InsertFloatImageCommand } from '../commands/commands/insert-image.command';
export const SHEETS_IMAGE_MENU_ID = 'sheet.menu.image';
export function ImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: SHEETS_IMAGE_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'AddImageIcon',
tooltip: 'sheets-drawing-ui.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}),
};
}
export function UploadFloatImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: InsertFloatImageCommand.id,
title: 'sheets-drawing-ui.upload.float',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
};
}
export function UploadCellImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: InsertCellImageCommand.id,
title: 'sheets-drawing-ui.upload.cell',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
};
}

View File

@@ -0,0 +1,386 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IDrawingSearch, Nullable, Workbook } from '@univerjs/core';
import type { ImageIoService } from '@univerjs/drawing';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import type { ISheetFloatDom } from '@univerjs/sheets-drawing';
import type { LocaleKey } from '../locale/types';
import {
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
IImageIoService,
Inject,
IUniverInstanceService,
LocaleService,
RxDisposable,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import {
COMPONENT_MOBILE_IMAGE_POPUP_MENU,
ImageCropperObject,
ImageResetSizeOperation,
OpenImageCropOperation,
} from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import {
FloatingObjectToolbarPosition,
IDialogService,
ILayoutService,
IMenuManagerService,
IMessageService,
MenuItemType,
} from '@univerjs/ui';
import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command';
import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation';
const MOBILE_IMAGE_ACTIONS_DIALOG_ID = 'sheet-mobile-image-actions';
const MOBILE_DRAWING_LONG_PRESS_DURATION = 500;
export class MobileDrawingPopupMenuController extends RxDisposable {
private readonly _initImagePopupMenu = new Set<string>();
constructor(
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(SheetCanvasPopManagerService)
private readonly _canvasPopManagerService: SheetCanvasPopManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IContextService private readonly _contextService: IContextService,
@IImageIoService private readonly _ioService: ImageIoService,
@ICommandService private readonly _commandService: ICommandService,
@IDialogService private readonly _dialogService: IDialogService,
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();
this._init();
}
private _init(): void {
this.disposeWithMe(
this._univerInstanceService
.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.subscribe((workbook) => this._create(workbook))
);
this.disposeWithMe(
this._univerInstanceService
.getTypeOfUnitDisposed$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.subscribe((workbook) => this._dispose(workbook))
);
this._univerInstanceService
.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET)
.forEach((workbook) => this._create(workbook));
this._setupLoadingStatus();
}
private _setupLoadingStatus(): void {
const messageId = 'image-upload-loading';
let messageDisposable: IDisposable | undefined;
this.disposeWithMe(this._ioService.change$.subscribe((status) => {
if (status > 0 && !messageDisposable) {
messageDisposable = this._messageService.show({
id: messageId,
type: MessageType.Loading,
content: `${this._localeService.t<LocaleKey>('sheets-drawing-ui.uploadLoading.loading')}: ${status}`,
duration: 0,
});
} else if (status === 0) {
messageDisposable?.dispose();
messageDisposable = undefined;
}
}));
}
private _dispose(workbook: Workbook): void {
super.dispose();
const unitId = workbook.getUnitId();
this._renderManagerService.removeRender(unitId);
this._initImagePopupMenu.delete(unitId);
}
private _create(workbook: Nullable<Workbook>): void {
if (!workbook) {
return;
}
const unitId = workbook.getUnitId();
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
this._popupMenuListener(unitId);
this._initImagePopupMenu.add(unitId);
}
}
private _hasCropObject(scene: Scene): boolean {
for (const object of scene.getAllObjectsByOrder()) {
if (object instanceof ImageCropperObject) {
return true;
}
}
return false;
}
private _openDrawingPopup(scene: Scene, object: BaseObject, closeCurrentPopup: () => void): Nullable<IDisposable> {
if (this._hasCropObject(scene)) {
return null;
}
const drawingParam = this._drawingManagerService.getDrawingOKey(object.oKey);
if (!drawingParam) {
return null;
}
const { unitId, subUnitId, drawingId, drawingType } = drawingParam;
const data = (drawingParam as ISheetFloatDom).data as Record<string, unknown> | undefined;
if (data && (data.disablePopup || (data.version === 1 && typeof data.embedId === 'string'))) {
return null;
}
const menus = this._canvasPopManagerService.getFeatureMenu(unitId, subUnitId, drawingId, drawingType);
const menuItems = [
...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)),
...this._getFloatingObjectMenuItems(),
];
const titleKey = this._canvasPopManagerService.getFeatureMenuTitle(drawingType) ??
(drawingType === DrawingTypeEnum.DRAWING_IMAGE
? 'sheets-drawing-ui.title'
: 'sheets-drawing-ui.image-popup.edit');
closeCurrentPopup();
this._dialogService.open({
id: MOBILE_IMAGE_ACTIONS_DIALOG_ID,
title: { title: this._localeService.t(titleKey) },
children: {
label: {
name: COMPONENT_MOBILE_IMAGE_POPUP_MENU,
props: {
popup: {
extraProps: { menuItems, dialogId: MOBILE_IMAGE_ACTIONS_DIALOG_ID },
},
},
},
},
});
return toDisposable(() => this._dialogService.close(MOBILE_IMAGE_ACTIONS_DIALOG_ID));
}
private _trackActivePointer(clearLongPressTimer: () => void) {
const contentElement = this._layoutService.getContentElement();
let activePointerId: number | undefined;
const handlePointerDown = (event: PointerEvent) => {
if (event.isPrimary && event.button === 0) {
activePointerId = event.pointerId;
}
};
const handlePointerEnd = (event: PointerEvent) => {
if (event.pointerId === activePointerId) {
activePointerId = undefined;
clearLongPressTimer();
}
};
contentElement?.addEventListener('pointerdown', handlePointerDown, true);
contentElement?.addEventListener('pointerup', handlePointerEnd, true);
contentElement?.addEventListener('pointercancel', handlePointerEnd, true);
return {
getActivePointerId: () => activePointerId,
disposable: toDisposable(() => {
contentElement?.removeEventListener('pointerdown', handlePointerDown, true);
contentElement?.removeEventListener('pointerup', handlePointerEnd, true);
contentElement?.removeEventListener('pointercancel', handlePointerEnd, true);
}),
};
}
private _getSelectedDrawingObject(
transformer: { getSelectedObjectMap: () => Map<string, BaseObject> },
drawing: IDrawingSearch
): Nullable<BaseObject> {
const selectedObjects = transformer.getSelectedObjectMap();
if (selectedObjects.size !== 1) {
return null;
}
const selectedObject = selectedObjects.values().next().value as Nullable<BaseObject>;
const selectedDrawing = selectedObject
? this._drawingManagerService.getDrawingOKey(selectedObject.oKey)
: null;
return selectedObject &&
selectedDrawing?.unitId === drawing.unitId &&
selectedDrawing.subUnitId === drawing.subUnitId &&
selectedDrawing.drawingId === drawing.drawingId
? selectedObject
: null;
}
private _popupMenuListener(unitId: string): void {
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (!scene) {
return;
}
const transformer = scene.getTransformerByCreate();
if (!transformer) {
return;
}
let singletonPopupDisposer: IDisposable;
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
const clearLongPressTimer = () => {
if (longPressTimer === undefined) {
return;
}
clearTimeout(longPressTimer);
longPressTimer = undefined;
};
const pointerTracker = this._trackActivePointer(clearLongPressTimer);
this.disposeWithMe(pointerTracker.disposable);
this.disposeWithMe(toDisposable(() => {
clearLongPressTimer();
singletonPopupDisposer?.dispose();
}));
this.disposeWithMe(transformer.createControl$.subscribe(() => {
clearLongPressTimer();
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true);
}));
this.disposeWithMe(transformer.changeStart$.subscribe(({ target, objects }) => {
clearLongPressTimer();
singletonPopupDisposer?.dispose();
if (pointerTracker.getActivePointerId() === undefined || !target || objects.size !== 1 || this._hasCropObject(scene)) {
return;
}
const drawingParam = this._drawingManagerService.getDrawingOKey(target.oKey);
if (!drawingParam) {
return;
}
longPressTimer = setTimeout(() => {
longPressTimer = undefined;
const selectedObject = this._getSelectedDrawingObject(transformer, drawingParam);
if (!selectedObject) {
return;
}
const popupDisposer = this._openDrawingPopup(
scene,
selectedObject,
() => singletonPopupDisposer?.dispose()
);
if (popupDisposer) {
singletonPopupDisposer = popupDisposer;
}
}, MOBILE_DRAWING_LONG_PRESS_DURATION);
}));
this.disposeWithMe(transformer.clearControl$.subscribe(() => {
clearLongPressTimer();
singletonPopupDisposer?.dispose();
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, []);
}));
this.disposeWithMe(this._contextService.contextChanged$.subscribe((event) => {
if (event[FOCUSING_COMMON_DRAWINGS] === false) {
singletonPopupDisposer?.dispose();
}
}));
this.disposeWithMe(transformer.changing$.subscribe(() => {
clearLongPressTimer();
singletonPopupDisposer?.dispose();
}));
this.disposeWithMe(transformer.changeEnd$.subscribe(() => {
clearLongPressTimer();
}));
}
private _getImageMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) {
return [
{
label: 'sheets-drawing-ui.image-popup.edit',
index: 0,
commandId: EditSheetDrawingOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.delete',
index: 1,
commandId: RemoveSheetDrawingCommand.id,
commandParams: { unitId, drawings: [{ unitId, subUnitId, drawingId }] },
disable: false,
},
{
label: 'sheets-drawing-ui.image-popup.crop',
index: 2,
commandId: OpenImageCropOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.flipH',
index: 2,
commandId: FlipSheetDrawingCommand.id,
commandParams: { unitId, flipH: true, drawings: [{ unitId, subUnitId, drawingId }] },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.flipV',
index: 2,
commandId: FlipSheetDrawingCommand.id,
commandParams: { unitId, flipV: true, drawings: [{ unitId, subUnitId, drawingId }] },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'sheets-drawing-ui.image-popup.reset',
index: 3,
commandId: ImageResetSizeOperation.id,
commandParams: [{ unitId, subUnitId, drawingId }],
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.SHEET)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title) {
return [];
}
return [{
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
}];
});
}
}

View File

@@ -0,0 +1,106 @@
/**
* 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 } from '@univerjs/core';
import type { IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { isCellImage, SheetsSelectionsService } from '@univerjs/sheets';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map, of, switchMap } from 'rxjs';
import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command';
export const SAVE_CELL_IMAGES_MENU_ID = 'sheet.menu.save-cell-images';
/**
* Check if selection range has any images
*/
function selectionHasImages(
workbook: Workbook,
selection: IRange
): boolean {
const worksheet = workbook.getActiveSheet();
if (!worksheet) return false;
const cellMatrix = worksheet.getCellMatrix();
const { startRow, endRow, startColumn, endColumn } = selection;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const cell = cellMatrix.getValue(row, col);
if (isCellImage(cell?.p)) {
return true;
}
}
}
return false;
}
/**
* Check if File System Access API is supported
*/
function isFileSystemAccessSupported(): boolean {
return 'showDirectoryPicker' in window;
}
export function SaveCellImagesMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
const univerInstanceService = accessor.get(IUniverInstanceService);
const selectionService = accessor.get(SheetsSelectionsService);
// Hide menu if File System Access API is not supported or no images in selection
const hidden$ = combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).pipe(
switchMap((workbook) => {
if (!workbook) return of(true);
return selectionService.selectionMoveEnd$.pipe(
map(() => {
// Hide if File System Access API is not supported
if (!isFileSystemAccessSupported()) {
return true;
}
const selections = selectionService.getCurrentSelections();
if (!selections || selections.length === 0) {
return true;
}
// Check if any selection has images
for (const selection of selections) {
if (selectionHasImages(workbook, selection.range)) {
return false;
}
}
return true;
})
);
})
),
]).pipe(
map(([hidden, noImages]) => hidden || noImages)
);
return {
id: SaveCellImagesCommand.id,
type: MenuItemType.BUTTON,
icon: 'DownloadImageIcon',
title: 'sheets-drawing-ui.save.menuLabel',
hidden$,
};
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { MenuSchemaType } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertCellImageCommand, InsertFloatImageCommand } from '../commands/commands/insert-image.command';
import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command';
import { ImageMenuFactory, SHEETS_IMAGE_MENU_ID, UploadCellImageMenuFactory, UploadFloatImageMenuFactory } from './image.menu';
import { SaveCellImagesMenuFactory } from './save-images.menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[SHEETS_IMAGE_MENU_ID]: {
order: 0,
gridLayout: { row: 1, column: 1, rowSpan: 2, showLabel: true },
menuItemFactory: ImageMenuFactory,
[InsertFloatImageCommand.id]: {
order: 0,
menuItemFactory: UploadFloatImageMenuFactory,
},
[InsertCellImageCommand.id]: {
order: 1,
menuItemFactory: UploadCellImageMenuFactory,
},
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
[ContextMenuPosition.COL_HEADER]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
[ContextMenuPosition.ROW_HEADER]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
};

View File

@@ -0,0 +1,152 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsDrawingUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
merge,
mergeOverrideWithDependencies,
Plugin,
registerDependencies,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingMobileUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsDrawingPlugin } from '@univerjs/sheets-drawing';
import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { DrawingContextMenuController } from './controllers/drawing-context-menu.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { SheetCellImageHoverRenderController } from './controllers/render-controllers/sheet-celll-image-hover.render-controller';
import { SheetsDrawingRenderController } from './controllers/render-controllers/sheet-drawing.render-controller';
import { SheetCellImageAutofillController } from './controllers/sheet-cell-image-autofill.controller';
import { SheetCellImageCopyPasteController } from './controllers/sheet-cell-image-copy-paste.controller';
import { SheetCellImageController } from './controllers/sheet-cell-image.controller';
import { SheetDrawingActiveRenderController } from './controllers/sheet-drawing-active-render.controller';
import { SheetsDrawingCopyPasteController } from './controllers/sheet-drawing-copy-paste.controller';
import { SheetsDrawingGroupCopyPasteController } from './controllers/sheet-drawing-group-copy-paste.controller';
import { SheetDrawingPermissionController } from './controllers/sheet-drawing-permission.controller';
import { SheetDrawingPrintingController } from './controllers/sheet-drawing-printing.controller';
import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller';
import { SheetDrawingUIController } from './controllers/ui.controller';
import { touchSheetsDrawingFloatingHostCapabilityWhenReady } from './embed/floating-host';
import { MobileDrawingPopupMenuController } from './menu/mobile-drawing-popup-menu.controller';
import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service';
import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service';
import { DrawingContextMenuService, IDrawingContextMenuService } from './services/drawing-context-menu.service';
import { SheetDrawingHitTestService } from './services/sheet-drawing-hit-test.service';
@DependentOn(
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsDrawingPlugin,
UniverSheetsPlugin,
UniverSheetsDrawingPlugin,
UniverDrawingMobileUIPlugin,
UniverSheetsMobileUIPlugin,
UniverMobileUIPlugin
)
export class UniverSheetsDrawingMobileUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SHEET_IMAGE_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge({}, defaultPluginConfig, this._config);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies = [
[MobileComponentsController],
[SheetCanvasFloatDomManagerService],
[SheetDrawingHitTestService],
[SheetDrawingUIController],
[MobileDrawingPopupMenuController],
[SheetDrawingPrintingController],
[SheetDrawingPermissionController],
[SheetsDrawingCopyPasteController],
[SheetsDrawingGroupCopyPasteController],
[SheetCellImageController],
[SheetCellImageAutofillController],
[SheetCellImageCopyPasteController],
[IBatchSaveImagesService, { useClass: BatchSaveImagesService }],
[IDrawingContextMenuService, { useClass: DrawingContextMenuService }],
[DrawingContextMenuController],
] as Dependency[];
registerDependencies(this._injector, mergeOverrideWithDependencies(dependencies, this._config.overrides));
touchSheetsDrawingFloatingHostCapabilityWhenReady(this._injector);
this._injector.get(MobileComponentsController);
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsDrawingCopyPasteController],
[SheetCellImageCopyPasteController],
[SheetsDrawingGroupCopyPasteController],
]);
}
override onRendered(): void {
this._registerRenderModules();
touchDependencies(this._injector, [
[SheetDrawingPermissionController],
[SheetDrawingPrintingController],
[SheetDrawingUIController],
[SheetCellImageController],
[SheetCellImageAutofillController],
]);
}
override onSteady(): void {
this._injector.get(MobileDrawingPopupMenuController);
this._injector.get(DrawingContextMenuController);
}
private _registerRenderModules(): void {
([
[SheetDrawingUpdateController],
[SheetDrawingActiveRenderController],
[SheetsDrawingRenderController],
[SheetCellImageHoverRenderController],
] as Dependency[]).forEach((module) => {
this.disposeWithMe(this._renderManagerService.registerRenderModule(UniverInstanceType.UNIVER_SHEET, module));
});
}
}

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 { Dependency } from '@univerjs/core';
import type { IUniverSheetsDrawingUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
merge,
Plugin,
registerDependencies,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsDrawingPlugin } from '@univerjs/sheets-drawing';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DrawingContextMenuController } from './controllers/drawing-context-menu.controller';
import { SheetCellImageHoverRenderController } from './controllers/render-controllers/sheet-celll-image-hover.render-controller';
import { SheetsDrawingRenderController } from './controllers/render-controllers/sheet-drawing.render-controller';
import { SheetCellImageAutofillController } from './controllers/sheet-cell-image-autofill.controller';
import { SheetCellImageCopyPasteController } from './controllers/sheet-cell-image-copy-paste.controller';
import { SheetCellImageController } from './controllers/sheet-cell-image.controller';
import { SheetDrawingActiveRenderController } from './controllers/sheet-drawing-active-render.controller';
import { SheetsDrawingCopyPasteController } from './controllers/sheet-drawing-copy-paste.controller';
import { SheetsDrawingGroupCopyPasteController } from './controllers/sheet-drawing-group-copy-paste.controller';
import { SheetDrawingPermissionController } from './controllers/sheet-drawing-permission.controller';
import { SheetDrawingPrintingController } from './controllers/sheet-drawing-printing.controller';
import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller';
import { SheetDrawingUIController } from './controllers/ui.controller';
import { touchSheetsDrawingFloatingHostCapabilityWhenReady } from './embed/floating-host';
import { DrawingPopupMenuController } from './menu/drawing-popup-menu.controller';
import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service';
import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service';
import { DrawingContextMenuService, IDrawingContextMenuService } from './services/drawing-context-menu.service';
import { SheetDrawingHitTestService } from './services/sheet-drawing-hit-test.service';
@DependentOn(
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsDrawingPlugin,
UniverSheetsPlugin,
UniverSheetsDrawingPlugin,
UniverDrawingUIPlugin,
UniverSheetsUIPlugin
)
export class UniverSheetsDrawingUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SHEET_IMAGE_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
registerDependencies(this._injector, [
[ComponentsController],
[SheetCanvasFloatDomManagerService],
[SheetDrawingHitTestService],
[SheetDrawingUIController],
[DrawingPopupMenuController],
[SheetDrawingPrintingController],
[SheetDrawingPermissionController],
[SheetsDrawingCopyPasteController],
[SheetsDrawingGroupCopyPasteController],
[SheetCellImageController],
[SheetCellImageAutofillController],
[SheetCellImageCopyPasteController],
[IBatchSaveImagesService, { useClass: BatchSaveImagesService }],
[IDrawingContextMenuService, { useClass: DrawingContextMenuService }],
[DrawingContextMenuController],
]);
touchSheetsDrawingFloatingHostCapabilityWhenReady(this._injector);
this._injector.get(ComponentsController);
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsDrawingCopyPasteController],
[SheetCellImageCopyPasteController],
[SheetsDrawingGroupCopyPasteController],
]);
}
override onRendered(): void {
this._registerRenderModules();
touchDependencies(this._injector, [
[SheetDrawingPermissionController],
[SheetDrawingPrintingController],
[SheetDrawingUIController],
[SheetCellImageController],
[SheetCellImageAutofillController],
]);
}
override onSteady(): void {
this._injector.get(DrawingPopupMenuController);
this._injector.get(DrawingContextMenuController);
}
private _registerRenderModules(): void {
([
[SheetDrawingUpdateController],
[SheetDrawingActiveRenderController],
[SheetsDrawingRenderController],
[SheetCellImageHoverRenderController],
] as Dependency[]).forEach((m) => {
this.disposeWithMe(this._renderManagerService.registerRenderModule(UniverInstanceType.UNIVER_SHEET, m));
});
}
}

View File

@@ -0,0 +1,235 @@
/**
* 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, IWorkbookData } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import { BooleanNumber, ImageSourceType, LocaleType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../__tests__/create-sheets-drawing-ui-test-bed';
import { BatchSaveImagesService, FileNamePart, IBatchSaveImagesService } from '../batch-save-images.service';
const WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: createImageCell('img-a1', 'data:image/png;base64,aGVsbG8=', ImageSourceType.BASE64),
3: { v: 'Report:Q1' },
},
1: {
0: { v: 'Client/Name' },
1: createImageCell('img-b2', 'https://cdn.example.com/photo.jpeg?version=1', ImageSourceType.URL),
3: { p: { body: { dataStream: 'Summary' } } as ICellData['p'] },
},
},
hidden: BooleanNumber.FALSE,
},
},
};
function createImageCell(drawingId: string, source: string, imageSourceType: ImageSourceType): ICellData {
return {
p: {
id: `${drawingId}-doc`,
documentStyle: {},
drawingsOrder: [drawingId],
drawings: {
[drawingId]: {
drawingId,
source,
imageSourceType,
},
},
} as unknown as ICellData['p'],
};
}
function createSelection(startRow: number, endRow: number, startColumn: number, endColumn: number): ISelectionWithStyle {
return {
range: {
startRow,
endRow,
startColumn,
endColumn,
},
primary: {
actualRow: startRow,
actualColumn: startColumn,
startRow,
endRow,
startColumn,
endColumn,
isMerged: false,
isMergedMainCell: false,
},
style: null,
};
}
function setup() {
const testBed = createSheetsDrawingUiTestBed(WORKBOOK_DATA, [
[IBatchSaveImagesService, { useClass: BatchSaveImagesService }],
]);
const service = testBed.get(IBatchSaveImagesService);
const selectionsService = testBed.get(SheetsSelectionsService);
selectionsService.setSelections([
createSelection(0, 0, 0, 1),
createSelection(1, 1, 1, 1),
]);
return {
...testBed,
service,
selectionsService,
};
}
describe('BatchSaveImagesService', () => {
afterEach(() => {
Reflect.deleteProperty(window, 'showDirectoryPicker');
});
it('collects images from the active selection and reports the selected address space', () => {
const { service, univer } = setup();
expect(service.getCellImagesInSelection()).toEqual([
{
row: 0,
col: 0,
cellAddress: 'A1',
source: 'data:image/png;base64,aGVsbG8=',
imageSourceType: ImageSourceType.BASE64,
imageId: 'img-a1',
},
{
row: 1,
col: 1,
cellAddress: 'B2',
source: 'https://cdn.example.com/photo.jpeg?version=1',
imageSourceType: ImageSourceType.URL,
imageId: 'img-b2',
},
]);
expect(service.getCellImagesFromRanges('test', 'sheet1', [
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 },
])).toEqual([
{
row: 0,
col: 0,
cellAddress: 'A1',
source: 'data:image/png;base64,aGVsbG8=',
imageSourceType: ImageSourceType.BASE64,
imageId: 'img-a1',
},
{
row: 1,
col: 1,
cellAddress: 'B2',
source: 'https://cdn.example.com/photo.jpeg?version=1',
imageSourceType: ImageSourceType.URL,
imageId: 'img-b2',
},
]);
expect(service.getSelectionRangeNotation()).toBe('A1:B1, B2');
expect(service.getSelectionRowRange()).toEqual({ startRow: 0, endRow: 1 });
expect(Array.from(service.getSelectionColumnIndices()).sort((a, b) => a - b)).toEqual([0, 1]);
univer.dispose();
});
it('uses neighboring data columns to build spreadsheet-friendly image file names', () => {
const { service, univer } = setup();
const imageInfo = {
row: 1,
col: 1,
cellAddress: 'B2',
source: 'https://cdn.example.com/photo.jpeg?version=1',
imageSourceType: ImageSourceType.URL,
imageId: 'img-b2',
};
expect(service.getDataColumns()).toEqual([
{ index: 3, label: 'D' },
]);
expect(service.getDataColumnsForRanges('test', 'sheet1', [
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 },
])).toEqual([
{ index: 3, label: 'D' },
]);
expect(service.generateFileName(imageInfo, {
fileNameParts: [FileNamePart.COLUMN_VALUE, FileNamePart.CELL_ADDRESS],
columnIndex: 0,
})).toBe('Client_Name_B2.jpeg');
expect(service.generateFileNameWithContext(imageInfo, {
fileNameParts: [FileNamePart.CELL_ADDRESS, FileNamePart.COLUMN_VALUE],
columnIndex: 3,
}, 'test', 'sheet1')).toBe('B2_Summary.jpeg');
univer.dispose();
});
it('keeps every image when batch saving would otherwise produce duplicate file names', async () => {
const { service, univer } = setup();
const savedFiles: string[] = [];
window.showDirectoryPicker = async () => ({
getFileHandle: async (name: string) => {
savedFiles.push(name);
return {
createWritable: async () => ({
write: async () => undefined,
close: async () => undefined,
}),
};
},
}) as unknown as FileSystemDirectoryHandle;
await service.saveImages([
{
row: 0,
col: 0,
cellAddress: 'A1',
source: 'data:image/png;base64,aGVsbG8=',
imageSourceType: ImageSourceType.BASE64,
imageId: 'img-a1',
},
{
row: 0,
col: 1,
cellAddress: 'A1',
source: 'data:image/png;base64,aGVsbG8=',
imageSourceType: ImageSourceType.BASE64,
imageId: 'img-a1-copy',
},
], {
fileNameParts: [FileNamePart.CELL_ADDRESS],
});
expect(savedFiles).toEqual(['A1.png', 'A1_1.png']);
univer.dispose();
});
});

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 { IDrawingParam } from '@univerjs/core';
import { DrawingTypeEnum, UniverInstanceType } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetDrawingHitTestService } from '../sheet-drawing-hit-test.service';
function createDrawing(drawingId: string, subUnitId = 'sheet-1'): IDrawingParam {
return {
unitId: 'unit-1',
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_CHART,
};
}
function createScene(oKey = 'unit-1#-#sheet-1#-#chart-1') {
const handlers = new Set<(event: { offsetX: number; offsetY: number }) => void>();
const unsubscribers: Array<ReturnType<typeof vi.fn>> = [];
const scene = {
pick: vi.fn(() => ({ oKey })),
onDblclick$: {
subscribeEvent: vi.fn((handler: (event: { offsetX: number; offsetY: number }) => void) => {
handlers.add(handler);
const unsubscribe = vi.fn(() => handlers.delete(handler));
unsubscribers.push(unsubscribe);
return { unsubscribe };
}),
},
emitDoubleClick(event: { offsetX: number; offsetY: number }) {
Array.from(handlers).forEach((handler) => handler(event));
},
unsubscribers,
};
return scene;
}
function createServiceHarness(options: { drawing?: IDrawingParam | null } = {}) {
const currentSheet$ = new Subject<unknown>();
const renderCreated$ = new Subject<unknown>();
const renderDisposed$ = new Subject<string>();
const scene = createScene();
let currentRender = { unitId: 'unit-1', scene };
const drawing = options.drawing === undefined ? createDrawing('chart-1') : options.drawing;
const drawingManagerService = {
getDrawingOKey: vi.fn(() => drawing),
};
const workbook = {
getUnitId: vi.fn(() => 'unit-1'),
getActiveSheet: vi.fn(() => ({ getSheetId: vi.fn(() => 'sheet-1') })),
};
const renderManagerService = {
getRenderUnitById: vi.fn((unitId: string) => unitId === 'unit-1' ? currentRender : null),
created$: renderCreated$.asObservable(),
disposed$: renderDisposed$.asObservable(),
};
const service = new SheetDrawingHitTestService(
drawingManagerService as never,
{
getCurrentTypeOfUnit$: vi.fn(() => currentSheet$.asObservable()),
getCurrentUnitOfType: vi.fn((type: UniverInstanceType) => type === UniverInstanceType.UNIVER_SHEET ? workbook : null),
} as never,
renderManagerService as never
);
const doubleClickEvents: unknown[] = [];
const subscription = service.onDoubleClick$.subscribe((event) => doubleClickEvents.push(event));
return {
currentSheet$,
doubleClickEvents,
drawing,
drawingManagerService,
renderCreated$,
renderDisposed$,
renderManagerService,
scene,
service,
setRender(nextRender: typeof currentRender) {
currentRender = nextRender;
},
subscription,
};
}
describe('SheetDrawingHitTestService', () => {
it('emits the current sheet drawing on scene double-click', () => {
const { doubleClickEvents, drawing, drawingManagerService, scene, service, subscription } = createServiceHarness();
scene.emitDoubleClick({ offsetX: 140, offsetY: 90 });
expect(scene.pick).toHaveBeenCalled();
expect(drawingManagerService.getDrawingOKey).toHaveBeenCalledWith('unit-1#-#sheet-1#-#chart-1');
expect(doubleClickEvents[0]).toMatchObject({
drawing,
oKey: 'unit-1#-#sheet-1#-#chart-1',
offsetX: 140,
offsetY: 90,
});
subscription.unsubscribe();
service.dispose();
});
it('ignores drawings outside the active sheet', () => {
const { doubleClickEvents, scene, service, subscription } = createServiceHarness({
drawing: createDrawing('chart-1', 'sheet-2'),
});
scene.emitDoubleClick({ offsetX: 140, offsetY: 90 });
expect(doubleClickEvents).toHaveLength(0);
subscription.unsubscribe();
service.dispose();
});
it('rebinds when the active workbook render is recreated', () => {
const { doubleClickEvents, renderCreated$, scene, service, setRender, subscription } = createServiceHarness();
const nextScene = createScene();
const nextRender = { unitId: 'unit-1', scene: nextScene };
setRender(nextRender);
renderCreated$.next(nextRender);
scene.emitDoubleClick({ offsetX: 140, offsetY: 90 });
nextScene.emitDoubleClick({ offsetX: 150, offsetY: 100 });
expect(scene.unsubscribers[0]).toHaveBeenCalled();
expect(doubleClickEvents).toHaveLength(1);
expect(doubleClickEvents[0]).toMatchObject({
offsetX: 150,
offsetY: 100,
});
subscription.unsubscribe();
service.dispose();
});
});

View File

@@ -0,0 +1,674 @@
/**
* 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, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import { createIdentifier, Disposable, IImageIoService, ImageSourceType, Inject, IUniverInstanceService, IURLImageService, UniverInstanceType } from '@univerjs/core';
import { isCellImage, SheetsSelectionsService } from '@univerjs/sheets';
declare global {
// eslint-disable-next-line ts/naming-convention
interface Window {
showDirectoryPicker(options?: { mode?: 'read' | 'readwrite' }): Promise<FileSystemDirectoryHandle>;
}
}
/**
* File name part type for multi-select
*/
export enum FileNamePart {
/**
* Use cell address as part of file name (e.g., A1, B2)
*/
CELL_ADDRESS = 'cellAddress',
/**
* Use value from a specific column as part of file name
*/
COLUMN_VALUE = 'columnValue',
}
export interface ICellImageInfo {
row: number;
col: number;
cellAddress: string;
source: string;
imageSourceType: ImageSourceType;
imageId: string;
}
export interface IBatchSaveImagesConfig {
/**
* Selected file name parts (multi-select)
*/
fileNameParts: FileNamePart[];
/**
* Column index for COLUMN_VALUE part
*/
columnIndex?: number;
}
export interface IBatchSaveImagesService {
/**
* Get all cell images in the current selection
*/
getCellImagesInSelection(): ICellImageInfo[];
/**
* Get cell images from specified ranges
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
* @param ranges The ranges to get images from
*/
getCellImagesFromRanges(unitId: string, subUnitId: string, ranges: IRange[]): ICellImageInfo[];
/**
* Get columns that have data in the current selection
*/
getDataColumns(): Array<{ index: number; label: string }>;
/**
* Get columns that have data for specified ranges
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
* @param ranges The ranges to check
*/
getDataColumnsForRanges(unitId: string, subUnitId: string, ranges: IRange[]): Array<{ index: number; label: string }>;
/**
* Get current selection range as A1 notation
*/
getSelectionRangeNotation(): string;
/**
* Generate file name for a cell image based on config
* @param imageInfo The cell image info
* @param config The file name configuration
*/
generateFileName(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig): string;
/**
* Generate file name with specified worksheet context
* @param imageInfo The cell image info
* @param config The file name configuration
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
*/
generateFileNameWithContext(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): string;
/**
* Save images to the file system
* @param images The images to save
* @param config The file name configuration
*/
saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void>;
/**
* Save images to the file system with specified worksheet context
* @param images The images to save
* @param config The file name configuration
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
*/
saveImagesWithContext(images: ICellImageInfo[], config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): Promise<void>;
/**
* Download a single image directly
* @param imageInfo The cell image info
*/
downloadSingleImage(imageInfo: ICellImageInfo): Promise<void>;
/**
* Get the row range of current selection
* Returns the min and max row indices
*/
getSelectionRowRange(): { startRow: number; endRow: number } | null;
/**
* Get all column indices that are within the current selection
*/
/**
* Get all column indices that are within the current selection
*/
getSelectionColumnIndices(): Set<number>;
}
export const IBatchSaveImagesService = createIdentifier<IBatchSaveImagesService>('sheets-drawing-ui.batch-save-images.service');
/**
* Convert column index to letter (0 -> A, 1 -> B, etc.)
*/
function columnIndexToLetter(index: number): string {
let letter = '';
let temp = index;
while (temp >= 0) {
letter = String.fromCharCode((temp % 26) + 65) + letter;
temp = Math.floor(temp / 26) - 1;
}
return letter;
}
/**
* Convert row and column to A1 notation
*/
function toA1Notation(row: number, col: number): string {
return `${columnIndexToLetter(col)}${row + 1}`;
}
/**
* Convert range to A1 notation
*/
function rangeToA1Notation(range: IRange): string {
const start = toA1Notation(range.startRow, range.startColumn);
const end = toA1Notation(range.endRow, range.endColumn);
return start === end ? start : `${start}:${end}`;
}
/**
* Get image data from cell
*/
function getCellImageData(cell: ICellData): IImageData | null {
const documentData = cell.p;
const drawingId = documentData?.drawingsOrder?.[0];
if (!isCellImage(documentData) || !drawingId || !documentData?.drawings) {
return null;
}
const drawing = documentData.drawings[drawingId];
if (!drawing || !('source' in drawing) || !('imageSourceType' in drawing)) {
return null;
}
return drawing as unknown as IImageData;
}
/**
* Get file extension from mime type or source
*/
function getFileExtension(source: string, imageSourceType: ImageSourceType): string {
if (imageSourceType === ImageSourceType.BASE64) {
const match = source.match(/^data:image\/(\w+);/);
if (match) {
return match[1] === 'jpeg' ? 'jpg' : match[1];
}
}
// Try to get extension from URL
if (imageSourceType === ImageSourceType.URL) {
const urlMatch = source.match(/\.(\w+)(?:\?|$)/);
if (urlMatch) {
return urlMatch[1].toLowerCase();
}
}
// Default to png
return 'png';
}
/**
* Convert image source to blob
*/
async function imageSourceToBlob(source: string, imageSourceType: ImageSourceType): Promise<Blob> {
if (imageSourceType === ImageSourceType.BASE64) {
const response = await fetch(source);
return response.blob();
}
if (imageSourceType === ImageSourceType.URL) {
const response = await fetch(source);
return response.blob();
}
// For UUID type, we need to get the actual URL from the service
throw new Error('UUID image type requires additional handling');
}
export class BatchSaveImagesService extends Disposable implements IBatchSaveImagesService {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(SheetsSelectionsService) private readonly _selectionService: SheetsSelectionsService,
@IImageIoService private readonly _imageIoService: IImageIoService,
@IURLImageService private readonly _urlImageService: IURLImageService
) {
super();
}
getCellImagesInSelection(): ICellImageInfo[] {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getActiveSheet();
if (!worksheet) return [];
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return [];
const cellMatrix = worksheet.getCellMatrix();
const images: ICellImageInfo[] = [];
for (const selection of selections) {
const { startRow, endRow, startColumn, endColumn } = selection.range;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const cell = cellMatrix.getValue(row, col);
if (isCellImage(cell?.p)) {
const imageData = getCellImageData(cell!);
if (imageData) {
images.push({
row,
col,
cellAddress: toA1Notation(row, col),
source: imageData.source,
imageSourceType: imageData.imageSourceType,
imageId: imageData.drawingId,
});
}
}
}
}
}
return images;
}
getCellImagesFromRanges(unitId: string, subUnitId: string, ranges: IRange[]): ICellImageInfo[] {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) return [];
const cellMatrix = worksheet.getCellMatrix();
const images: ICellImageInfo[] = [];
for (const range of ranges) {
const { startRow, endRow, startColumn, endColumn } = range;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const cell = cellMatrix.getValue(row, col);
if (isCellImage(cell?.p)) {
const imageData = getCellImageData(cell!);
if (imageData) {
images.push({
row,
col,
cellAddress: toA1Notation(row, col),
source: imageData.source,
imageSourceType: imageData.imageSourceType,
imageId: imageData.drawingId,
});
}
}
}
}
}
return images;
}
getDataColumns(): Array<{ index: number; label: string }> {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getActiveSheet();
if (!worksheet) return [];
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return [];
const cellMatrix = worksheet.getCellMatrix();
const dataRange = cellMatrix.getDataRange();
// Get row range and column indices from selection
let minRow = Infinity;
let maxRow = -Infinity;
const selectionColumnIndices = new Set<number>();
for (const selection of selections) {
minRow = Math.min(minRow, selection.range.startRow);
maxRow = Math.max(maxRow, selection.range.endRow);
// Collect all column indices within selection
for (let col = selection.range.startColumn; col <= selection.range.endColumn; col++) {
selectionColumnIndices.add(col);
}
}
// Find columns that have values in the selection row range, excluding selection columns
const columnsWithData = new Set<number>();
for (let col = dataRange.startColumn; col <= dataRange.endColumn; col++) {
// Skip columns that are within the selection
if (selectionColumnIndices.has(col)) {
continue;
}
for (let row = minRow; row <= maxRow; row++) {
const cell = cellMatrix.getValue(row, col);
if (cell) {
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
columnsWithData.add(col);
break; // Found data in this column, move to next column
}
}
}
}
// Convert to array and sort
const columns: Array<{ index: number; label: string }> = [];
const sortedCols = Array.from(columnsWithData).sort((a, b) => a - b);
for (const col of sortedCols) {
columns.push({
index: col,
label: columnIndexToLetter(col),
});
}
return columns;
}
getDataColumnsForRanges(unitId: string, subUnitId: string, ranges: IRange[]): Array<{ index: number; label: string }> {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) return [];
const cellMatrix = worksheet.getCellMatrix();
const dataRange = cellMatrix.getDataRange();
// Get row range and column indices from ranges
let minRow = Infinity;
let maxRow = -Infinity;
const rangeColumnIndices = new Set<number>();
for (const range of ranges) {
minRow = Math.min(minRow, range.startRow);
maxRow = Math.max(maxRow, range.endRow);
// Collect all column indices within ranges
for (let col = range.startColumn; col <= range.endColumn; col++) {
rangeColumnIndices.add(col);
}
}
// Find columns that have values in the row range, excluding range columns
const columnsWithData = new Set<number>();
for (let col = dataRange.startColumn; col <= dataRange.endColumn; col++) {
// Skip columns that are within the ranges
if (rangeColumnIndices.has(col)) {
continue;
}
for (let row = minRow; row <= maxRow; row++) {
const cell = cellMatrix.getValue(row, col);
if (cell) {
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
columnsWithData.add(col);
break;
}
}
}
}
// Convert to array and sort
const columns: Array<{ index: number; label: string }> = [];
const sortedCols = Array.from(columnsWithData).sort((a, b) => a - b);
for (const col of sortedCols) {
columns.push({
index: col,
label: columnIndexToLetter(col),
});
}
return columns;
}
getSelectionRangeNotation(): string {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return '';
return selections.map((s) => rangeToA1Notation(s.range)).join(', ');
}
getSelectionRowRange(): { startRow: number; endRow: number } | null {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return null;
let minRow = Infinity;
let maxRow = -Infinity;
for (const selection of selections) {
minRow = Math.min(minRow, selection.range.startRow);
maxRow = Math.max(maxRow, selection.range.endRow);
}
return { startRow: minRow, endRow: maxRow };
}
getSelectionColumnIndices(): Set<number> {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return new Set();
const columnIndices = new Set<number>();
for (const selection of selections) {
for (let col = selection.range.startColumn; col <= selection.range.endColumn; col++) {
columnIndices.add(col);
}
}
return columnIndices;
}
generateFileName(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig): string {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const extension = getFileExtension(imageInfo.source, imageInfo.imageSourceType);
const parts: string[] = [];
// Process each selected file name part in order
for (const part of config.fileNameParts) {
if (part === FileNamePart.CELL_ADDRESS) {
parts.push(imageInfo.cellAddress);
} else if (part === FileNamePart.COLUMN_VALUE && config.columnIndex !== undefined) {
const worksheet = workbook?.getActiveSheet();
if (worksheet) {
const cellMatrix = worksheet.getCellMatrix();
const cell = cellMatrix.getValue(imageInfo.row, config.columnIndex);
if (cell) {
// Get cell display value
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
// Sanitize file name (remove invalid characters)
const sanitized = value.replace(/[<>:"/\\|?*]/g, '_').trim();
if (sanitized) {
parts.push(sanitized);
}
// If sanitized is empty, skip this part (don't add anything)
}
// If value is empty, skip this part
}
// If cell is empty, skip this part
}
}
}
// If no parts selected or all parts failed, use cell address as default
if (parts.length === 0) {
return `${imageInfo.cellAddress}.${extension}`;
}
// Join parts with underscore
return `${parts.join('_')}.${extension}`;
}
generateFileNameWithContext(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): string {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const extension = getFileExtension(imageInfo.source, imageInfo.imageSourceType);
const parts: string[] = [];
// Process each selected file name part in order
for (const part of config.fileNameParts) {
if (part === FileNamePart.CELL_ADDRESS) {
parts.push(imageInfo.cellAddress);
} else if (part === FileNamePart.COLUMN_VALUE && config.columnIndex !== undefined) {
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (worksheet) {
const cellMatrix = worksheet.getCellMatrix();
const cell = cellMatrix.getValue(imageInfo.row, config.columnIndex);
if (cell) {
// Get cell display value
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
// Sanitize file name (remove invalid characters)
const sanitized = value.replace(/[<>:"/\\|?*]/g, '_').trim();
if (sanitized) {
parts.push(sanitized);
}
}
}
}
}
}
// If no parts selected or all parts failed, use cell address as default
if (parts.length === 0) {
return `${imageInfo.cellAddress}.${extension}`;
}
// Join parts with underscore
return `${parts.join('_')}.${extension}`;
}
async saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void> {
// Request directory access using File System Access API
const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
// Track file names to handle duplicates
const fileNameCounts = new Map<string, number>();
for (const imageInfo of images) {
let fileName = this.generateFileName(imageInfo, config);
// Handle duplicate file names
const baseName = fileName.replace(/\.\w+$/, '');
const ext = fileName.match(/\.\w+$/)?.[0] || '.png';
const count = fileNameCounts.get(baseName) || 0;
if (count > 0) {
fileName = `${baseName}_${count}${ext}`;
}
fileNameCounts.set(baseName, count + 1);
try {
// Get image blob
const blob = await this._getImageBlob(imageInfo);
// Create file and write
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error) {
console.error(`Failed to save image ${fileName}:`, error);
throw error;
}
}
}
async saveImagesWithContext(images: ICellImageInfo[], config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): Promise<void> {
// Request directory access using File System Access API
const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
// Track file names to handle duplicates
const fileNameCounts = new Map<string, number>();
for (const imageInfo of images) {
let fileName = this.generateFileNameWithContext(imageInfo, config, unitId, subUnitId);
// Handle duplicate file names
const baseName = fileName.replace(/\.\w+$/, '');
const ext = fileName.match(/\.\w+$/)?.[0] || '.png';
const count = fileNameCounts.get(baseName) || 0;
if (count > 0) {
fileName = `${baseName}_${count}${ext}`;
}
fileNameCounts.set(baseName, count + 1);
try {
// Get image blob
const blob = await this._getImageBlob(imageInfo);
// Create file and write
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error) {
console.error(`Failed to save image ${fileName}:`, error);
throw error;
}
}
}
async downloadSingleImage(imageInfo: ICellImageInfo): Promise<void> {
const extension = getFileExtension(imageInfo.source, imageInfo.imageSourceType);
const fileName = `${imageInfo.cellAddress}.${extension}`;
try {
const blob = await this._getImageBlob(imageInfo);
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error(`Failed to download image ${fileName}:`, error);
throw error;
}
}
private async _getImageBlob(imageInfo: ICellImageInfo): Promise<Blob> {
if (imageInfo.imageSourceType === ImageSourceType.UUID) {
// For UUID, we need to get the actual image from the service
const imageUrl = await this._imageIoService.getImage(imageInfo.source);
return imageSourceToBlob(imageUrl, ImageSourceType.URL);
}
if (imageInfo.imageSourceType === ImageSourceType.URL) {
return this._urlImageService.downloadImage(imageInfo.source);
}
return imageSourceToBlob(imageInfo.source, imageInfo.imageSourceType);
}
}

File diff suppressed because it is too large Load Diff

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 { DrawingTypeEnum, IDisposable, IDrawingParam } from '@univerjs/core';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
export interface IDrawingContextMenuProviderContext {
unitId: string;
subUnitId: string;
drawingId: string;
drawingType: DrawingTypeEnum;
drawing: IDrawingParam;
}
export interface IDrawingContextMenuProvider {
getContextMenuPosition(context: IDrawingContextMenuProviderContext): string | undefined;
}
export interface IDrawingContextMenuService {
registerProvider(drawingType: DrawingTypeEnum, provider: IDrawingContextMenuProvider): IDisposable;
getContextMenuPosition(context: IDrawingContextMenuProviderContext): string | undefined;
}
export const IDrawingContextMenuService = createIdentifier<IDrawingContextMenuService>('sheets-drawing-ui.drawing-context-menu.service');
export class DrawingContextMenuService extends Disposable implements IDrawingContextMenuService {
private readonly _providers = new Map<DrawingTypeEnum, IDrawingContextMenuProvider>();
registerProvider(drawingType: DrawingTypeEnum, provider: IDrawingContextMenuProvider): IDisposable {
this._providers.set(drawingType, provider);
return toDisposable(() => {
if (this._providers.get(drawingType) === provider) {
this._providers.delete(drawingType);
}
});
}
getContextMenuPosition(context: IDrawingContextMenuProviderContext): string | undefined {
return this._providers.get(context.drawingType)?.getContextMenuPosition(context);
}
}

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 { IRectProps } from '@univerjs/engine-render';
type SheetChartRenderObjectConfig = Pick<
IRectProps,
'borderEnabled' | 'paintFirst' | 'radius' | 'rotateEnabled' | 'strokeWidth'
>;
/** Shared Chart frame config for editor and isolated print Scene objects. */
export const SHEET_CHART_RENDER_OBJECT_CONFIG = Object.freeze({
borderEnabled: false,
paintFirst: 'stroke',
radius: 8,
rotateEnabled: false,
strokeWidth: 1,
} satisfies SheetChartRenderObjectConfig);

View File

@@ -0,0 +1,192 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IDrawingParam, Workbook } from '@univerjs/core';
import type { IMouseEvent, IPointerEvent, IRender, Scene } from '@univerjs/engine-render';
import type { Observable } from 'rxjs';
import { Disposable, IUniverInstanceService, toDisposable, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService, Vector2 } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
export interface ISheetDrawingHitTestResult {
drawing: IDrawingParam;
oKey: string;
offsetX: number;
offsetY: number;
}
export interface ISheetDrawingDoubleClickEvent extends ISheetDrawingHitTestResult {
nativeEvent: IPointerEvent | IMouseEvent;
}
/**
* Resolves sheet canvas pointer coordinates to drawing objects.
*
* Callers consume drawing-level events and should not depend on renderer object
* keys, scene picking, or active sheet filtering.
*/
export class SheetDrawingHitTestService extends Disposable {
private readonly _onDoubleClick$ = new Subject<ISheetDrawingDoubleClickEvent>();
readonly onDoubleClick$: Observable<ISheetDrawingDoubleClickEvent> = this._onDoubleClick$.asObservable();
private _doubleClickDisposable: IDisposable | null = null;
private _boundRender: IRender | null = null;
private _boundScene: Scene | null = null;
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
this._init();
}
override dispose(): void {
this._disposeDoubleClickListener();
this._onDoubleClick$.complete();
super.dispose();
}
hitTestCurrentSheet(offsetX: number, offsetY: number): ISheetDrawingHitTestResult | null {
const context = this._getActiveSheetContext();
if (!context) {
return null;
}
return this.hitTest(context.unitId, context.subUnitId, offsetX, offsetY);
}
hitTest(unitId: string, subUnitId: string, offsetX: number, offsetY: number): ISheetDrawingHitTestResult | null {
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (!scene) {
return null;
}
return this._hitTestScene(scene, unitId, subUnitId, offsetX, offsetY);
}
private _init(): void {
this.disposeWithMe(toDisposable(
this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).subscribe(() => {
this._bindDoubleClickListener();
})
));
this.disposeWithMe(toDisposable(
this._renderManagerService.created$.subscribe((render) => {
const context = this._getActiveSheetContext();
if (render.unitId === context?.unitId) {
this._bindDoubleClickListener();
}
})
));
this.disposeWithMe(toDisposable(
this._renderManagerService.disposed$.subscribe((unitId) => {
if (unitId === this._boundRender?.unitId) {
this._disposeDoubleClickListener();
}
})
));
this._bindDoubleClickListener();
}
private _bindDoubleClickListener(): void {
const context = this._getActiveSheetContext();
if (!context) {
this._disposeDoubleClickListener();
return;
}
const render = this._renderManagerService.getRenderUnitById(context.unitId);
const scene = render?.scene;
if (!render || !scene) {
this._disposeDoubleClickListener();
return;
}
if (this._boundRender === render && this._boundScene === scene && this._doubleClickDisposable) {
return;
}
this._disposeDoubleClickListener();
const subscription = scene.onDblclick$.subscribeEvent((event: IPointerEvent | IMouseEvent) => {
const activeContext = this._getActiveSheetContext();
if (!activeContext || activeContext.unitId !== context.unitId) {
return;
}
const hit = this._hitTestScene(scene, activeContext.unitId, activeContext.subUnitId, event.offsetX, event.offsetY);
if (!hit) {
return;
}
this._onDoubleClick$.next({
...hit,
nativeEvent: event,
});
});
this._doubleClickDisposable = { dispose: () => subscription.unsubscribe() };
this._boundRender = render;
this._boundScene = scene;
}
private _disposeDoubleClickListener(): void {
this._doubleClickDisposable?.dispose();
this._doubleClickDisposable = null;
this._boundRender = null;
this._boundScene = null;
}
private _hitTestScene(scene: Scene, unitId: string, subUnitId: string, offsetX: number, offsetY: number): ISheetDrawingHitTestResult | null {
const pickedObject = scene.pick(Vector2.FromArray([offsetX, offsetY])) as {
oKey?: string;
} | null | undefined;
const oKey = typeof pickedObject?.oKey === 'string' ? pickedObject.oKey : null;
if (!oKey) {
return null;
}
const drawing = this._drawingManagerService.getDrawingOKey(oKey);
if (!drawing || drawing.unitId !== unitId || drawing.subUnitId !== subUnitId) {
return null;
}
return {
drawing,
oKey,
offsetX,
offsetY,
};
}
private _getActiveSheetContext(): { unitId: string; subUnitId: string } | null {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const unitId = workbook?.getUnitId();
const subUnitId = workbook?.getActiveSheet()?.getSheetId();
if (!unitId || !subUnitId) {
return null;
}
return { unitId, subUnitId };
}
}

View File

@@ -0,0 +1,109 @@
/**
* 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, Worksheet } from '@univerjs/core';
import type { Scene, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { IFloatDomData } from '@univerjs/sheets-drawing';
import type { IFloatDom, IFloatDomLayout } from '@univerjs/ui';
import type { ComponentType } from 'react';
import { render, unmount } from '@univerjs/design';
import { connectInjector, PrintFloatDomSingle } from '@univerjs/ui';
import { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import { transformBound2DOMBound } from '../services/canvas-float-dom-manager.service';
export interface IPrintingFloatDomProps {
floatDomInfos: IFloatDomData[];
scene: Scene;
skeleton: SpreadsheetSkeleton;
worksheet: Worksheet;
};
export function createPrintingFloatDom(
info: IFloatDomData,
scene: Scene,
skeleton: SpreadsheetSkeleton,
worksheet: Worksheet
) {
const { width, height, angle, left, top } = info.transform!;
// The print root starts at the sheet content origin and does not include row/column headers.
const offsetBound = transformBound2DOMBound(
{
left: left ?? 0,
right: (left ?? 0) + (width ?? 0),
top: top ?? 0,
bottom: (top ?? 0) + (height ?? 0),
},
scene,
skeleton,
worksheet,
undefined,
true
);
const { scaleX, scaleY } = scene.getAncestorScale();
const domPos: IFloatDomLayout = {
startX: offsetBound.left,
endX: offsetBound.right,
startY: offsetBound.top,
endY: offsetBound.bottom,
rotate: angle!,
width: width! * scaleX,
height: height! * scaleY,
absolute: offsetBound.absolute,
};
const floatDom: IFloatDom & { position: IFloatDomLayout } = {
position$: new BehaviorSubject<IFloatDomLayout>(domPos),
position: domPos,
id: info.drawingId,
componentKey: info.componentKey,
onPointerMove: () => { },
onPointerDown: () => { },
onPointerUp: () => { },
onWheel: () => { },
unitId: info.unitId,
data: info.data,
// Preserve props required by custom printable floating DOM components.
props: (info as IFloatDomData & { props?: Record<string, unknown> }).props,
};
return [info.drawingId, floatDom] as const;
}
export const PrintingFloatDom = (props: IPrintingFloatDomProps) => {
const { floatDomInfos, scene, skeleton, worksheet } = props;
const floatDomParams = useMemo(
() => floatDomInfos.map((info) => createPrintingFloatDom(info, scene, skeleton, worksheet)),
[floatDomInfos, scene, skeleton, worksheet]
);
return (
<div style={{ position: 'absolute', top: 0, left: 0 }}>
{floatDomParams.map(([id, floatDom]) => (
<PrintFloatDomSingle key={id} layer={floatDom} id={id} position={floatDom.position} />
))}
</div>
);
};
export function mountPrintingFloatDom(props: IPrintingFloatDomProps, root: HTMLElement, injector: Injector): () => void {
const PrintingFloatDomInjector = connectInjector(PrintingFloatDom, injector) as ComponentType<IPrintingFloatDomProps>;
render(<PrintingFloatDomInjector {...props} />, root);
return () => unmount(root);
}

View File

@@ -0,0 +1,202 @@
/**
* 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 { Worksheet } from '@univerjs/core';
import type { Scene, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { IFloatDomData } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum } from '@univerjs/core';
import { SHEET_VIEWPORT_KEY } from '@univerjs/engine-render';
import { ComponentManager } from '@univerjs/ui';
import { act } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../__tests__/create-sheets-drawing-ui-test-bed';
import { createPrintingFloatDom, mountPrintingFloatDom } from '../PrintingFloatDom';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const PRINTING_COMPONENT_KEY = 'printing-visible-content';
class TestPrintingScene {
getAncestorScale() {
return { scaleX: 1.5, scaleY: 2 };
}
getViewport(key: string) {
if (key !== SHEET_VIEWPORT_KEY.VIEW_MAIN) {
return null;
}
return {
left: 0,
top: 0,
right: 600,
bottom: 500,
viewportScrollX: 0,
viewportScrollY: 0,
};
}
}
class TestPrintingSkeleton {
readonly rowHeaderWidth = 46;
readonly columnHeaderHeight = 28;
rowStartY(row: number): number {
return row * 24;
}
colStartX(column: number): number {
return column * 72;
}
}
class TestPrintingWorksheet {
getFreeze() {
return {
startColumn: 0,
startRow: 0,
xSplit: 0,
ySplit: 0,
};
}
}
function PrintingContent(props: { data?: { label?: string }; printingLabel?: string }) {
return <span data-testid="printed-content">{props.printingLabel ?? props.data?.label}</span>;
}
function createFloatDomInfo(drawingId = 'printed-float-dom', label = 'printing'): IFloatDomData {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType: DrawingTypeEnum.DRAWING_DOM,
componentKey: PRINTING_COMPONENT_KEY,
transform: {
left: 20,
top: 40,
width: 200,
height: 100,
angle: 27,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
},
data: {
label,
},
axisAlignSheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}
describe('PrintingFloatDom', () => {
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createSheetsDrawingUiTestBed> | undefined;
let unmountPrintingFloatDom: (() => void) | undefined;
afterEach(() => {
act(() => {
unmountPrintingFloatDom?.();
});
container?.remove();
currentTestBed?.univer.dispose();
container = undefined;
currentTestBed = undefined;
unmountPrintingFloatDom = undefined;
});
it('prints floating doms with the scene scale applied to size and rotation', async () => {
currentTestBed = createSheetsDrawingUiTestBed(undefined, [
[ComponentManager],
]);
currentTestBed.injector.get(ComponentManager).register(PRINTING_COMPONENT_KEY, PrintingContent);
const root = document.createElement('div');
container = root;
document.body.appendChild(root);
await act(async () => {
unmountPrintingFloatDom = mountPrintingFloatDom({
floatDomInfos: [createFloatDomInfo()],
scene: new TestPrintingScene() as unknown as Scene,
skeleton: new TestPrintingSkeleton() as unknown as SpreadsheetSkeleton,
worksheet: new TestPrintingWorksheet() as unknown as Worksheet,
}, root, currentTestBed!.injector);
await Promise.resolve();
});
const printedFloatDom = root.querySelector<HTMLElement>('#printed-float-dom');
if (!printedFloatDom?.parentElement) {
throw new Error('Printed float dom was not rendered');
}
expect(printedFloatDom.style.width).toBe('296px');
expect(printedFloatDom.style.height).toBe('196px');
expect(printedFloatDom.parentElement.style.width).toBe('298px');
expect(printedFloatDom.parentElement.style.height).toBe('198px');
expect(printedFloatDom.parentElement.style.transform).toBe('rotate(27deg)');
});
it('prints each floating DOM with its own component data', async () => {
currentTestBed = createSheetsDrawingUiTestBed(undefined, [
[ComponentManager],
]);
currentTestBed.injector.get(ComponentManager).register(PRINTING_COMPONENT_KEY, PrintingContent);
const root = document.createElement('div');
container = root;
document.body.appendChild(root);
await act(async () => {
unmountPrintingFloatDom = mountPrintingFloatDom({
floatDomInfos: [
createFloatDomInfo('first-printed-float-dom', 'First printed label'),
createFloatDomInfo('second-printed-float-dom', 'Second printed label'),
],
scene: new TestPrintingScene() as unknown as Scene,
skeleton: new TestPrintingSkeleton() as unknown as SpreadsheetSkeleton,
worksheet: new TestPrintingWorksheet() as unknown as Worksheet,
}, root, currentTestBed!.injector);
await Promise.resolve();
});
expect(root.textContent).toContain('First printed label');
expect(root.textContent).toContain('Second printed label');
});
it('forwards printing-only component props', () => {
const info = {
...createFloatDomInfo(),
props: { printingLabel: 'Ready callback props' },
} as IFloatDomData;
const [, floatDom] = createPrintingFloatDom(
info,
new TestPrintingScene() as unknown as Scene,
new TestPrintingSkeleton() as unknown as SpreadsheetSkeleton,
new TestPrintingWorksheet() as unknown as Worksheet
);
expect(floatDom.props).toEqual({ printingLabel: 'Ready callback props' });
});
});

View File

@@ -0,0 +1,169 @@
/**
* 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 } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { Button, Checkbox, CheckboxGroup, FormLayout, Select } from '@univerjs/design';
import { useHighlightRange } from '@univerjs/sheets-ui';
import { IDialogService, useDependency } from '@univerjs/ui';
import { useCallback, useMemo, useState } from 'react';
import { FileNamePart, IBatchSaveImagesService } from '../../services/batch-save-images.service';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from './component-name';
export interface IBatchSaveImagesDialogProps {
SelectComponent?: typeof Select;
}
export function BatchSaveImagesDialog({ SelectComponent = Select }: IBatchSaveImagesDialogProps = {}) {
const localeService = useDependency(LocaleService);
const dialogService = useDependency(IDialogService);
const batchSaveService = useDependency(IBatchSaveImagesService);
const [fileNameParts, setFileNameParts] = useState<Array<string | number | boolean>>([FileNamePart.CELL_ADDRESS]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const images = useMemo(() => batchSaveService.getCellImagesInSelection(), [batchSaveService]);
const dataColumns = useMemo(() => batchSaveService.getDataColumns(), [batchSaveService]);
const rowRange = useMemo(() => batchSaveService.getSelectionRowRange(), [batchSaveService]);
// Check if there are available columns to select (columns outside the selection range)
const hasAvailableColumns = dataColumns.length > 0;
const columnOptions = useMemo(() => {
return dataColumns.map((col) => ({
label: col.label,
value: String(col.index),
}));
}, [dataColumns]);
const [selectedColumn, setSelectedColumn] = useState<string>(
() => columnOptions.length > 0 ? columnOptions[0].value : '0'
);
// Calculate highlight range based on selected column and original selection row range
const highlightRanges = useMemo<IRange[]>(() => {
const showColumnSelect = fileNameParts.includes(FileNamePart.COLUMN_VALUE);
if (!showColumnSelect || !rowRange) {
return [];
}
const colIndex = Number(selectedColumn);
return [{
startRow: rowRange.startRow,
endRow: rowRange.endRow,
startColumn: colIndex,
endColumn: colIndex,
}];
}, [fileNameParts, selectedColumn, rowRange]);
// Highlight the selected column range
useHighlightRange(highlightRanges);
const handleFileNamePartsChange = useCallback((value: Array<string | number | boolean>) => {
// Ensure at least one option is selected
if (value.length === 0) {
return;
}
setFileNameParts(value);
}, []);
const handleColumnChange = useCallback((value: string | number | boolean) => {
setSelectedColumn(String(value));
}, []);
const handleCancel = useCallback(() => {
dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID);
}, [dialogService]);
const handleConfirm = useCallback(async () => {
if (images.length === 0) {
return;
}
setSaving(true);
setError(null);
try {
await batchSaveService.saveImages(images, {
fileNameParts: fileNameParts as FileNamePart[],
columnIndex: fileNameParts.includes(FileNamePart.COLUMN_VALUE) ? Number(selectedColumn) : undefined,
});
dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID);
} catch (err) {
console.error('Failed to save images:', err);
setError(localeService.t<LocaleKey>('sheets-drawing-ui.save.error'));
} finally {
setSaving(false);
}
}, [batchSaveService, images, fileNameParts, selectedColumn, dialogService, localeService]);
const showColumnSelect = fileNameParts.includes(FileNamePart.COLUMN_VALUE);
return (
<div className="univer-flex univer-flex-col">
<FormLayout label={localeService.t<LocaleKey>('sheets-drawing-ui.save.imageCount')}>
<div className="univer-text-sm univer-text-gray-600">{images.length}</div>
</FormLayout>
<FormLayout label={localeService.t<LocaleKey>('sheets-drawing-ui.save.fileNameConfig')}>
<CheckboxGroup value={fileNameParts} onChange={handleFileNamePartsChange} direction="vertical">
<Checkbox value={FileNamePart.CELL_ADDRESS} disabled={!hasAvailableColumns}>
{localeService.t<LocaleKey>('sheets-drawing-ui.save.useRowCol')}
</Checkbox>
{hasAvailableColumns && (
<Checkbox value={FileNamePart.COLUMN_VALUE}>
{localeService.t<LocaleKey>('sheets-drawing-ui.save.useColumnValue')}
</Checkbox>
)}
</CheckboxGroup>
</FormLayout>
{showColumnSelect && (
<FormLayout label={localeService.t<LocaleKey>('sheets-drawing-ui.save.selectColumn')}>
<SelectComponent
value={selectedColumn}
options={columnOptions}
onChange={handleColumnChange}
/>
</FormLayout>
)}
{error && (
<div className="univer-text-xs univer-text-red-500">{error}</div>
)}
<div
className={`
univer-flex univer-justify-end univer-gap-2 univer-border-t univer-border-gray-200 univer-pt-3
`}
>
<Button onClick={handleCancel} disabled={saving}>
{localeService.t<LocaleKey>('sheets-drawing-ui.save.cancel')}
</Button>
<Button
variant="primary"
onClick={handleConfirm}
disabled={saving || images.length === 0}
>
{saving ? localeService.t<LocaleKey>('sheets-drawing-ui.save.saving') : localeService.t<LocaleKey>('sheets-drawing-ui.save.confirm')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
/**
* 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 { MobileSelect } from '@univerjs/design';
import { BatchSaveImagesDialog } from './BatchSaveImagesDialog';
export function MobileBatchSaveImagesDialog() {
return <BatchSaveImagesDialog SelectComponent={MobileSelect} />;
}

View File

@@ -0,0 +1,498 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IRange } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { Root } from 'react-dom/client';
import type { IBatchSaveImagesConfig, ICellImageInfo } from '../../../services/batch-save-images.service';
import { Disposable, ImageSourceType, toDisposable } from '@univerjs/core';
import { IMarkSelectionService } from '@univerjs/sheets-ui';
import { IDialogService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
import { FileNamePart, IBatchSaveImagesService } from '../../../services/batch-save-images.service';
import { BatchSaveImagesDialog } from '../BatchSaveImagesDialog';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from '../component-name';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class TestDialogService extends Disposable implements IDialogService {
private readonly _closedIds: string[] = [];
private readonly _dialogs$ = new BehaviorSubject<any[]>([]);
open(): IDisposable {
return toDisposable(() => undefined);
}
close(id: string): void {
this._closedIds.push(id);
}
closeAll(): void {
this._closedIds.push('*');
}
getDialogs$() {
return this._dialogs$.asObservable();
}
get closedIds(): string[] {
return this._closedIds;
}
override dispose(): void {
super.dispose();
this._dialogs$.complete();
}
}
class TestMarkSelectionService implements IMarkSelectionService {
private _nextId = 1;
private readonly _shapeMap = new Map<string, ISelectionWithStyle>();
private readonly _addedRanges: IRange[] = [];
private readonly _removedIds: string[] = [];
addShape(selection: ISelectionWithStyle): string {
const id = `shape-${this._nextId++}`;
this._shapeMap.set(id, selection);
this._addedRanges.push(selection.range);
return id;
}
addShapeWithNoFresh(selection: ISelectionWithStyle): string {
return this.addShape(selection);
}
removeShape(id: string): void {
this._shapeMap.delete(id);
this._removedIds.push(id);
}
removeAllShapes(): void {
this._shapeMap.clear();
}
refreshShapes(): void {
return undefined;
}
getShapeMap(): ReturnType<IMarkSelectionService['getShapeMap']> {
return this._shapeMap as unknown as ReturnType<IMarkSelectionService['getShapeMap']>;
}
get addedRanges(): IRange[] {
return this._addedRanges;
}
get activeRanges(): IRange[] {
return Array.from(this._shapeMap.values()).map((selection) => selection.range);
}
get removedIds(): string[] {
return this._removedIds;
}
}
class TestBatchSaveImagesService implements IBatchSaveImagesService {
private readonly _images = [
createImage('image-a', 'B3', 2, 1),
createImage('image-b', 'B4', 3, 1),
];
private readonly _dataColumns = [
{ index: 4, label: 'Name' },
{ index: 6, label: 'SKU' },
];
private _failSaving = false;
private _savedImages: ICellImageInfo[] = [];
private _savedConfig: IBatchSaveImagesConfig | null = null;
failSaving(): void {
this._failSaving = true;
}
get savedImages(): ICellImageInfo[] {
return this._savedImages;
}
get savedConfig(): IBatchSaveImagesConfig | null {
return this._savedConfig;
}
getCellImagesInSelection(): ICellImageInfo[] {
return this._images;
}
getCellImagesFromRanges(): ICellImageInfo[] {
return this._images;
}
getDataColumns(): Array<{ index: number; label: string }> {
return this._dataColumns;
}
getDataColumnsForRanges(): Array<{ index: number; label: string }> {
return this._dataColumns;
}
getSelectionRangeNotation(): string {
return 'B3:B4';
}
generateFileName(imageInfo: ICellImageInfo): string {
return imageInfo.cellAddress;
}
generateFileNameWithContext(imageInfo: ICellImageInfo): string {
return imageInfo.cellAddress;
}
async saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void> {
if (this._failSaving) {
throw new Error('save failed');
}
this._savedImages = images;
this._savedConfig = config;
}
async saveImagesWithContext(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void> {
await this.saveImages(images, config);
}
async downloadSingleImage(): Promise<void> {
return undefined;
}
getSelectionRowRange(): { startRow: number; endRow: number } {
return { startRow: 2, endRow: 3 };
}
getSelectionColumnIndices(): Set<number> {
return new Set([1]);
}
}
class TestEmptyBatchSaveImagesService extends TestBatchSaveImagesService {
override getCellImagesInSelection(): ICellImageInfo[] {
return [];
}
override getCellImagesFromRanges(): ICellImageInfo[] {
return [];
}
}
class TestNoDataColumnsBatchSaveImagesService extends TestBatchSaveImagesService {
override getDataColumns(): Array<{ index: number; label: string }> {
return [];
}
override getDataColumnsForRanges(): Array<{ index: number; label: string }> {
return [];
}
}
function createImage(imageId: string, cellAddress: string, row: number, col: number): ICellImageInfo {
return {
row,
col,
cellAddress,
imageId,
source: `https://example.com/${imageId}.png`,
imageSourceType: ImageSourceType.URL,
};
}
function getButton(container: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === text);
if (!button) {
throw new Error(`Button ${text} was not rendered`);
}
return button;
}
function getCheckbox(container: HTMLElement, index: number): HTMLInputElement {
const checkbox = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')[index];
if (!checkbox) {
throw new Error(`Checkbox ${index} was not rendered`);
}
return checkbox;
}
function openSelect(select: HTMLElement): void {
select.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
}
describe('BatchSaveImagesDialog', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
root = undefined;
container = undefined;
});
async function renderDialog(batchSaveImagesService = TestBatchSaveImagesService) {
const testBed = createSheetsDrawingUiTestBed(undefined, [
[IDialogService, { useClass: TestDialogService }],
[IMarkSelectionService, { useClass: TestMarkSelectionService }],
[IBatchSaveImagesService, { useClass: batchSaveImagesService }],
]);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<BatchSaveImagesDialog />
</RediContext.Provider>
);
await Promise.resolve();
});
return {
testBed,
batchSaveService: testBed.get(IBatchSaveImagesService) as TestBatchSaveImagesService,
dialogService: testBed.get(IDialogService) as unknown as TestDialogService,
markSelectionService: testBed.get(IMarkSelectionService) as TestMarkSelectionService,
};
}
it('saves selected images with the chosen column value in file names', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog();
const columnValueCheckbox = getCheckbox(container!, 1);
await act(async () => {
columnValueCheckbox.click();
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([{
startRow: 2,
endRow: 3,
startColumn: 4,
endColumn: 4,
}]);
await act(async () => {
openSelect(container!.querySelector<HTMLElement>('[data-u-comp="select"]')!);
await Promise.resolve();
});
const skuOption = Array.from(document.body.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"]'))
.find((item) => item.textContent === 'SKU');
if (!skuOption) {
throw new Error('SKU column option was not rendered');
}
await act(async () => {
skuOption.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([{
startRow: 2,
endRow: 3,
startColumn: 6,
endColumn: 6,
}]);
expect(markSelectionService.removedIds).toEqual(['shape-1']);
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.confirm').click();
await Promise.resolve();
});
expect(batchSaveService.savedImages.map((image) => image.imageId)).toEqual(['image-a', 'image-b']);
expect(batchSaveService.savedConfig).toEqual({
fileNameParts: [FileNamePart.CELL_ADDRESS, FileNamePart.COLUMN_VALUE],
columnIndex: 6,
});
expect(dialogService.closedIds).toEqual([BATCH_SAVE_IMAGES_DIALOG_ID]);
testBed.univer.dispose();
});
it('removes the highlighted lookup column when column values are no longer used in file names', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog();
const columnValueCheckbox = getCheckbox(container!, 1);
await act(async () => {
columnValueCheckbox.click();
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([{
startRow: 2,
endRow: 3,
startColumn: 4,
endColumn: 4,
}]);
await act(async () => {
columnValueCheckbox.click();
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([]);
expect(markSelectionService.removedIds).toEqual(['shape-1']);
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.confirm').click();
await Promise.resolve();
});
expect(batchSaveService.savedImages.map((image) => image.imageId)).toEqual(['image-a', 'image-b']);
expect(batchSaveService.savedConfig).toEqual({
fileNameParts: [FileNamePart.CELL_ADDRESS],
columnIndex: undefined,
});
expect(dialogService.closedIds).toEqual([BATCH_SAVE_IMAGES_DIALOG_ID]);
testBed.univer.dispose();
});
it('keeps cell address naming when the user tries to clear the last file name option', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog();
const cellAddressCheckbox = getCheckbox(container!, 0);
await act(async () => {
cellAddressCheckbox.click();
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([]);
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.confirm').click();
await Promise.resolve();
});
expect(batchSaveService.savedImages.map((image) => image.imageId)).toEqual(['image-a', 'image-b']);
expect(batchSaveService.savedConfig).toEqual({
fileNameParts: [FileNamePart.CELL_ADDRESS],
columnIndex: undefined,
});
expect(dialogService.closedIds).toEqual([BATCH_SAVE_IMAGES_DIALOG_ID]);
testBed.univer.dispose();
});
it('clears lookup column highlight without saving when the dialog is cancelled', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog();
const columnValueCheckbox = getCheckbox(container!, 1);
await act(async () => {
columnValueCheckbox.click();
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([{
startRow: 2,
endRow: 3,
startColumn: 4,
endColumn: 4,
}]);
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.cancel').click();
await Promise.resolve();
});
expect(batchSaveService.savedConfig).toBeNull();
expect(dialogService.closedIds).toEqual([BATCH_SAVE_IMAGES_DIALOG_ID]);
await act(async () => {
root!.unmount();
root = undefined;
await Promise.resolve();
});
expect(markSelectionService.activeRanges).toEqual([]);
expect(markSelectionService.removedIds).toEqual(['shape-1']);
testBed.univer.dispose();
});
it('keeps the dialog open and shows the save error when saving fails', async () => {
const { testBed, batchSaveService, dialogService } = await renderDialog();
batchSaveService.failSaving();
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.confirm').click();
await Promise.resolve();
});
expect(batchSaveService.savedConfig).toBeNull();
expect(dialogService.closedIds).toEqual([]);
expect(container!.textContent).toContain('sheets-drawing-ui.save.error');
testBed.univer.dispose();
});
it('saves by cell address when no lookup columns are available', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog(TestNoDataColumnsBatchSaveImagesService);
expect(container!.querySelector('[data-u-comp="select"]')).toBeNull();
expect(container!.textContent).not.toContain('sheets-drawing-ui.save.useColumnValue');
expect(markSelectionService.activeRanges).toEqual([]);
await act(async () => {
getButton(container!, 'sheets-drawing-ui.save.confirm').click();
await Promise.resolve();
});
expect(batchSaveService.savedImages.map((image) => image.imageId)).toEqual(['image-a', 'image-b']);
expect(batchSaveService.savedConfig).toEqual({
fileNameParts: [FileNamePart.CELL_ADDRESS],
columnIndex: undefined,
});
expect(dialogService.closedIds).toEqual([BATCH_SAVE_IMAGES_DIALOG_ID]);
testBed.univer.dispose();
});
it('prevents confirming when the selection contains no images', async () => {
const { testBed, batchSaveService, dialogService, markSelectionService } = await renderDialog(TestEmptyBatchSaveImagesService);
const confirmButton = getButton(container!, 'sheets-drawing-ui.save.confirm');
expect(confirmButton.disabled).toBe(true);
await act(async () => {
confirmButton.click();
await Promise.resolve();
});
expect(batchSaveService.savedConfig).toBeNull();
expect(dialogService.closedIds).toEqual([]);
expect(markSelectionService.activeRanges).toEqual([]);
testBed.univer.dispose();
});
});

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 BATCH_SAVE_IMAGES_DIALOG_ID = 'sheet.dialog.batch-save-images';

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