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

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

View File

@@ -0,0 +1,37 @@
# @univerjs/drawing
[![npm version](https://img.shields.io/npm/v/@univerjs/drawing?style=flat-square)](https://npmjs.com/package/@univerjs/drawing)
[![license](https://img.shields.io/npm/l/@univerjs/drawing?style=flat-square)](https://npmjs.com/package/@univerjs/drawing)
[![downloads](https://img.shields.io/npm/dm/@univerjs/drawing?style=flat-square)](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)

View 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"
}
}

View 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'];

View File

@@ -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')]);
});
});

View File

@@ -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;
},
};

View 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 = {};

View File

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

View File

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

View 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)));
}
}

View File

@@ -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' }]]);
});
});

View File

@@ -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,
});
});
});

View File

@@ -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);
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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();
}
}

View 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' }),
]);
});
});

View File

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

View File

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

View 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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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