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
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:
37
packages/drawing/README.md
Normal file
37
packages/drawing/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @univerjs/drawing
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/drawing)
|
||||
[](https://npmjs.com/package/@univerjs/drawing)
|
||||
[](https://npmjs.com/package/@univerjs/drawing)
|
||||
|
||||
`@univerjs/drawing` provides the shared drawing data model, services, and commands used by Docs, Sheets, and other drawing-enabled surfaces.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | UMD global | CSS | Locales | Facade entry |
|
||||
| --- | --- | :---: | :---: | :---: |
|
||||
| `@univerjs/drawing` | `UniverDrawing` | No | No | No |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @univerjs/drawing
|
||||
# or
|
||||
npm install @univerjs/drawing
|
||||
```
|
||||
|
||||
Keep all `@univerjs/*` packages on the same version.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { UniverDrawingPlugin } from '@univerjs/drawing';
|
||||
|
||||
univer.registerPlugin(UniverDrawingPlugin);
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.univer.ai)
|
||||
- [NPM package](https://npmjs.com/package/@univerjs/drawing)
|
||||
- [GitHub repository](https://github.com/dream-num/univer)
|
||||
78
packages/drawing/package.json
Normal file
78
packages/drawing/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@univerjs/drawing",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Shared drawing models, commands, and services for Univer.",
|
||||
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/univer"
|
||||
},
|
||||
"homepage": "https://univer.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dream-num/univer"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/dream-num/univer/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"univer",
|
||||
"drawing",
|
||||
"graphics",
|
||||
"canvas",
|
||||
"plugin"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./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": {
|
||||
"rxjs": ">=7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@univerjs/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
33
packages/drawing/src/basics/config.ts
Normal file
33
packages/drawing/src/basics/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 DRAWING_IMAGE_WIDTH_LIMIT = 500;
|
||||
|
||||
export const DRAWING_IMAGE_HEIGHT_LIMIT = 500;
|
||||
|
||||
export const DRAWING_IMAGE_COUNT_LIMIT = 10;
|
||||
|
||||
let DRAWING_IMAGE_ALLOW_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
export function setDrawingImageAllowSize(size: number): void {
|
||||
DRAWING_IMAGE_ALLOW_SIZE = size;
|
||||
}
|
||||
|
||||
export function getDrawingImageAllowSize(): number {
|
||||
return DRAWING_IMAGE_ALLOW_SIZE;
|
||||
}
|
||||
|
||||
export const DRAWING_IMAGE_ALLOW_IMAGE_LIST = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/bmp'];
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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, IDrawingSearch } from '@univerjs/core';
|
||||
import { DrawingTypeEnum, ICommandService, Univer } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DrawingManagerService } from '../../../services/drawing-manager-impl.service';
|
||||
import { IDrawingManagerService } from '../../../services/drawing-manager.service';
|
||||
import { SetDrawingSelectedOperation } from '../set-drawing-selected.operation';
|
||||
|
||||
const unitId = 'drawing-unit';
|
||||
const subUnitId = 'drawing-subunit';
|
||||
|
||||
function createDrawing(drawingId: string): IDrawingParam {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearch(drawingId: string): IDrawingSearch {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetDrawingSelectedOperation', () => {
|
||||
let univer: Univer;
|
||||
let commandService: ICommandService;
|
||||
let drawingManagerService: IDrawingManagerService;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
|
||||
|
||||
commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(SetDrawingSelectedOperation);
|
||||
drawingManagerService = injector.get(IDrawingManagerService);
|
||||
drawingManagerService.registerDrawingData(unitId, {
|
||||
[subUnitId]: {
|
||||
data: {
|
||||
image1: createDrawing('image1'),
|
||||
image2: createDrawing('image2'),
|
||||
},
|
||||
order: ['image1', 'image2'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('selects only drawings that exist in the drawing manager', async () => {
|
||||
const result = await commandService.executeCommand(SetDrawingSelectedOperation.id, [
|
||||
createSearch('image2'),
|
||||
createSearch('missing'),
|
||||
]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(drawingManagerService.getFocusDrawings()).toEqual([createDrawing('image2')]);
|
||||
});
|
||||
|
||||
it('keeps the current drawing focus when selection params are missing', async () => {
|
||||
await commandService.executeCommand(SetDrawingSelectedOperation.id, [createSearch('image1')]);
|
||||
const result = await commandService.executeCommand(SetDrawingSelectedOperation.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(drawingManagerService.getFocusDrawings()).toEqual([createDrawing('image1')]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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,
|
||||
} from '@univerjs/core';
|
||||
import { IDrawingManagerService } from '../../services/drawing-manager.service';
|
||||
|
||||
export const SetDrawingSelectedOperation: IOperation<IDrawingSearch[]> = {
|
||||
id: 'drawing.operation.set-drawing-selected',
|
||||
type: CommandType.OPERATION,
|
||||
handler: (accessor: IAccessor, params?: IDrawingSearch[]) => {
|
||||
const drawingManagerService = accessor.get(IDrawingManagerService);
|
||||
|
||||
if (params == null) {
|
||||
return false;
|
||||
}
|
||||
drawingManagerService.focusDrawing(params);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
32
packages/drawing/src/config/config.ts
Normal file
32
packages/drawing/src/config/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const DRAWING_PLUGIN_CONFIG_KEY = 'drawing.config';
|
||||
|
||||
export const configSymbol = Symbol(DRAWING_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export interface IUniverDrawingConfig {
|
||||
override?: DependencyOverride;
|
||||
/**
|
||||
* The maximum allowed size of uploaded images in bytes.
|
||||
* @default 5 * 1024 * 1024 (5MB)
|
||||
*/
|
||||
allowImageSize?: number;
|
||||
}
|
||||
|
||||
export const defaultPluginConfig: IUniverDrawingConfig = {};
|
||||
44
packages/drawing/src/index.ts
Normal file
44
packages/drawing/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DRAWING_IMAGE_ALLOW_IMAGE_LIST, DRAWING_IMAGE_COUNT_LIMIT, DRAWING_IMAGE_HEIGHT_LIMIT, DRAWING_IMAGE_WIDTH_LIMIT, getDrawingImageAllowSize } from './basics/config';
|
||||
export { SetDrawingSelectedOperation } from './commands/operations/set-drawing-selected.operation';
|
||||
export type { IUniverDrawingConfig } from './config/config';
|
||||
export type { IDocFloatDomData, IDocFloatDomDataBase, IImageData } from './models/image-model-interface';
|
||||
export { UniverDrawingPlugin } from './plugin';
|
||||
export { DrawingManagerService, UnitDrawingService } from './services/drawing-manager-impl.service';
|
||||
export type { IDrawingJson1Type, IDrawingJsonUndo1 } from './services/drawing-manager-impl.service';
|
||||
export type {
|
||||
IDrawingGroupUpdateParam,
|
||||
IDrawingMap,
|
||||
IDrawingMapItem,
|
||||
IDrawingMapItemData,
|
||||
IDrawingOrderMapParam,
|
||||
IDrawingOrderUpdateParam,
|
||||
IDrawingSubunitMap,
|
||||
IDrawingVisibleParam,
|
||||
IUnitDrawingService,
|
||||
} from './services/drawing-manager.service';
|
||||
export { IDrawingManagerService } from './services/drawing-manager.service';
|
||||
export { ImageIoService } from './services/image-io-impl.service';
|
||||
export { URLImageService } from './services/url-image.service';
|
||||
export { createDrawingCopyPlan, DRAWING_COPY_CONTEXT_KEY, getOrCreateDrawingCopyPlan } from './utils/drawing-copy-plan';
|
||||
export type { ICreateDrawingCopyPlanOptions, IDrawingCopyPlan } from './utils/drawing-copy-plan';
|
||||
export { DRAWING_GROUPABLE_TYPES, isGroupableDrawingType } from './utils/drawing-group';
|
||||
export { getDrawingShapeKeyByDrawingSearch } from './utils/get-image-shape-key';
|
||||
export { getImageSize } from './utils/get-image-size';
|
||||
export { resolveDrawingRotateEnabled } from './utils/rotate-enabled';
|
||||
export type { IDrawingRotateEnabledResolverOptions } from './utils/rotate-enabled';
|
||||
46
packages/drawing/src/models/image-model-interface.ts
Normal file
46
packages/drawing/src/models/image-model-interface.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDrawingParam, ImageSourceType, ISrcRect, Nullable, Serializable } from '@univerjs/core';
|
||||
|
||||
export interface IImageData extends IDrawingParam {
|
||||
imageSourceType: ImageSourceType;
|
||||
source: string;
|
||||
/**
|
||||
* 20.1.8.55 srcRect (Source Rectangle)
|
||||
*/
|
||||
srcRect?: Nullable<ISrcRect>;
|
||||
/**
|
||||
* 20.1.9.18 prstGeom (Preset geometry)
|
||||
*/
|
||||
prstGeom?: Nullable<string>;
|
||||
|
||||
/**
|
||||
* Adjust values for the preset geometry (e.g. corner radius for roundRect).
|
||||
* Keys are adjust handle names, values are numeric values.
|
||||
*/
|
||||
adjustValues?: Nullable<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface IDocFloatDomDataBase {
|
||||
componentKey: string;
|
||||
data?: Serializable;
|
||||
allowTransform?: boolean;
|
||||
}
|
||||
|
||||
export interface IDocFloatDomData extends IDrawingParam, IDocFloatDomDataBase {
|
||||
|
||||
}
|
||||
76
packages/drawing/src/plugin.ts
Normal file
76
packages/drawing/src/plugin.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 { IUniverDrawingConfig } from './config/config';
|
||||
import { ICommandService, IConfigService, IImageIoService, Inject, Injector, IURLImageService, merge, mergeOverrideWithDependencies, Plugin } from '@univerjs/core';
|
||||
import pkg from '../package.json';
|
||||
import { setDrawingImageAllowSize } from './basics/config';
|
||||
import { SetDrawingSelectedOperation } from './commands/operations/set-drawing-selected.operation';
|
||||
import { defaultPluginConfig, DRAWING_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { DrawingManagerService } from './services/drawing-manager-impl.service';
|
||||
import { IDrawingManagerService } from './services/drawing-manager.service';
|
||||
import { ImageIoService } from './services/image-io-impl.service';
|
||||
import { URLImageService } from './services/url-image.service';
|
||||
|
||||
export class UniverDrawingPlugin extends Plugin {
|
||||
static override pluginName = 'UNIVER_DRAWING_PLUGIN';
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverDrawingConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) protected _injector: Injector,
|
||||
@IConfigService private readonly _configService: IConfigService,
|
||||
@ICommandService private readonly _commandService: ICommandService
|
||||
) {
|
||||
super();
|
||||
|
||||
// Manage the plugin configuration.
|
||||
const { ...rest } = merge(
|
||||
{},
|
||||
defaultPluginConfig,
|
||||
this._config
|
||||
);
|
||||
this._configService.setConfig(DRAWING_PLUGIN_CONFIG_KEY, rest);
|
||||
|
||||
if (rest.allowImageSize !== undefined) {
|
||||
setDrawingImageAllowSize(rest.allowImageSize);
|
||||
}
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
this._initCommands();
|
||||
this._initDependencies();
|
||||
}
|
||||
|
||||
private _initDependencies(): void {
|
||||
const dependencies: Dependency[] = [
|
||||
[IImageIoService, { useClass: ImageIoService }],
|
||||
[IURLImageService, { useClass: URLImageService }],
|
||||
[IDrawingManagerService, { useClass: DrawingManagerService }],
|
||||
];
|
||||
|
||||
const dependency = mergeOverrideWithDependencies(dependencies, this._config?.override);
|
||||
dependency.forEach((d) => this._injector.add(d));
|
||||
}
|
||||
|
||||
private _initCommands() {
|
||||
[
|
||||
SetDrawingSelectedOperation,
|
||||
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* 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, IDrawingSearch } from '@univerjs/core';
|
||||
import { BooleanNumber, DrawingTypeEnum, Injector, JSON1 } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DrawingManagerService, UnitDrawingService } from '../drawing-manager-impl.service';
|
||||
|
||||
const unitId = 'unit';
|
||||
const subUnitId = 'subUnit';
|
||||
|
||||
type NestedDrawingTestParam = IDrawingParam & {
|
||||
element: {
|
||||
name: string;
|
||||
shapeData: {
|
||||
text: string;
|
||||
fontFamily?: string;
|
||||
dataModel?: {
|
||||
horizontalAlign?: number;
|
||||
doc: { body: { dataStream: string } };
|
||||
};
|
||||
};
|
||||
style?: {
|
||||
dash?: number[];
|
||||
stroke?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function createNestedDrawingService(): UnitDrawingService<NestedDrawingTestParam> {
|
||||
const injector = new Injector();
|
||||
injector.add([UnitDrawingService]);
|
||||
return injector.get<UnitDrawingService<NestedDrawingTestParam>>(UnitDrawingService);
|
||||
}
|
||||
|
||||
function createDrawing(drawingId: string, overrides: Partial<IDrawingParam> = {}): IDrawingParam {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearch(drawingId: string): IDrawingSearch {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('UnitDrawingService', () => {
|
||||
let service: UnitDrawingService<IDrawingParam>;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([UnitDrawingService]);
|
||||
service = injector.get(UnitDrawingService);
|
||||
});
|
||||
|
||||
it('should register, initialize and remove drawing data for a unit', () => {
|
||||
const added: IDrawingSearch[][] = [];
|
||||
const removed: IDrawingSearch[][] = [];
|
||||
|
||||
service.add$.subscribe((params) => added.push(params));
|
||||
service.remove$.subscribe((params) => removed.push(params));
|
||||
|
||||
service.registerDrawingData(unitId, {
|
||||
[subUnitId]: {
|
||||
data: {
|
||||
a: createDrawing('a'),
|
||||
b: createDrawing('b'),
|
||||
},
|
||||
order: ['a', 'b'],
|
||||
},
|
||||
});
|
||||
|
||||
service.initializeNotification(unitId);
|
||||
|
||||
expect(added).toHaveLength(1);
|
||||
expect(added[0].map((item) => item.drawingId)).toEqual(['a', 'b']);
|
||||
expect(service.getDrawingData(unitId, subUnitId)).toMatchObject({
|
||||
a: createDrawing('a'),
|
||||
b: createDrawing('b'),
|
||||
});
|
||||
|
||||
service.removeDrawingDataForUnit(unitId);
|
||||
|
||||
expect(removed).toEqual([[createSearch('a'), createSearch('b')]]);
|
||||
expect(service.getDrawingDataForUnit(unitId)).toEqual({});
|
||||
});
|
||||
|
||||
it('should build and apply add, update and remove operations', () => {
|
||||
const drawingA = createDrawing('a', { allowTransform: true });
|
||||
const drawingB = createDrawing('b');
|
||||
|
||||
const addOp = service.getBatchAddOp([drawingA, drawingB]);
|
||||
service.applyJson1(unitId, subUnitId, addOp.redo);
|
||||
|
||||
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'a']);
|
||||
expect(service.getDrawingByParam(createSearch('a'))).toEqual(drawingA);
|
||||
expect(service.getDrawingOKey(`${unitId}#-#${subUnitId}#-#a`)).toEqual(drawingA);
|
||||
|
||||
const updatedDrawingA = createDrawing('a', {
|
||||
allowTransform: false,
|
||||
groupId: 'group-1',
|
||||
});
|
||||
const updateOp = service.getBatchUpdateOp([updatedDrawingA]);
|
||||
service.applyJson1(unitId, subUnitId, updateOp.redo);
|
||||
|
||||
expect(service.getDrawingByParam(createSearch('a'))).toMatchObject(updatedDrawingA);
|
||||
expect(service.getOldDrawingByParam(createSearch('a'))).toMatchObject(drawingA);
|
||||
|
||||
const removeOp = service.getBatchRemoveOp([createSearch('b')]);
|
||||
service.applyJson1(unitId, subUnitId, removeOp.redo);
|
||||
|
||||
expect(service.getDrawingByParam(createSearch('b'))).toBeUndefined();
|
||||
expect(service.getOldDrawingByParam(createSearch('b'))).toMatchObject(drawingB);
|
||||
});
|
||||
|
||||
it('should build and apply update operations for newly added metadata fields', () => {
|
||||
const drawingA = createDrawing('a');
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getBatchAddOp([drawingA]).redo);
|
||||
|
||||
const updateOp = service.getBatchUpdateOp([{ ...drawingA, hidden: true }]);
|
||||
service.applyJson1(unitId, subUnitId, updateOp.redo);
|
||||
|
||||
expect(service.getDrawingByParam(createSearch('a'))).toMatchObject({
|
||||
drawingId: 'a',
|
||||
hidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves disjoint nested fields across concurrent drawing updates', () => {
|
||||
const base: NestedDrawingTestParam = {
|
||||
...createDrawing('shape-1'),
|
||||
element: {
|
||||
name: 'before',
|
||||
shapeData: { text: 'before' },
|
||||
},
|
||||
transform: { height: 80, left: 100, top: 100, width: 200 },
|
||||
};
|
||||
const textService = createNestedDrawingService();
|
||||
const nameService = createNestedDrawingService();
|
||||
[textService, nameService].forEach((drawingService) => {
|
||||
drawingService.applyJson1(unitId, subUnitId, drawingService.getBatchAddOp([base]).redo);
|
||||
});
|
||||
|
||||
const textOp = textService.getBatchUpdateOp([{
|
||||
...base,
|
||||
element: {
|
||||
...base.element,
|
||||
shapeData: { text: 'offline once' },
|
||||
},
|
||||
transform: { ...base.transform, left: 360 },
|
||||
}]).redo;
|
||||
const nameOp = nameService.getBatchUpdateOp([{
|
||||
...base,
|
||||
element: { ...base.element, name: 'server while offline' },
|
||||
}]).redo;
|
||||
if (!textOp || !nameOp) {
|
||||
throw new Error('Expected both drawing updates to produce JSON1 operations');
|
||||
}
|
||||
|
||||
const textPrime = JSON1.type.transform(textOp, nameOp, 'left');
|
||||
const namePrime = JSON1.type.transform(nameOp, textOp, 'right');
|
||||
if (!textPrime || !namePrime) {
|
||||
throw new Error('Expected concurrent drawing operations to transform');
|
||||
}
|
||||
|
||||
textService.applyJson1(unitId, subUnitId, nameOp);
|
||||
textService.applyJson1(unitId, subUnitId, textPrime);
|
||||
nameService.applyJson1(unitId, subUnitId, textOp);
|
||||
nameService.applyJson1(unitId, subUnitId, namePrime);
|
||||
|
||||
[textService, nameService].forEach((drawingService) => {
|
||||
expect(drawingService.getDrawingByParam(createSearch('shape-1'))).toMatchObject({
|
||||
element: {
|
||||
name: 'server while offline',
|
||||
shapeData: { text: 'offline once' },
|
||||
},
|
||||
transform: { left: 360 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts nested drawing data when optional properties are undefined', () => {
|
||||
const drawingService = createNestedDrawingService();
|
||||
const base: NestedDrawingTestParam = {
|
||||
...createDrawing('shape-with-text'),
|
||||
element: {
|
||||
name: 'shape',
|
||||
shapeData: { text: 'before' },
|
||||
},
|
||||
};
|
||||
drawingService.applyJson1(unitId, subUnitId, drawingService.getBatchAddOp([base]).redo);
|
||||
|
||||
const updated: NestedDrawingTestParam = {
|
||||
...base,
|
||||
element: {
|
||||
...base.element,
|
||||
shapeData: {
|
||||
text: 'after',
|
||||
dataModel: {
|
||||
horizontalAlign: undefined,
|
||||
doc: { body: { dataStream: 'after\r\n' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const updateOp = drawingService.getBatchUpdateOp([updated]);
|
||||
drawingService.applyJson1(unitId, subUnitId, updateOp.redo);
|
||||
|
||||
expect(drawingService.getDrawingByParam(createSearch('shape-with-text'))).toMatchObject({
|
||||
element: {
|
||||
shapeData: {
|
||||
text: 'after',
|
||||
dataModel: {
|
||||
doc: { body: { dataStream: 'after\r\n' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats undefined drawing properties as absent when updating nested data', () => {
|
||||
const drawingService = createNestedDrawingService();
|
||||
const base: NestedDrawingTestParam = {
|
||||
...createDrawing('connector'),
|
||||
element: {
|
||||
name: 'connector',
|
||||
shapeData: { text: '' },
|
||||
style: {
|
||||
dash: undefined,
|
||||
stroke: '#000000',
|
||||
},
|
||||
},
|
||||
};
|
||||
drawingService.applyJson1(unitId, subUnitId, drawingService.getBatchAddOp([base]).redo);
|
||||
|
||||
const updated: NestedDrawingTestParam = {
|
||||
...base,
|
||||
element: {
|
||||
...base.element,
|
||||
style: {
|
||||
dash: [1, 6],
|
||||
stroke: '#00aa55',
|
||||
},
|
||||
},
|
||||
};
|
||||
const updateOp = drawingService.getBatchUpdateOp([updated]);
|
||||
drawingService.applyJson1(unitId, subUnitId, updateOp.redo);
|
||||
|
||||
expect(drawingService.getDrawingByParam(createSearch('connector'))).toMatchObject({
|
||||
element: {
|
||||
style: {
|
||||
dash: [1, 6],
|
||||
stroke: '#00aa55',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('updates drawing objects with non-enumerable runtime defaults atomically', () => {
|
||||
const drawingService = createNestedDrawingService();
|
||||
const shapeData = { text: 'before' };
|
||||
Object.defineProperty(shapeData, 'fontFamily', {
|
||||
configurable: true,
|
||||
value: 'Calibri',
|
||||
writable: true,
|
||||
});
|
||||
const base: NestedDrawingTestParam = {
|
||||
...createDrawing('shape-with-defaults'),
|
||||
element: {
|
||||
name: 'shape',
|
||||
shapeData,
|
||||
},
|
||||
};
|
||||
drawingService.applyJson1(unitId, subUnitId, drawingService.getBatchAddOp([base]).redo);
|
||||
|
||||
const updatedShapeData = { fontFamily: 'Inter', text: 'after' };
|
||||
const updateOp = drawingService.getBatchUpdateOp([{
|
||||
...base,
|
||||
element: {
|
||||
...base.element,
|
||||
shapeData: updatedShapeData,
|
||||
},
|
||||
}]);
|
||||
drawingService.applyJson1(unitId, subUnitId, updateOp.redo);
|
||||
|
||||
const updatedDrawing = drawingService.getDrawingByParam(createSearch('shape-with-defaults'));
|
||||
expect(updatedDrawing?.element.shapeData).toMatchObject({
|
||||
fontFamily: 'Inter',
|
||||
text: 'after',
|
||||
});
|
||||
|
||||
drawingService.applyJson1(unitId, subUnitId, updateOp.undo);
|
||||
const restoredShapeData = drawingService.getDrawingByParam(createSearch('shape-with-defaults'))?.element.shapeData;
|
||||
expect(restoredShapeData).toMatchObject({ text: 'before' });
|
||||
expect(restoredShapeData).not.toHaveProperty('fontFamily');
|
||||
});
|
||||
|
||||
it('preserves previous drawing data when docs replace a subunit snapshot', () => {
|
||||
service.registerDrawingData(unitId, {
|
||||
[subUnitId]: {
|
||||
data: { a: createDrawing('a', { selectable: false }) },
|
||||
order: ['a'],
|
||||
},
|
||||
});
|
||||
|
||||
service.setDrawingData(unitId, subUnitId, {
|
||||
a: createDrawing('a'),
|
||||
});
|
||||
|
||||
expect(service.getDrawingByParam(createSearch('a'))?.selectable).toBeUndefined();
|
||||
expect(service.getOldDrawingByParam(createSearch('a'))?.selectable).toBe(false);
|
||||
});
|
||||
|
||||
it('expands grouped remove operations to include all nested drawing records', () => {
|
||||
service.applyJson1(unitId, subUnitId, service.getBatchAddOp([
|
||||
createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP }),
|
||||
createDrawing('image-child', { groupId: 'group' }),
|
||||
createDrawing('chart-child', { drawingType: DrawingTypeEnum.DRAWING_CHART, groupId: 'group' }),
|
||||
]).redo);
|
||||
|
||||
const removeOp = service.getBatchRemoveOp([createSearch('group')]);
|
||||
|
||||
expect(removeOp.objects).toEqual([
|
||||
createSearch('chart-child'),
|
||||
createSearch('image-child'),
|
||||
createSearch('group'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps batch remove operations stable for empty drawing inputs', () => {
|
||||
expect(service.getBatchRemoveOp([])).toMatchObject({
|
||||
unitId: '',
|
||||
subUnitId: '',
|
||||
objects: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates expanded remove params for grouped, normal, and missing drawings', () => {
|
||||
service.applyJson1(unitId, subUnitId, service.getBatchAddOp([
|
||||
createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP }),
|
||||
createDrawing('image'),
|
||||
]).redo);
|
||||
vi.spyOn(service, 'getDrawingsByGroupNested').mockReturnValue(null);
|
||||
|
||||
const expanded = (service as unknown as {
|
||||
_getExpandedBatchRemoveParams: (removeParams: IDrawingSearch[]) => IDrawingSearch[];
|
||||
})._getExpandedBatchRemoveParams([
|
||||
createSearch('group'),
|
||||
createSearch('group'),
|
||||
createSearch('image'),
|
||||
createSearch('image'),
|
||||
createSearch('missing'),
|
||||
createSearch('missing'),
|
||||
]);
|
||||
|
||||
expect(expanded).toEqual([
|
||||
createSearch('group'),
|
||||
createSearch('image'),
|
||||
createSearch('missing'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should manage focus, refresh and visibility notifications', () => {
|
||||
const focused: IDrawingParam[][] = [];
|
||||
const refreshed: IDrawingParam[][] = [];
|
||||
const visibleUpdates: Array<Array<{ drawingId: string; visible: boolean }>> = [];
|
||||
|
||||
const drawing = createDrawing('a');
|
||||
const addOp = service.getBatchAddOp([drawing]);
|
||||
service.applyJson1(unitId, subUnitId, addOp.redo);
|
||||
|
||||
service.focus$.subscribe((params) => focused.push(params));
|
||||
service.refreshTransform$.subscribe((params) => refreshed.push(params));
|
||||
service.visible$.subscribe((params) => visibleUpdates.push(params.map(({ drawingId, visible }) => ({ drawingId, visible }))));
|
||||
|
||||
service.focusDrawing([createSearch('a'), createSearch('missing')]);
|
||||
expect(focused[0].map((item) => item.drawingId)).toEqual(['a']);
|
||||
expect(service.getFocusDrawings().map((item) => item.drawingId)).toEqual(['a']);
|
||||
|
||||
const transformed = createDrawing('a', {
|
||||
transform: { left: 1, top: 2 } as NonNullable<IDrawingParam['transform']>,
|
||||
transforms: [{ left: 3, top: 4 } as NonNullable<IDrawingParam['transform']>] as NonNullable<IDrawingParam['transforms']>,
|
||||
isMultiTransform: BooleanNumber.TRUE,
|
||||
behindText: true,
|
||||
hidden: true,
|
||||
} as Partial<IDrawingParam>);
|
||||
service.refreshTransform([transformed]);
|
||||
|
||||
expect(refreshed).toEqual([[transformed]]);
|
||||
expect(service.getDrawingByParam(createSearch('a'))).toMatchObject({
|
||||
transform: { left: 1, top: 2 },
|
||||
transforms: [{ left: 3, top: 4 }],
|
||||
isMultiTransform: BooleanNumber.TRUE,
|
||||
behindText: true,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
service.visibleNotification([{ ...createSearch('a'), visible: false }]);
|
||||
expect(visibleUpdates).toEqual([[{ drawingId: 'a', visible: false }]]);
|
||||
|
||||
service.focusDrawing(null);
|
||||
expect(focused.at(-1)).toEqual([]);
|
||||
expect(service.getFocusDrawings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not add omitted multi-transform fields during a transform refresh', () => {
|
||||
const drawing = createDrawing('a', { transform: { left: 0, top: 0 } });
|
||||
service.applyJson1(unitId, subUnitId, service.getBatchAddOp([drawing]).redo);
|
||||
|
||||
service.refreshTransform([{ ...drawing, transform: { left: 1, top: 2 } }]);
|
||||
|
||||
expect(service.getDrawingByParam(createSearch('a'))).toEqual({
|
||||
...drawing,
|
||||
transform: { left: 1, top: 2 },
|
||||
});
|
||||
expect(Object.hasOwn(service.getDrawingByParam(createSearch('a'))!, 'transforms')).toBe(false);
|
||||
expect(Object.hasOwn(service.getDrawingByParam(createSearch('a'))!, 'isMultiTransform')).toBe(false);
|
||||
});
|
||||
|
||||
it('should update drawing order in all directions', () => {
|
||||
service.registerDrawingData(unitId, {
|
||||
[subUnitId]: {
|
||||
data: {
|
||||
a: createDrawing('a'),
|
||||
b: createDrawing('b'),
|
||||
c: createDrawing('c'),
|
||||
},
|
||||
order: ['a', 'b', 'c'],
|
||||
},
|
||||
});
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getForwardDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
|
||||
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'a', 'c']);
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getBackwardDrawingOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
|
||||
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['a', 'b', 'c']);
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getFrontDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
|
||||
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'c', 'a']);
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getBackDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
|
||||
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should group, ungroup and expose feature notifications and flags', () => {
|
||||
const addOp = service.getBatchAddOp([
|
||||
createDrawing('child-1'),
|
||||
createDrawing('child-2'),
|
||||
]);
|
||||
service.applyJson1(unitId, subUnitId, addOp.redo);
|
||||
|
||||
const groupNotifications: IDrawingSearch[][] = [];
|
||||
const pluginUpdates: IDrawingParam[][] = [];
|
||||
const pluginAdds: IDrawingParam[][] = [];
|
||||
const pluginRemoves: IDrawingSearch[][] = [];
|
||||
const pluginOrderUpdates: string[][] = [];
|
||||
const pluginGroupUpdates: string[][] = [];
|
||||
const pluginUngroupUpdates: string[][] = [];
|
||||
|
||||
service.update$.subscribe((params) => groupNotifications.push(params));
|
||||
service.featurePluginUpdate$.subscribe((params) => pluginUpdates.push(params));
|
||||
service.featurePluginAdd$.subscribe((params) => pluginAdds.push(params));
|
||||
service.featurePluginRemove$.subscribe((params) => pluginRemoves.push(params));
|
||||
service.featurePluginOrderUpdate$.subscribe((params) => pluginOrderUpdates.push(params.drawingIds));
|
||||
service.featurePluginGroupUpdate$.subscribe((params) => pluginGroupUpdates.push(params.map((item) => item.parent.drawingId)));
|
||||
service.featurePluginUngroupUpdate$.subscribe((params) => pluginUngroupUpdates.push(params.map((item) => item.parent.drawingId)));
|
||||
|
||||
const groupParent = createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP });
|
||||
const groupedChildren = [
|
||||
createDrawing('child-1', { groupId: 'group' }),
|
||||
createDrawing('child-2', { groupId: 'group' }),
|
||||
];
|
||||
const groupParams = [{
|
||||
parent: groupParent,
|
||||
children: groupedChildren,
|
||||
}];
|
||||
|
||||
service.applyJson1(unitId, subUnitId, service.getGroupDrawingOp(groupParams).redo);
|
||||
expect(service.getDrawingByParam(createSearch('group'))).toMatchObject(groupParent);
|
||||
expect(service.getDrawingsByGroup(createSearch('group')).map((item) => item.drawingId).sort()).toEqual(['child-1', 'child-2']);
|
||||
|
||||
const ungroupParams = [{
|
||||
parent: groupParent,
|
||||
children: [createDrawing('child-1'), createDrawing('child-2')],
|
||||
}];
|
||||
service.applyJson1(unitId, subUnitId, service.getUngroupDrawingOp(ungroupParams).redo);
|
||||
expect(service.getDrawingByParam(createSearch('group'))).toBeUndefined();
|
||||
|
||||
service.updateNotification([createSearch('child-1')]);
|
||||
service.featurePluginUpdateNotification([createDrawing('child-1')]);
|
||||
service.featurePluginAddNotification([createDrawing('child-2')]);
|
||||
service.featurePluginRemoveNotification([createSearch('child-2')]);
|
||||
service.featurePluginOrderUpdateNotification({ unitId, subUnitId, drawingIds: ['child-1'], arrangeType: 0 });
|
||||
service.featurePluginGroupUpdateNotification(groupParams);
|
||||
service.featurePluginUngroupUpdateNotification(ungroupParams);
|
||||
|
||||
expect(groupNotifications).toEqual([[createSearch('child-1')]]);
|
||||
expect(pluginUpdates[0].map((item) => item.drawingId)).toEqual(['child-1']);
|
||||
expect(pluginAdds[0].map((item) => item.drawingId)).toEqual(['child-2']);
|
||||
expect(pluginRemoves).toEqual([[createSearch('child-2')]]);
|
||||
expect(pluginOrderUpdates).toEqual([['child-1']]);
|
||||
expect(pluginGroupUpdates).toEqual([['group']]);
|
||||
expect(pluginUngroupUpdates).toEqual([['group']]);
|
||||
|
||||
expect(service.getDrawingVisible()).toBe(true);
|
||||
expect(service.getDrawingEditable()).toBe(true);
|
||||
|
||||
service.setDrawingVisible(false);
|
||||
service.setDrawingEditable(false);
|
||||
|
||||
expect(service.getDrawingVisible()).toBe(false);
|
||||
expect(service.getDrawingEditable()).toBe(false);
|
||||
|
||||
service.dispose();
|
||||
expect(service.drawingManagerData).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DrawingManagerService', () => {
|
||||
it('uses the shared drawing manager behavior to register and remove drawings by unit', () => {
|
||||
const injector = new Injector();
|
||||
injector.add([DrawingManagerService]);
|
||||
const service = injector.get(DrawingManagerService);
|
||||
const removed: unknown[] = [];
|
||||
service.remove$.subscribe((value) => removed.push(value));
|
||||
|
||||
service.registerDrawingData('unit-1', {
|
||||
'sheet-1': {
|
||||
data: {
|
||||
image1: { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'image1', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
|
||||
},
|
||||
order: ['image1'],
|
||||
},
|
||||
});
|
||||
expect(service.getDrawingData('unit-1', 'sheet-1')).toHaveProperty('image1');
|
||||
|
||||
service.removeDrawingDataForUnit('unit-1');
|
||||
expect(service.getDrawingDataForUnit('unit-1')).toEqual({});
|
||||
expect(removed).toEqual([[{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'image1' }]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 { ImageSourceType, ImageUploadStatusType, Injector } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDrawingImageAllowSize } from '../../basics/config';
|
||||
import { ImageIoService } from '../image-io-impl.service';
|
||||
|
||||
type MockLoadEvent = ProgressEvent<FileReader> & {
|
||||
target: {
|
||||
result: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
class MockImage {
|
||||
onload: null | (() => void) = null;
|
||||
private _src = '';
|
||||
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this._src = value;
|
||||
queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
}
|
||||
|
||||
class SuccessFileReader {
|
||||
onload: null | ((event: MockLoadEvent) => void) = null;
|
||||
|
||||
readAsDataURL(_file: File) {
|
||||
queueMicrotask(() => {
|
||||
this.onload?.({
|
||||
target: {
|
||||
result: 'data:image/png;base64,Zm9v',
|
||||
},
|
||||
} as MockLoadEvent);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyFileReader {
|
||||
onload: null | ((event: MockLoadEvent) => void) = null;
|
||||
|
||||
readAsDataURL(_file: File) {
|
||||
queueMicrotask(() => {
|
||||
this.onload?.({
|
||||
target: {
|
||||
result: null,
|
||||
},
|
||||
} as MockLoadEvent);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('ImageIoService', () => {
|
||||
let service: ImageIoService;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([ImageIoService]);
|
||||
service = injector.get(ImageIoService);
|
||||
vi.stubGlobal('Image', MockImage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should emit wait count changes and manage image source cache', () => {
|
||||
const counts: number[] = [];
|
||||
const cachedImage = new MockImage() as unknown as HTMLImageElement;
|
||||
|
||||
service.change$.subscribe((count) => counts.push(count));
|
||||
service.setWaitCount(2);
|
||||
service.addImageSourceCache('https://example.com/image.png', ImageSourceType.URL, cachedImage);
|
||||
|
||||
expect(service.getImageSourceCache('https://example.com/image.png', ImageSourceType.URL)).toBe(cachedImage);
|
||||
expect(service.getImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64)).toMatchObject({
|
||||
src: 'data:image/png;base64,Zm9v',
|
||||
});
|
||||
expect(counts).toEqual([2]);
|
||||
});
|
||||
|
||||
it('should cache base64 images and emit after they load', async () => {
|
||||
const counts: number[] = [];
|
||||
service.change$.subscribe((count) => counts.push(count));
|
||||
|
||||
const image = service.getImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64);
|
||||
const cached = service.getImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64);
|
||||
|
||||
expect(cached).toBe(image);
|
||||
await Promise.resolve();
|
||||
expect(counts).toEqual([0]);
|
||||
});
|
||||
|
||||
it('should ignore invalid cache insertions and resolve image ids directly', async () => {
|
||||
service.addImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64, new MockImage() as unknown as HTMLImageElement);
|
||||
service.addImageSourceCache('https://example.com/image.png', ImageSourceType.URL, null);
|
||||
|
||||
expect(service.getImageSourceCache('https://example.com/image.png', ImageSourceType.URL)).toBeUndefined();
|
||||
await expect(service.getImage('image-id')).resolves.toBe('image-id');
|
||||
});
|
||||
|
||||
it('should reject unsupported image types and oversized files', async () => {
|
||||
const counts: number[] = [];
|
||||
service.change$.subscribe((count) => counts.push(count));
|
||||
|
||||
service.setWaitCount(1);
|
||||
await expect(service.saveImage(new File(['abc'], 'a.txt', { type: 'text/plain' }))).rejects.toThrow(ImageUploadStatusType.ERROR_IMAGE_TYPE);
|
||||
|
||||
vi.stubGlobal('FileReader', SuccessFileReader);
|
||||
service.setWaitCount(1);
|
||||
await expect(service.saveImage(new File(['abc'], 'a.png', { type: 'image/png' }))).resolves.toMatchObject({
|
||||
source: expect.any(String),
|
||||
});
|
||||
|
||||
service.setWaitCount(1);
|
||||
await expect(
|
||||
service.saveImage(new File([new Uint8Array(getDrawingImageAllowSize() + 1)], 'big.png', { type: 'image/png' }))
|
||||
).rejects.toThrow(ImageUploadStatusType.ERROR_EXCEED_SIZE);
|
||||
|
||||
expect(counts).toContain(0);
|
||||
});
|
||||
|
||||
it('should reject when file reader returns empty result', async () => {
|
||||
vi.stubGlobal('FileReader', EmptyFileReader);
|
||||
service.setWaitCount(1);
|
||||
|
||||
await expect(service.saveImage(new File(['abc'], 'empty.png', { type: 'image/png' }))).rejects.toThrow(ImageUploadStatusType.ERROR_IMAGE);
|
||||
});
|
||||
|
||||
it('should save valid images as base64 payload', async () => {
|
||||
vi.stubGlobal('FileReader', SuccessFileReader);
|
||||
service.setWaitCount(1);
|
||||
|
||||
await expect(service.saveImage(new File(['abc'], 'ok.png', { type: 'image/png' }))).resolves.toMatchObject({
|
||||
imageId: expect.any(String),
|
||||
imageSourceType: ImageSourceType.BASE64,
|
||||
source: 'data:image/png;base64,Zm9v',
|
||||
base64Cache: 'data:image/png;base64,Zm9v',
|
||||
status: ImageUploadStatusType.SUCCUSS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector, IURLImageService } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { URLImageService } from '../url-image.service';
|
||||
|
||||
describe('URLImageService', () => {
|
||||
let service: IURLImageService;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([IURLImageService, { useClass: URLImageService }]);
|
||||
service = injector.get(IURLImageService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should use downloader when registered and reset after disposal', async () => {
|
||||
const disposable = service.registerURLImageDownloader(async (url) => `base64:${url}`);
|
||||
|
||||
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('base64:https://example.com/image.png');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('https://example.com/image.png');
|
||||
});
|
||||
|
||||
it('should fall back to original url when custom downloader fails', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.registerURLImageDownloader(async () => {
|
||||
throw new Error('downloader failed');
|
||||
});
|
||||
|
||||
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('https://example.com/image.png');
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should download blob through converted base64 when custom downloader succeeds', async () => {
|
||||
const blob = new Blob(['image']);
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchMock = async (url: string) => {
|
||||
requestedUrls.push(url);
|
||||
return {
|
||||
blob: async () => blob,
|
||||
};
|
||||
};
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
service.registerURLImageDownloader(async () => 'data:image/png;base64,Zm9v');
|
||||
|
||||
await expect(service.downloadImage('https://example.com/image.png')).resolves.toBe(blob);
|
||||
expect(requestedUrls).toEqual(['data:image/png;base64,Zm9v']);
|
||||
});
|
||||
|
||||
it('should fall back to the original url when custom blob download fails', async () => {
|
||||
const blob = new Blob(['image']);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchMock = async (url: string) => {
|
||||
requestedUrls.push(url);
|
||||
return {
|
||||
blob: async () => blob,
|
||||
};
|
||||
};
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
service.registerURLImageDownloader(async () => {
|
||||
throw new Error('downloader failed');
|
||||
});
|
||||
|
||||
await expect(service.downloadImage('https://example.com/image.png')).resolves.toBe(blob);
|
||||
expect(requestedUrls).toEqual(['https://example.com/image.png']);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
1124
packages/drawing/src/services/drawing-manager-impl.service.ts
Normal file
1124
packages/drawing/src/services/drawing-manager-impl.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
165
packages/drawing/src/services/drawing-manager.service.ts
Normal file
165
packages/drawing/src/services/drawing-manager.service.ts
Normal 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 { ArrangeTypeEnum, IDisposable, IDrawingGroupNestedParam, IDrawingParam, IDrawingSearch, Nullable } from '@univerjs/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { createIdentifier } from '@univerjs/core';
|
||||
|
||||
export interface IDrawingMap<T extends IDrawingParam> {
|
||||
[unitId: string]: IDrawingSubunitMap<T>;
|
||||
}
|
||||
|
||||
export interface IDrawingSubunitMap<T extends IDrawingParam> {
|
||||
[subUnitId: string]: IDrawingMapItem<T>;
|
||||
}
|
||||
|
||||
export interface IDrawingMapItemData<T> {
|
||||
[drawingId: string]: T;
|
||||
}
|
||||
export interface IDrawingMapItem<T extends IDrawingParam> {
|
||||
data: IDrawingMapItemData<T>;
|
||||
order: string[];
|
||||
}
|
||||
|
||||
export interface IDrawingOrderMapParam {
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
drawingIds: string[];
|
||||
}
|
||||
|
||||
export interface IDrawingOrderUpdateParam extends IDrawingOrderMapParam {
|
||||
arrangeType: ArrangeTypeEnum;
|
||||
}
|
||||
|
||||
export interface IDrawingGroupUpdateParam {
|
||||
parent: IDrawingParam;
|
||||
children: IDrawingParam[];
|
||||
}
|
||||
|
||||
export interface IDrawingVisibleParam extends IDrawingSearch {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for operations related to drawing additions, deletions, and modifications,
|
||||
* including observers, events, broadcasting, and generating operations.
|
||||
*/
|
||||
export interface IUnitNormalDrawingService<T extends IDrawingParam> {
|
||||
readonly remove$: Observable<IDrawingSearch[]>;
|
||||
readonly add$: Observable<IDrawingSearch[]>;
|
||||
readonly update$: Observable<IDrawingSearch[]>;
|
||||
|
||||
readonly refreshTransform$: Observable<T[]>;
|
||||
readonly visible$: Observable<IDrawingVisibleParam[]>;
|
||||
|
||||
readonly featurePluginUpdate$: Observable<T[]>;
|
||||
|
||||
refreshTransform(updateParams: T[]): void;
|
||||
|
||||
removeNotification(removeParams: IDrawingSearch[]): void;
|
||||
addNotification(insertParams: IDrawingSearch[]): void;
|
||||
updateNotification(updateParams: IDrawingSearch[]): void;
|
||||
|
||||
refreshTransformNotification(refreshParams: T[]): void;
|
||||
visibleNotification(visibleParams: IDrawingVisibleParam[]): void;
|
||||
|
||||
getBatchAddOp(insertParams: T[]): unknown;
|
||||
getBatchRemoveOp(removeParams: IDrawingSearch[]): unknown;
|
||||
getBatchUpdateOp(updateParams: T[]): unknown;
|
||||
|
||||
featurePluginUpdateNotification(updateParams: T[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for operations related to drawing focus, including observers,broadcasting
|
||||
*/
|
||||
export interface IUnitFocusDrawingService {
|
||||
readonly focus$: Observable<IDrawingParam[]>;
|
||||
|
||||
focusDrawing(params: Nullable<IDrawingSearch[]>): void;
|
||||
getFocusDrawings(): IDrawingParam[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for operations related to drawing grouping, including observers,broadcasting, and generating operations.
|
||||
*/
|
||||
export interface IUnitGroupDrawingService {
|
||||
readonly group$: Observable<IDrawingGroupUpdateParam[]>;
|
||||
readonly ungroup$: Observable<IDrawingGroupUpdateParam[]>;
|
||||
|
||||
readonly featurePluginGroupUpdate$: Observable<IDrawingGroupUpdateParam[]>;
|
||||
readonly featurePluginUngroupUpdate$: Observable<IDrawingGroupUpdateParam[]>;
|
||||
|
||||
getGroupDrawingOp(groupParams: IDrawingGroupUpdateParam[]): unknown;
|
||||
getUngroupDrawingOp(groupParams: IDrawingGroupUpdateParam[]): unknown;
|
||||
groupUpdateNotification(groupParams: IDrawingGroupUpdateParam[]): void;
|
||||
ungroupUpdateNotification(groupParams: IDrawingGroupUpdateParam[]): void;
|
||||
getDrawingsByGroup(groupParam: IDrawingSearch): IDrawingParam[];
|
||||
getDrawingsByGroupNested(groupParam: IDrawingSearch): IDrawingGroupNestedParam | null;
|
||||
|
||||
featurePluginGroupUpdateNotification(groupParams: IDrawingGroupUpdateParam[]): void;
|
||||
featurePluginUngroupUpdateNotification(groupParams: IDrawingGroupUpdateParam[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for operations related to drawing order, including observers,broadcasting, and generating operations.
|
||||
*/
|
||||
export interface IUnitOrderDrawingService {
|
||||
|
||||
readonly order$: Observable<IDrawingOrderMapParam>;
|
||||
|
||||
readonly featurePluginOrderUpdate$: Observable<IDrawingOrderUpdateParam>;
|
||||
|
||||
orderNotification(orderParams: IDrawingOrderMapParam): void;
|
||||
|
||||
getForwardDrawingsOp(orderParams: IDrawingOrderMapParam): unknown;
|
||||
getBackwardDrawingOp(orderParams: IDrawingOrderMapParam): unknown;
|
||||
getFrontDrawingsOp(orderParams: IDrawingOrderMapParam): unknown;
|
||||
getBackDrawingsOp(orderParams: IDrawingOrderMapParam): unknown;
|
||||
getDrawingOrderOp(orderParams: IDrawingOrderMapParam, zOrder: number): unknown;
|
||||
// replaceDrawingOrder(unitId: string, subUnitId: string, drawingIds: string[]): void;
|
||||
getDrawingOrder(unitId: string, subUnitId: string): string[];
|
||||
setDrawingOrder(unitId: string, subUnitId: string, order: string[]): void;
|
||||
orderUpdateNotification(orderParams: IDrawingOrderMapParam): void;
|
||||
featurePluginOrderUpdateNotification(drawingOrderUpdateParam: IDrawingOrderUpdateParam): void;
|
||||
}
|
||||
|
||||
export interface IUnitDrawingService<T extends IDrawingParam> extends IUnitNormalDrawingService<T>, IUnitFocusDrawingService, IUnitGroupDrawingService, IUnitOrderDrawingService, IDisposable {
|
||||
drawingManagerData: IDrawingMap<T>;
|
||||
|
||||
dispose(): void;
|
||||
|
||||
getDrawingDataForUnit(unitId: string): IDrawingSubunitMap<T>;
|
||||
removeDrawingDataForUnit(unitId: string): void;
|
||||
registerDrawingData(unitId: string, data: IDrawingSubunitMap<T>): void;
|
||||
|
||||
initializeNotification(unitId: string): void;
|
||||
|
||||
getDrawingData(unitId: string, subUnitId: string): IDrawingMapItemData<T>;
|
||||
setDrawingData(unitId: string, subUnitId: string, data: IDrawingMapItemData<T>): void;
|
||||
getDrawingByParam(param: Nullable<IDrawingSearch>): Nullable<T>;
|
||||
getOldDrawingByParam(param: Nullable<IDrawingSearch>): Nullable<T>;
|
||||
getDrawingOKey(oKey: string): Nullable<T>;
|
||||
applyJson1(unitId: string, subUnitId: string, jsonOp: unknown): void;
|
||||
getDrawingEditable(): boolean;
|
||||
getDrawingVisible(): boolean;
|
||||
setDrawingVisible(visible: boolean): void;
|
||||
setDrawingEditable(editable: boolean): void;
|
||||
}
|
||||
|
||||
export interface IDrawingManagerService extends IUnitDrawingService<IDrawingParam> {}
|
||||
|
||||
export const IDrawingManagerService = createIdentifier<IUnitDrawingService<IDrawingParam>>('univer.drawing-manager.service');
|
||||
103
packages/drawing/src/services/image-io-impl.service.ts
Normal file
103
packages/drawing/src/services/image-io-impl.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IImageIoService, IImageIoServiceParam, Nullable } from '@univerjs/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { generateRandomId, ImageSourceType, ImageUploadStatusType } from '@univerjs/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { DRAWING_IMAGE_ALLOW_IMAGE_LIST, getDrawingImageAllowSize } from '../basics/config';
|
||||
|
||||
export class ImageIoService implements IImageIoService {
|
||||
private _waitCount = 0;
|
||||
|
||||
private _change$ = new Subject<number>();
|
||||
change$ = this._change$ as Observable<number>;
|
||||
|
||||
setWaitCount(count: number) {
|
||||
this._waitCount = count;
|
||||
this._change$.next(count);
|
||||
}
|
||||
|
||||
private _imageSourceCache: Map<string, HTMLImageElement> = new Map();
|
||||
getImageSourceCache(source: string, imageSourceType: ImageSourceType) {
|
||||
const cachedImage = this._imageSourceCache.get(source);
|
||||
if (cachedImage != null) {
|
||||
return cachedImage;
|
||||
}
|
||||
if (imageSourceType === ImageSourceType.BASE64) {
|
||||
const image = new Image();
|
||||
image.onload = () => this._change$.next(this._waitCount);
|
||||
image.onerror = () => this._change$.next(this._waitCount);
|
||||
image.src = source;
|
||||
this._imageSourceCache.set(source, image);
|
||||
return image;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
addImageSourceCache(source: string, imageSourceType: ImageSourceType, imageSource: Nullable<HTMLImageElement>) {
|
||||
if (imageSourceType === ImageSourceType.BASE64 || imageSource == null) {
|
||||
return;
|
||||
}
|
||||
this._imageSourceCache.set(source, imageSource);
|
||||
}
|
||||
|
||||
async getImage(imageId: string): Promise<string> {
|
||||
return Promise.resolve(imageId);
|
||||
}
|
||||
|
||||
async saveImage(imageFile: File): Promise<Nullable<IImageIoServiceParam>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!DRAWING_IMAGE_ALLOW_IMAGE_LIST.includes(imageFile.type)) {
|
||||
reject(new Error(ImageUploadStatusType.ERROR_IMAGE_TYPE));
|
||||
this._decreaseWaiting();
|
||||
return;
|
||||
}
|
||||
if (imageFile.size > getDrawingImageAllowSize()) {
|
||||
reject(new Error(ImageUploadStatusType.ERROR_EXCEED_SIZE));
|
||||
this._decreaseWaiting();
|
||||
return;
|
||||
}
|
||||
// Get the width and height of the uploaded image
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(imageFile);
|
||||
reader.onload = (evt) => {
|
||||
const replaceSrc = evt.target?.result as string;
|
||||
if (replaceSrc == null) {
|
||||
reject(new Error(ImageUploadStatusType.ERROR_IMAGE));
|
||||
this._decreaseWaiting();
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = generateRandomId(6);
|
||||
resolve({
|
||||
imageId,
|
||||
imageSourceType: ImageSourceType.BASE64,
|
||||
source: replaceSrc,
|
||||
base64Cache: replaceSrc,
|
||||
status: ImageUploadStatusType.SUCCUSS,
|
||||
});
|
||||
|
||||
this._decreaseWaiting();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _decreaseWaiting() {
|
||||
this._waitCount -= 1;
|
||||
this._change$.next(this._waitCount);
|
||||
}
|
||||
}
|
||||
56
packages/drawing/src/services/url-image.service.ts
Normal file
56
packages/drawing/src/services/url-image.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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, IURLImageService } from '@univerjs/core';
|
||||
import { Disposable, toDisposable } from '@univerjs/core';
|
||||
|
||||
export class URLImageService extends Disposable implements IURLImageService {
|
||||
private _urlImageDownloader: ((url: string) => Promise<string>) | null = null;
|
||||
|
||||
registerURLImageDownloader(downloader: (url: string) => Promise<string>): IDisposable {
|
||||
this._urlImageDownloader = downloader;
|
||||
return toDisposable(() => {
|
||||
this._urlImageDownloader = null;
|
||||
});
|
||||
}
|
||||
|
||||
async getImage(url: string): Promise<string> {
|
||||
if (this._urlImageDownloader) {
|
||||
try {
|
||||
return await this._urlImageDownloader(url);
|
||||
} catch (error) {
|
||||
console.error(`Custom downloader failed for ${url}, falling back to default behavior:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async downloadImage(url: string): Promise<Blob> {
|
||||
if (this._urlImageDownloader) {
|
||||
try {
|
||||
const base64 = await this._urlImageDownloader(url);
|
||||
const response = await fetch(base64);
|
||||
return await response.blob();
|
||||
} catch (error) {
|
||||
console.error(`Custom downloader failed for ${url}, falling back to default fetch:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
return await response.blob();
|
||||
}
|
||||
}
|
||||
156
packages/drawing/src/utils/__tests__/drawing-copy-plan.spec.ts
Normal file
156
packages/drawing/src/utils/__tests__/drawing-copy-plan.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDrawingCopyPlan, getOrCreateDrawingCopyPlan } from '../drawing-copy-plan';
|
||||
|
||||
const unitId = 'unit';
|
||||
const subUnitId = 'subUnit';
|
||||
|
||||
function createDrawing(drawingId: string, overrides: Partial<IDrawingParam> = {}): IDrawingParam {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('drawing copy plan', () => {
|
||||
it('creates one copy id map for group children and chart drawings', () => {
|
||||
let nextId = 0;
|
||||
const plan = createDrawingCopyPlan([
|
||||
createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP }),
|
||||
createDrawing('image-child', { groupId: 'group' }),
|
||||
createDrawing('chart-child', { drawingType: DrawingTypeEnum.DRAWING_CHART, groupId: 'group' }),
|
||||
], {
|
||||
unitId,
|
||||
sourceSubUnitId: subUnitId,
|
||||
targetSubUnitId: 'copied-sheet',
|
||||
generateId: () => `copy-${nextId++}`,
|
||||
});
|
||||
|
||||
expect(plan.idMap.size).toBe(3);
|
||||
expect(plan.idMap.get('group')).toBe('copy-0');
|
||||
expect(plan.idMap.get('image-child')).toBe('copy-1');
|
||||
expect(plan.idMap.get('chart-child')).toBe('copy-2');
|
||||
expect(plan.drawings).toEqual([
|
||||
expect.objectContaining({ drawingId: 'copy-0', subUnitId: 'copied-sheet' }),
|
||||
expect.objectContaining({ drawingId: 'copy-1', groupId: 'copy-0', subUnitId: 'copied-sheet' }),
|
||||
expect.objectContaining({ drawingId: 'copy-2', groupId: 'copy-0', subUnitId: 'copied-sheet' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps copied drawing unchanged when the id map cannot resolve its current id', () => {
|
||||
let readCount = 0;
|
||||
const dynamicDrawing = {
|
||||
...createDrawing('registered-id'),
|
||||
get drawingId() {
|
||||
readCount += 1;
|
||||
return readCount <= 2 ? 'registered-id' : 'unmapped-id';
|
||||
},
|
||||
} as IDrawingParam;
|
||||
|
||||
const plan = createDrawingCopyPlan([dynamicDrawing], {
|
||||
unitId,
|
||||
sourceSubUnitId: subUnitId,
|
||||
targetSubUnitId: 'copied-sheet',
|
||||
generateId: () => 'copy-0',
|
||||
});
|
||||
|
||||
expect(plan.idMap.get('registered-id')).toBe('copy-0');
|
||||
expect(plan.drawings[0]).toEqual(expect.objectContaining({
|
||||
drawingId: 'unmapped-id',
|
||||
subUnitId,
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates uncached copy plans and reuses cached complete copy plans', () => {
|
||||
let nextId = 0;
|
||||
const options = {
|
||||
unitId,
|
||||
sourceSubUnitId: subUnitId,
|
||||
targetSubUnitId: 'copied-sheet',
|
||||
generateId: () => `copy-${nextId++}`,
|
||||
};
|
||||
const child = createDrawing('child', { groupId: 'missing-group' });
|
||||
const uncachedPlan = getOrCreateDrawingCopyPlan(undefined, [child], options);
|
||||
|
||||
expect(uncachedPlan.drawings[0]).toEqual(expect.objectContaining({
|
||||
drawingId: 'copy-0',
|
||||
subUnitId: 'copied-sheet',
|
||||
}));
|
||||
expect(uncachedPlan.drawings[0].groupId).toBeUndefined();
|
||||
|
||||
const copyContext = new Map<string, unknown>();
|
||||
const cachedPlan = getOrCreateDrawingCopyPlan(copyContext, [child], options);
|
||||
|
||||
expect(getOrCreateDrawingCopyPlan(copyContext, [child], options)).toBe(cachedPlan);
|
||||
});
|
||||
|
||||
it('extends a cached copy plan with the default id generator when no generator is provided', () => {
|
||||
const options = {
|
||||
unitId,
|
||||
sourceSubUnitId: subUnitId,
|
||||
targetSubUnitId: 'copied-sheet',
|
||||
};
|
||||
const copyContext = new Map<string, unknown>();
|
||||
const firstPlan = getOrCreateDrawingCopyPlan(copyContext, [createDrawing('child-1')], options);
|
||||
|
||||
const extendedPlan = getOrCreateDrawingCopyPlan(copyContext, [
|
||||
createDrawing('child-1'),
|
||||
createDrawing('child-2'),
|
||||
], options);
|
||||
|
||||
expect(extendedPlan).toBe(firstPlan);
|
||||
expect(extendedPlan.idMap.has('child-2')).toBe(true);
|
||||
expect(extendedPlan.drawings.map((drawing) => drawing.subUnitId)).toEqual(['copied-sheet', 'copied-sheet']);
|
||||
});
|
||||
|
||||
it('extends a cached copy plan when a later caller provides more drawing graph nodes', () => {
|
||||
let nextId = 0;
|
||||
const copyContext = new Map<string, unknown>();
|
||||
const options = {
|
||||
unitId,
|
||||
sourceSubUnitId: subUnitId,
|
||||
targetSubUnitId: 'copied-sheet',
|
||||
generateId: () => `copy-${nextId++}`,
|
||||
};
|
||||
const chart = createDrawing('chart-child', { drawingType: DrawingTypeEnum.DRAWING_CHART, groupId: 'group' });
|
||||
const firstPlan = getOrCreateDrawingCopyPlan(copyContext, [chart], options);
|
||||
|
||||
expect(firstPlan.drawings[0].groupId).toBeUndefined();
|
||||
|
||||
const extendedPlan = getOrCreateDrawingCopyPlan(copyContext, [
|
||||
createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP }),
|
||||
createDrawing('image-child', { groupId: 'group' }),
|
||||
chart,
|
||||
], options);
|
||||
|
||||
expect(extendedPlan).toBe(firstPlan);
|
||||
expect(extendedPlan.idMap.get('chart-child')).toBe('copy-0');
|
||||
expect(extendedPlan.idMap.get('group')).toBe('copy-1');
|
||||
expect(extendedPlan.idMap.get('image-child')).toBe('copy-2');
|
||||
expect(extendedPlan.drawings).toEqual([
|
||||
expect.objectContaining({ drawingId: 'copy-1', subUnitId: 'copied-sheet' }),
|
||||
expect.objectContaining({ drawingId: 'copy-2', groupId: 'copy-1', subUnitId: 'copied-sheet' }),
|
||||
expect.objectContaining({ drawingId: 'copy-0', groupId: 'copy-1', subUnitId: 'copied-sheet' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
25
packages/drawing/src/utils/__tests__/drawing-group.spec.ts
Normal file
25
packages/drawing/src/utils/__tests__/drawing-group.spec.ts
Normal 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 { DrawingTypeEnum } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isGroupableDrawingType } from '../drawing-group';
|
||||
|
||||
describe('drawing group', () => {
|
||||
it('rejects drawing types that cannot join a group', () => {
|
||||
expect(isGroupableDrawingType(DrawingTypeEnum.DRAWING_TABLE)).toBe(false);
|
||||
});
|
||||
});
|
||||
72
packages/drawing/src/utils/__tests__/drawing-utils.spec.ts
Normal file
72
packages/drawing/src/utils/__tests__/drawing-utils.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDrawingShapeKeyByDrawingSearch } from '../get-image-shape-key';
|
||||
import { getImageSize } from '../get-image-size';
|
||||
|
||||
class MockImage {
|
||||
width = 320;
|
||||
height = 180;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | ((error?: unknown) => void) = null;
|
||||
|
||||
private _src = '';
|
||||
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this._src = value;
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (value === 'bad-image') {
|
||||
this.onerror?.(new Error('load failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('drawing utils', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should generate a drawing shape key with or without index', () => {
|
||||
expect(getDrawingShapeKeyByDrawingSearch({ unitId: 'u', subUnitId: 's', drawingId: 'd' })).toBe('u#-#s#-#d');
|
||||
expect(getDrawingShapeKeyByDrawingSearch({ unitId: 'u', subUnitId: 's', drawingId: 'd' }, 2)).toBe('u#-#s#-#d#-#2');
|
||||
});
|
||||
|
||||
it('should resolve image size from the loaded image element', async () => {
|
||||
vi.stubGlobal('Image', MockImage);
|
||||
|
||||
await expect(getImageSize('good-image')).resolves.toMatchObject({
|
||||
width: 320,
|
||||
height: 180,
|
||||
image: expect.objectContaining({ src: 'good-image' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject when image loading fails', async () => {
|
||||
vi.stubGlobal('Image', MockImage);
|
||||
|
||||
await expect(getImageSize('bad-image')).rejects.toEqual(new Error('load failed'));
|
||||
});
|
||||
});
|
||||
107
packages/drawing/src/utils/__tests__/rotate-enabled.spec.ts
Normal file
107
packages/drawing/src/utils/__tests__/rotate-enabled.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDrawingRotateEnabled } from '../rotate-enabled';
|
||||
|
||||
const unitId = 'unit-1';
|
||||
const subUnitId = 'sheet-1';
|
||||
|
||||
function createDrawing(drawingId: string, drawingType = DrawingTypeEnum.DRAWING_IMAGE, rotateEnabled?: boolean): IDrawingParam {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
drawingType,
|
||||
transform: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
rotateEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGroup(drawingId: string, rotateEnabled?: boolean): IDrawingParam {
|
||||
return createDrawing(drawingId, DrawingTypeEnum.DRAWING_GROUP, rotateEnabled);
|
||||
}
|
||||
|
||||
describe('resolveDrawingRotateEnabled', () => {
|
||||
it('keeps a group rotatable when all descendants are rotatable', () => {
|
||||
const group = createGroup('group');
|
||||
const child = createDrawing('shape');
|
||||
|
||||
expect(resolveDrawingRotateEnabled(group, {
|
||||
getChildren: (drawing) => drawing.drawingId === 'group' ? [child] : undefined,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('disables a group when a nested descendant cannot rotate', () => {
|
||||
const root = createGroup('root');
|
||||
const nested = createGroup('nested');
|
||||
const child = createDrawing('shape', DrawingTypeEnum.DRAWING_SHAPE, false);
|
||||
|
||||
expect(resolveDrawingRotateEnabled(root, {
|
||||
getChildren: (drawing) => {
|
||||
if (drawing.drawingId === 'root') return [nested];
|
||||
if (drawing.drawingId === 'nested') return [child];
|
||||
return undefined;
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('recomputes a stale group rotate flag from resolvable descendants', () => {
|
||||
const group = createGroup('group', false);
|
||||
const child = createDrawing('shape');
|
||||
|
||||
expect(resolveDrawingRotateEnabled(group, {
|
||||
getChildren: (drawing) => drawing.drawingId === 'group' ? [child] : undefined,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a group persisted rotate flag when children cannot be resolved', () => {
|
||||
expect(resolveDrawingRotateEnabled(createGroup('group', false))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses known non-rotatable drawing types for old data without rotate flags', () => {
|
||||
const group = createGroup('group');
|
||||
const chart = createDrawing('chart', DrawingTypeEnum.DRAWING_CHART);
|
||||
|
||||
expect(resolveDrawingRotateEnabled(group, {
|
||||
getChildren: (drawing) => drawing.drawingId === 'group' ? [chart] : undefined,
|
||||
isKnownNonRotatableType: (drawingType) => drawingType === DrawingTypeEnum.DRAWING_CHART,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps cyclic group graphs rotatable instead of recursing forever', () => {
|
||||
const group = createGroup('group');
|
||||
|
||||
expect(resolveDrawingRotateEnabled(group, {
|
||||
getChildren: () => [group],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('uses render object transformer config as a runtime rotation guard', () => {
|
||||
const shape = createDrawing('shape');
|
||||
|
||||
expect(resolveDrawingRotateEnabled(shape, {
|
||||
getRenderObject: () => ({ transformerConfig: { rotateEnabled: false } }),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
134
packages/drawing/src/utils/drawing-copy-plan.ts
Normal file
134
packages/drawing/src/utils/drawing-copy-plan.ts
Normal 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 { IDrawingParam } from '@univerjs/core';
|
||||
import { generateRandomId } from '@univerjs/core';
|
||||
|
||||
/**
|
||||
* A command-scoped cache key shared by sheet-copy interceptors so every drawing model
|
||||
* resolves against the same copied drawing IDs.
|
||||
*/
|
||||
export const DRAWING_COPY_CONTEXT_KEY = 'univer.drawing.copy-plan';
|
||||
|
||||
export interface ICreateDrawingCopyPlanOptions {
|
||||
unitId: string;
|
||||
sourceSubUnitId: string;
|
||||
targetSubUnitId: string;
|
||||
generateId?: () => string;
|
||||
}
|
||||
|
||||
export interface IDrawingCopyPlan<T extends IDrawingParam = IDrawingParam> {
|
||||
idMap: Map<string, string>;
|
||||
drawings: T[];
|
||||
}
|
||||
|
||||
function cloneDrawing<T extends IDrawingParam>(drawing: T): T {
|
||||
return JSON.parse(JSON.stringify(drawing)) as T;
|
||||
}
|
||||
|
||||
function copyDrawingWithIdMap<T extends IDrawingParam>(
|
||||
drawing: T,
|
||||
idMap: Map<string, string>,
|
||||
options: ICreateDrawingCopyPlanOptions
|
||||
): T {
|
||||
const copied = cloneDrawing(drawing);
|
||||
const copiedDrawingId = idMap.get(drawing.drawingId);
|
||||
|
||||
if (copiedDrawingId == null) {
|
||||
return copied;
|
||||
}
|
||||
|
||||
copied.unitId = options.unitId;
|
||||
copied.subUnitId = options.targetSubUnitId;
|
||||
copied.drawingId = copiedDrawingId;
|
||||
|
||||
// Remap the parent through the shared plan instead of retaining a relationship to the source sheet.
|
||||
if (copied.groupId) {
|
||||
const copiedGroupId = idMap.get(copied.groupId);
|
||||
if (copiedGroupId) {
|
||||
copied.groupId = copiedGroupId;
|
||||
} else {
|
||||
// The parent is not part of this copy, so avoid leaving a dangling cross-sheet group reference.
|
||||
delete copied.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
export function createDrawingCopyPlan<T extends IDrawingParam>(
|
||||
drawings: readonly T[],
|
||||
options: ICreateDrawingCopyPlanOptions
|
||||
): IDrawingCopyPlan<T> {
|
||||
const { generateId = () => generateRandomId(10) } = options;
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
drawings.forEach((drawing) => {
|
||||
if (!idMap.has(drawing.drawingId)) {
|
||||
idMap.set(drawing.drawingId, generateId());
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
idMap,
|
||||
drawings: drawings.map((drawing) => copyDrawingWithIdMap(drawing, idMap, options)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrCreateDrawingCopyPlan<T extends IDrawingParam>(
|
||||
copyContext: Map<string, unknown> | undefined,
|
||||
drawings: readonly T[],
|
||||
options: ICreateDrawingCopyPlanOptions
|
||||
): IDrawingCopyPlan<T> {
|
||||
if (copyContext == null) {
|
||||
return createDrawingCopyPlan(drawings, options);
|
||||
}
|
||||
|
||||
const cached = copyContext.get(DRAWING_COPY_CONTEXT_KEY) as IDrawingCopyPlan<T> | undefined;
|
||||
if (cached) {
|
||||
const { generateId = () => generateRandomId(10) } = options;
|
||||
// Feature interceptors may contribute different portions of the drawing graph. Extend the plan in
|
||||
// place to preserve IDs already consumed by earlier interceptors.
|
||||
const hasMissingDrawing = drawings.some((drawing) => !cached.idMap.has(drawing.drawingId));
|
||||
if (!hasMissingDrawing) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
drawings.forEach((drawing) => {
|
||||
if (!cached.idMap.has(drawing.drawingId)) {
|
||||
cached.idMap.set(drawing.drawingId, generateId());
|
||||
}
|
||||
});
|
||||
|
||||
const incomingDrawingIds = new Set(drawings.map((drawing) => drawing.drawingId));
|
||||
const originalIdByCopiedId = new Map(Array.from(cached.idMap.entries()).map(([drawingId, copiedDrawingId]) => [copiedDrawingId, drawingId]));
|
||||
const retainedDrawings = cached.drawings.filter((drawing) => {
|
||||
const originalDrawingId = originalIdByCopiedId.get(drawing.drawingId);
|
||||
return originalDrawingId == null || !incomingDrawingIds.has(originalDrawingId);
|
||||
});
|
||||
|
||||
// Re-copy incoming nodes after extending the map so a child can resolve a group discovered later.
|
||||
cached.drawings = [
|
||||
...drawings.map((drawing) => copyDrawingWithIdMap(drawing, cached.idMap, options)),
|
||||
...retainedDrawings,
|
||||
];
|
||||
return cached;
|
||||
}
|
||||
|
||||
const plan = createDrawingCopyPlan(drawings, options);
|
||||
copyContext.set(DRAWING_COPY_CONTEXT_KEY, plan);
|
||||
return plan;
|
||||
}
|
||||
29
packages/drawing/src/utils/drawing-group.ts
Normal file
29
packages/drawing/src/utils/drawing-group.ts
Normal 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 { DrawingType } from '@univerjs/core';
|
||||
import { DrawingTypeEnum } from '@univerjs/core';
|
||||
|
||||
export const DRAWING_GROUPABLE_TYPES = [
|
||||
DrawingTypeEnum.DRAWING_IMAGE,
|
||||
DrawingTypeEnum.DRAWING_SHAPE,
|
||||
DrawingTypeEnum.DRAWING_CHART,
|
||||
DrawingTypeEnum.DRAWING_GROUP,
|
||||
] as const;
|
||||
|
||||
export function isGroupableDrawingType(type: DrawingType): boolean {
|
||||
return (DRAWING_GROUPABLE_TYPES as readonly DrawingType[]).includes(type);
|
||||
}
|
||||
23
packages/drawing/src/utils/get-image-shape-key.ts
Normal file
23
packages/drawing/src/utils/get-image-shape-key.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDrawingSearch } from '@univerjs/core';
|
||||
|
||||
export function getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId }: IDrawingSearch, index?: number): string {
|
||||
return typeof index === 'number'
|
||||
? `${unitId}#-#${subUnitId}#-#${drawingId}#-#${index}`
|
||||
: `${unitId}#-#${subUnitId}#-#${drawingId}`;
|
||||
}
|
||||
38
packages/drawing/src/utils/get-image-size.ts
Normal file
38
packages/drawing/src/utils/get-image-size.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ISize } from '@univerjs/core';
|
||||
|
||||
interface IImageParam extends ISize {
|
||||
image: HTMLImageElement;
|
||||
}
|
||||
|
||||
export const getImageSize = async (src: string): Promise<Required<IImageParam>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image,
|
||||
});
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
};
|
||||
70
packages/drawing/src/utils/rotate-enabled.ts
Normal file
70
packages/drawing/src/utils/rotate-enabled.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DrawingType, IDrawingParam } from '@univerjs/core';
|
||||
import { DrawingTypeEnum } from '@univerjs/core';
|
||||
|
||||
interface IRotateRenderObject {
|
||||
transformerConfig?: {
|
||||
rotateEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDrawingRotateEnabledResolverOptions {
|
||||
getChildren?: (drawing: IDrawingParam) => readonly IDrawingParam[] | null | undefined;
|
||||
getRenderObject?: (drawing: IDrawingParam) => IRotateRenderObject | null | undefined;
|
||||
isKnownNonRotatableType?: (drawingType: DrawingType) => boolean;
|
||||
}
|
||||
|
||||
function getDrawingKey(drawing: IDrawingParam): string {
|
||||
return `${drawing.unitId}\u0000${drawing.subUnitId}\u0000${drawing.drawingId}`;
|
||||
}
|
||||
|
||||
export function resolveDrawingRotateEnabled(
|
||||
drawing: IDrawingParam,
|
||||
options: IDrawingRotateEnabledResolverOptions = {},
|
||||
visiting = new Set<string>()
|
||||
): boolean {
|
||||
const drawingKey = getDrawingKey(drawing);
|
||||
if (visiting.has(drawingKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (drawing.drawingType === DrawingTypeEnum.DRAWING_GROUP) {
|
||||
visiting.add(drawingKey);
|
||||
const children = options.getChildren?.(drawing);
|
||||
if (children != null) {
|
||||
const enabled = children.every((child) => resolveDrawingRotateEnabled(child, options, visiting));
|
||||
visiting.delete(drawingKey);
|
||||
return enabled;
|
||||
}
|
||||
visiting.delete(drawingKey);
|
||||
}
|
||||
|
||||
if (drawing.transform?.rotateEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.getRenderObject?.(drawing)?.transformerConfig?.rotateEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.isKnownNonRotatableType?.(drawing.drawingType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
8
packages/drawing/tsconfig.json
Normal file
8
packages/drawing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/drawing/tsconfig.node.json
Normal file
18
packages/drawing/tsconfig.node.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/node",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
3
packages/drawing/vitest.config.ts
Normal file
3
packages/drawing/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user