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/docs-drawing
[![npm version](https://img.shields.io/npm/v/@univerjs/docs-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/docs-drawing)
[![license](https://img.shields.io/npm/l/@univerjs/docs-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/docs-drawing)
[![downloads](https://img.shields.io/npm/dm/@univerjs/docs-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/docs-drawing)
`@univerjs/docs-drawing` connects the common drawing model to Univer Docs so documents can host and manage drawing objects.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/docs-drawing` | `UniverDocsDrawing` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/docs-drawing
# or
npm install @univerjs/docs-drawing
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
univer.registerPlugin(UniverDocsDrawingPlugin);
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/docs-drawing)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,88 @@
{
"name": "@univerjs/docs-drawing",
"version": "1.0.0-rc.0",
"private": false,
"description": "Drawing model integration for Univer Docs.",
"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",
"docs",
"drawing",
"document",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/drawing": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,91 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRemoveDocDrawingCommandParams } from '../remove-doc-drawing.command';
import { DrawingTypeEnum, ICommandService, ImageSourceType } from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../../facade/__tests__/create-test-bed';
import { RemoveDocDrawingCommand } from '../remove-doc-drawing.command';
class MockImage {
width = 800;
height = 400;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
get src(): string {
return '';
}
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
describe('RemoveDocDrawingCommand', () => {
let testBed: ReturnType<typeof createFacadeTestBed>;
beforeEach(() => {
vi.stubGlobal('Image', MockImage);
testBed = createFacadeTestBed();
});
afterEach(() => {
testBed.univer.dispose();
vi.unstubAllGlobals();
});
it.each([
[DrawingTypeEnum.DRAWING_IMAGE, DocHistoryAction.DeleteImage],
[DrawingTypeEnum.DRAWING_SHAPE, DocHistoryAction.DeleteShape],
[DrawingTypeEnum.DRAWING_CHART, DocHistoryAction.DeleteChart],
])('records the drawing type %s in history metadata', async (drawingType, historyAction) => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
});
const commandService = testBed.injector.get(ICommandService);
const mutationSpy = vi.spyOn(commandService, 'syncExecuteCommand');
const result = commandService.syncExecuteCommand<IRemoveDocDrawingCommandParams>(
RemoveDocDrawingCommand.id,
{
unitId: 'test-doc',
drawings: [{
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingId: image!.getId(),
drawingType,
}],
}
);
expect(result).toBe(true);
expect(mutationSpy).toHaveBeenCalledWith(
RichTextEditingMutation.id,
expect.objectContaining({ historyActions: [historyAction] })
);
});
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IUpdateDrawingDocTransformCommandParams } from '../update-doc-drawing-transform.command';
import { ICommandService, ImageSourceType } from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../../facade/__tests__/create-test-bed';
import { UpdateDrawingDocTransformCommand } from '../update-doc-drawing-transform.command';
class MockImage {
width = 800;
height = 400;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
get src(): string {
return '';
}
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
describe('UpdateDrawingDocTransformCommand', () => {
let testBed: ReturnType<typeof createFacadeTestBed>;
beforeEach(() => {
vi.stubGlobal('Image', MockImage);
testBed = createFacadeTestBed();
});
afterEach(() => {
testBed.univer.dispose();
vi.unstubAllGlobals();
});
it('persists image crop geometry and restores it through undo and redo', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: { startOffset: 3, endOffset: 3, collapsed: true, segmentId: '' },
});
if (!image) {
throw new Error('Image insertion failed');
}
const drawingId = image.getId();
const before = structuredClone(testBed.documentDataModel.getDrawings()?.[drawingId]);
const commandService = testBed.injector.get(ICommandService);
expect(commandService.syncExecuteCommand<IUpdateDrawingDocTransformCommandParams>(
UpdateDrawingDocTransformCommand.id,
{
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [
{ drawingId, key: 'prstGeom', value: 'roundRect' },
{ drawingId, key: 'adjustValues', value: { adj: 25000 } },
{ drawingId, key: 'srcRect', value: { left: 10, top: 0, right: 10, bottom: 0 } },
],
}
)).toBe(true);
const after = structuredClone(testBed.documentDataModel.getDrawings()?.[drawingId]);
expect(after).toMatchObject({
prstGeom: 'roundRect',
adjustValues: { adj: 25000 },
srcRect: { left: 10, top: 0, right: 10, bottom: 0 },
docTransform: before?.docTransform,
});
expect(testBed.document.undo()).toBe(true);
expect(testBed.documentDataModel.getDrawings()?.[drawingId]).toEqual(before);
expect(testBed.document.redo()).toBe(true);
expect(testBed.documentDataModel.getDrawings()?.[drawingId]).toEqual(after);
});
it('marks image transforms for history action summaries', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
});
const commandService = testBed.injector.get(ICommandService);
const mutationSpy = vi.spyOn(commandService, 'syncExecuteCommand');
const result = commandService.syncExecuteCommand<IUpdateDrawingDocTransformCommandParams>(
UpdateDrawingDocTransformCommand.id,
{
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId: image!.getId(), key: 'angle', value: 15 }],
}
);
expect(result).toBe(true);
expect(mutationSpy).toHaveBeenCalledWith(
RichTextEditingMutation.id,
expect.objectContaining({ historyAction: DocHistoryAction.UpdateImage })
);
});
});

View File

@@ -0,0 +1,117 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IUpdateDocDrawingWrappingStyleParams } from '@univerjs/docs-drawing';
import {
BooleanNumber,
ICommandService,
ImageSourceType,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { TextWrappingStyle, UpdateDocDrawingWrappingStyleCommand } from '@univerjs/docs-drawing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../../facade/__tests__/create-test-bed';
class MockImage {
width = 800;
height = 400;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
get src(): string {
return '';
}
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
describe('UpdateDocDrawingWrappingStyleCommand', () => {
let testBed: ReturnType<typeof createFacadeTestBed>;
beforeEach(() => {
vi.stubGlobal('Image', MockImage);
testBed = createFacadeTestBed();
});
afterEach(() => {
testBed.univer.dispose();
vi.unstubAllGlobals();
});
it('updates the requested document without render services or current-unit dependence', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
});
const positionH = { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 80 };
const positionV = { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 120 };
const drawing = image!.getImageData()!;
const otherDocument = testBed.univer.createUnit<IDocumentData, DocumentDataModel>(
UniverInstanceType.UNIVER_DOC,
{
id: 'other-doc',
documentStyle: {},
body: { dataStream: '\r\n', paragraphs: [{ startIndex: 0, paragraphId: 'other-paragraph' }], customBlocks: [] },
drawings: {},
drawingsOrder: [],
}
);
testBed.injector.get(IUniverInstanceService).focusUnit(otherDocument.getUnitId());
const commandService = testBed.injector.get(ICommandService);
const mutationSpy = vi.spyOn(commandService, 'syncExecuteCommand');
const result = commandService.syncExecuteCommand<IUpdateDocDrawingWrappingStyleParams>(
UpdateDocDrawingWrappingStyleCommand.id,
{
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{
...drawing,
docTransform: { ...drawing.docTransform, positionH, positionV },
}],
wrappingStyle: TextWrappingStyle.BEHIND_TEXT,
}
);
expect(result).toBe(true);
expect(mutationSpy).toHaveBeenCalledWith(
RichTextEditingMutation.id,
expect.objectContaining({ historyAction: DocHistoryAction.UpdateImage })
);
expect(testBed.document.getImage(image!.getId())?.getImageData()).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
docTransform: { positionH, positionV },
});
expect(otherDocument.getSnapshot().drawings).toEqual({});
});
});

View File

@@ -0,0 +1,144 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, ITextRangeParam } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing } from '../../services/doc-drawing.service';
import {
BooleanNumber,
BuildTextUtils,
CommandType,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import {
DocSelectionManagerService,
getContentInsertRange,
normalizeTextRange,
RichTextEditingMutation,
} from '@univerjs/docs';
export interface IInsertDocDrawingCommandParams {
unitId: string;
drawings: IDocDrawing[];
textRange?: ITextRangeParam;
}
/**
* The command to insert new drawings
*/
export const InsertDocDrawingCommand: ICommand = {
id: 'doc.command.insert-doc-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IInsertDocDrawingCommandParams) => {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { unitId, drawings, textRange } = params;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!documentDataModel) {
return false;
}
const resolvedTextRange = resolveDocDrawingInsertTextRange(accessor, unitId, textRange);
if (!resolvedTextRange) {
return false;
}
const { segmentId = '' } = resolvedTextRange;
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!body) {
return false;
}
const snapshot = documentDataModel.getSnapshot();
const isFootnote = !!snapshot.notes?.[segmentId];
if (isFootnote && drawings.some((drawing) => drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE)) {
return false;
}
const isHeaderFooter = !!snapshot.headers?.[segmentId] || !!snapshot.footers?.[segmentId];
let targetDrawings = isHeaderFooter
? drawings.map((drawing) => ({
...drawing,
isMultiTransform: BooleanNumber.TRUE,
transforms: drawing.transforms ?? (drawing.transform ? [drawing.transform] : null),
}))
: drawings;
if (isFootnote) {
targetDrawings = drawings.map((drawing) => ({ ...drawing, layoutType: PositionedObjectLayoutType.INLINE }));
}
const actions = BuildTextUtils.drawing.add({
selection: resolvedTextRange,
documentDataModel,
drawings: targetDrawings,
});
if (!actions) {
return false;
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId,
actions: [],
textRanges: [],
},
};
doMutation.params.actions = actions;
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
function resolveDocDrawingInsertTextRange(
accessor: IAccessor,
unitId: string,
textRange?: ITextRangeParam
): ITextRangeParam | null {
const activeTextRange = accessor.get(DocSelectionManagerService).getActiveTextRange();
const explicitTextRange = textRange ? normalizeTextRange(textRange) : null;
const contentInsertRange = explicitTextRange ?? getContentInsertRange(accessor, unitId);
if (!contentInsertRange) {
return activeTextRange ?? null;
}
return {
...activeTextRange,
startOffset: contentInsertRange.startOffset,
endOffset: contentInsertRange.endOffset,
collapsed: contentInsertRange.startOffset === contentInsertRange.endOffset,
segmentId: contentInsertRange.segmentId ?? activeTextRange?.segmentId ?? '',
};
}

View File

@@ -0,0 +1,240 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IDisposable, IMutationInfo, ITextRangeParam, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing } from '../../services/doc-drawing.service';
import {
CommandType,
DrawingTypeEnum,
getRichTextEditPath,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
MemoryCursor,
TextX,
TextXActionType,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, DocSelectionManagerService, getContentInsertRange, normalizeTextRange, RichTextEditingMutation } from '@univerjs/docs';
import { IDocDrawingAdapterService } from '../../services/doc-drawing-adapter.service';
import { findDocDrawing } from '../../services/doc-drawing-source';
export interface IRemoveDocDrawingCommandParam {
unitId: string;
subUnitId: string;
drawingId: string;
drawingType: DrawingTypeEnum;
}
export interface IRemoveDocDrawingCommandParams {
unitId: string;
drawings: IRemoveDocDrawingCommandParam[];
textRange?: ITextRangeParam;
}
export const RemoveDocDrawingCommand: ICommand = {
id: 'doc.command.remove-doc-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IRemoveDocDrawingCommandParams) => {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const drawingAdapterService = accessor.get(IDocDrawingAdapterService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const { unitId, drawings: removeDrawings, textRange } = params;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!documentDataModel || removeDrawings.length === 0) {
return false;
}
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const explicitTextRange = !textRange ? null : normalizeTextRange(textRange);
const contentInsertRange = explicitTextRange ?? getContentInsertRange(accessor, unitId);
const snapshot = documentDataModel.getSnapshot();
const noteSegments = removeDrawings.map(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId ?? '');
if (noteSegments.some((id) => id !== noteSegments[0])) {
return false;
}
const segmentId = noteSegments[0] || (contentInsertRange?.segmentId ?? activeTextRange?.segmentId ?? '');
const footnote = snapshot.notes?.[segmentId];
const source = footnote ?? snapshot;
const prefix = footnote ? ['notes', segmentId] : [];
const textX = new TextX();
const jsonX = JSONX.getInstance();
const customBlocks = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customBlocks ?? [];
const removeCustomBlocks = removeDrawings
.map((drawing) => customBlocks.find((customBlock) => customBlock.blockId === drawing.drawingId))
.filter((block) => !!block)
.sort((a, b) => a!.startIndex > b!.startIndex ? 1 : -1);
if (removeCustomBlocks.length === 0) {
return false;
}
const drawings = source.drawings ?? {};
const removeDrawingParamById = new Map(removeDrawings.map((drawing) => [drawing.drawingId, drawing]));
const removeDrawingSnapshots = removeCustomBlocks
.map((block) => drawings[block!.blockId] as IDocDrawing | undefined)
.filter((drawing): drawing is IDocDrawing => drawing != null);
const resourceRedoMutations: IMutationInfo[] = [];
const resourceUndoMutations: IMutationInfo[] = [];
const resourceMutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }> = [];
for (const block of removeCustomBlocks) {
const { blockId } = block!;
const drawing = drawings[blockId] as IDocDrawing | undefined;
if (drawing == null) {
continue;
}
const removeDrawingParam = removeDrawingParamById.get(blockId);
const mutationInfos = drawingAdapterService.getRemoveDrawingMutationInfos({
unitId,
subUnitId: removeDrawingParam?.subUnitId ?? unitId,
drawing,
removeDrawings: removeDrawingSnapshots,
});
if (mutationInfos.redoMutations.length === 0 && mutationInfos.undoMutations.length === 0) {
continue;
}
resourceRedoMutations.push(...mutationInfos.redoMutations);
resourceUndoMutations.push(...mutationInfos.undoMutations);
resourceMutationGroups.push(mutationInfos);
}
if (!executeResourceMutationGroups(resourceMutationGroups, commandService)) {
return false;
}
const historyId = `doc-drawing-remove-resource:${unitId}:${removeCustomBlocks.map((block) => block!.blockId).join(',')}`;
let batchingDisposable: IDisposable | null = null;
if (resourceRedoMutations.length > 0 || resourceUndoMutations.length > 0) {
batchingDisposable = undoRedoService.__tempBatchingUndoRedo(unitId);
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: resourceRedoMutations,
undoMutations: resourceUndoMutations,
id: historyId,
});
}
const memoryCursor = new MemoryCursor();
const cursorIndex = removeCustomBlocks[0]!.startIndex;
const textRanges = [{ startOffset: cursorIndex, endOffset: cursorIndex }] as IRichTextEditingMutationParams['textRanges'];
const historyActions = getHistoryActions(removeDrawings);
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: { unitId, segmentId, actions: [], textRanges, historyActions },
};
const rawActions: JSONXActions = [];
for (const block of removeCustomBlocks) {
const { startIndex } = block!;
if (startIndex > memoryCursor.cursor) {
textX.push({ t: TextXActionType.RETAIN, len: startIndex - memoryCursor.cursor });
}
textX.push({ t: TextXActionType.DELETE, len: 1 });
memoryCursor.moveCursorTo(startIndex + 1);
}
rawActions.push(jsonX.editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId))!);
const drawingOrder = source.drawingsOrder ?? [];
const resourceBlocks = [...removeCustomBlocks].sort((left, right) =>
drawingOrder.indexOf(right!.blockId) - drawingOrder.indexOf(left!.blockId)
);
for (const block of resourceBlocks) {
const { blockId } = block!;
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawings[blockId]) {
rawActions.push(jsonX.removeOp([...prefix, 'drawings', blockId], drawings[blockId])!);
}
if (drawingIndex >= 0) {
rawActions.push(jsonX.removeOp([...prefix, 'drawingsOrder', drawingIndex], blockId)!);
}
}
doMutation.params.actions = rawActions.reduce((acc, cur) => JSONX.compose(acc, cur as JSONXActions), null as JSONXActions);
const result = commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(doMutation.id, doMutation.params);
if (!result && batchingDisposable != null) {
batchingDisposable.dispose();
undoRedoService.rollback(historyId, unitId);
return false;
}
batchingDisposable?.dispose();
return Boolean(result);
},
};
function getHistoryActions(drawings: IRemoveDocDrawingCommandParam[]): DocHistoryAction[] {
const historyActions = drawings.flatMap((drawing): DocHistoryAction[] => {
switch (drawing.drawingType) {
case DrawingTypeEnum.DRAWING_IMAGE:
return [DocHistoryAction.DeleteImage];
case DrawingTypeEnum.DRAWING_SHAPE:
return [DocHistoryAction.DeleteShape];
case DrawingTypeEnum.DRAWING_CHART:
return [DocHistoryAction.DeleteChart];
default:
return [];
}
});
return [...new Set(historyActions)];
}
function executeResourceMutationGroups(
mutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }>,
commandService: ICommandService
): boolean {
const executedUndoGroups: IMutationInfo[][] = [];
for (const mutationGroup of mutationGroups) {
if (!executeMutations(mutationGroup.redoMutations, commandService)) {
executeMutationGroups([...executedUndoGroups].reverse(), commandService);
return false;
}
executedUndoGroups.push(mutationGroup.undoMutations);
}
return true;
}
function executeMutationGroups(mutationGroups: IMutationInfo[][], commandService: ICommandService): void {
mutationGroups.forEach((mutations) => executeMutations(mutations, commandService));
}
function executeMutations(mutations: IMutationInfo[], commandService: ICommandService): boolean {
for (const mutation of mutations) {
if (!commandService.syncExecuteCommand(mutation.id, mutation.params)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,174 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IDocumentData, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDrawingOrderMapParam } from '@univerjs/drawing';
import {
ArrangeTypeEnum,
CommandType,
ICommandService,
IUniverInstanceService,
JSONX,
normalizeDrawingOrderIndex,
UniverInstanceType,
} from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
export type ISetDocDrawingArrangeCommandParams = IDrawingOrderMapParam & (
{ arrangeType: ArrangeTypeEnum; zOrder?: never }
| { arrangeType?: never; zOrder: number }
);
const DRAWINGS_ORDER_KEY = 'drawingsOrder' satisfies keyof IDocumentData;
/**
* Calculates the target model order. Adjacent moves treat selected drawings as a group,
* so selected drawings keep their relative order instead of crossing each other.
*/
function getArrangedDrawingOrder(
drawingOrder: string[],
drawingIds: string[],
arrangeType: ArrangeTypeEnum
): string[] {
const selectedDrawingIds = new Set(drawingIds.filter((drawingId) => drawingOrder.includes(drawingId)));
const arrangedDrawingOrder = [...drawingOrder];
if (selectedDrawingIds.size === 0) {
return arrangedDrawingOrder;
}
if (arrangeType === ArrangeTypeEnum.forward) {
for (let index = arrangedDrawingOrder.length - 2; index >= 0; index--) {
if (selectedDrawingIds.has(arrangedDrawingOrder[index]) && !selectedDrawingIds.has(arrangedDrawingOrder[index + 1])) {
[arrangedDrawingOrder[index], arrangedDrawingOrder[index + 1]] =
[arrangedDrawingOrder[index + 1], arrangedDrawingOrder[index]];
}
}
} else if (arrangeType === ArrangeTypeEnum.backward) {
for (let index = 1; index < arrangedDrawingOrder.length; index++) {
if (selectedDrawingIds.has(arrangedDrawingOrder[index]) && !selectedDrawingIds.has(arrangedDrawingOrder[index - 1])) {
[arrangedDrawingOrder[index], arrangedDrawingOrder[index - 1]] =
[arrangedDrawingOrder[index - 1], arrangedDrawingOrder[index]];
}
}
} else if (arrangeType === ArrangeTypeEnum.front || arrangeType === ArrangeTypeEnum.back) {
const selected = arrangedDrawingOrder.filter((drawingId) => selectedDrawingIds.has(drawingId));
const unselected = arrangedDrawingOrder.filter((drawingId) => !selectedDrawingIds.has(drawingId));
return arrangeType === ArrangeTypeEnum.front
? [...unselected, ...selected]
: [...selected, ...unselected];
}
return arrangedDrawingOrder;
}
function getDrawingOrderAtIndex(drawingOrder: string[], drawingIds: string[], zOrder: number): string[] {
if (drawingIds.length !== 1) {
return [...drawingOrder];
}
const arrangedDrawingOrder = [...drawingOrder];
const currentIndex = arrangedDrawingOrder.indexOf(drawingIds[0]);
if (currentIndex < 0) {
return arrangedDrawingOrder;
}
const [drawingId] = arrangedDrawingOrder.splice(currentIndex, 1);
const targetIndex = normalizeDrawingOrderIndex(zOrder, drawingOrder.length);
arrangedDrawingOrder.splice(targetIndex, 0, drawingId);
return arrangedDrawingOrder;
}
/**
* Converts the target order into granular JSONX move operations. This avoids replacing
* the entire drawingsOrder array and keeps collaboration conflicts scoped to moved items.
*/
function createDrawingOrderActions(drawingOrder: string[], arrangedDrawingOrder: string[]): JSONXActions {
const jsonX = JSONX.getInstance();
const workingDrawingOrder = [...drawingOrder];
const rawActions: JSONXActions[] = [];
for (let targetIndex = 0; targetIndex < arrangedDrawingOrder.length; targetIndex++) {
const drawingId = arrangedDrawingOrder[targetIndex];
const currentIndex = workingDrawingOrder.indexOf(drawingId);
if (currentIndex < 0 || currentIndex === targetIndex) {
continue;
}
const action = jsonX.moveOp(
[DRAWINGS_ORDER_KEY, currentIndex],
[DRAWINGS_ORDER_KEY, targetIndex]
);
if (action) {
rawActions.push(action);
}
workingDrawingOrder.splice(currentIndex, 1);
workingDrawingOrder.splice(targetIndex, 0, drawingId);
}
return rawActions.reduce<JSONXActions>(
(actions, action) => JSONX.compose(actions, action),
null
);
}
/**
* The command to arrange drawings.
*/
export const SetDocDrawingArrangeCommand: ICommand = {
id: 'doc.command.set-drawing-arrange',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: ISetDocDrawingArrangeCommandParams) => {
const commandService = accessor.get(ICommandService);
if (params == null) {
return false;
}
const { unitId, drawingIds } = params;
const documentDataModel = accessor.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const drawingOrder = documentDataModel?.getDrawingsOrder();
if (!drawingOrder) {
return false;
}
const arrangedDrawingOrder = params.zOrder !== undefined
? getDrawingOrderAtIndex(drawingOrder, drawingIds, params.zOrder)
: getArrangedDrawingOrder(drawingOrder, drawingIds, params.arrangeType);
const actions = createDrawingOrderActions(drawingOrder, arrangedDrawingOrder);
if (JSONX.isNoop(actions)) {
return false;
}
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(RichTextEditingMutation.id, {
unitId,
actions,
textRanges: null,
});
return Boolean(result);
},
};

View File

@@ -0,0 +1,101 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IObjectPositionH, IObjectPositionV, ISize, ISrcRect, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocImage } from '../../services/doc-drawing.service';
import {
CommandType,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
JSONX,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { findDocDrawing } from '../../services/doc-drawing-source';
export interface IDrawingDocTransform {
drawingId: string;
key: 'size' | 'angle' | 'positionH' | 'positionV' | 'flipX' | 'flipY' | 'srcRect' | 'prstGeom' | 'adjustValues';
value: ISize | number | boolean | IObjectPositionH | IObjectPositionV | Nullable<ISrcRect> | IDocImage['prstGeom'] | IDocImage['adjustValues'];
}
export interface IUpdateDrawingDocTransformCommandParams {
unitId: string;
subUnitId: string;
drawings: IDrawingDocTransform[];
}
export const UpdateDrawingDocTransformCommand: ICommand = {
id: 'doc.command.update-drawing-doc-transform',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IUpdateDrawingDocTransformCommandParams) => {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { unitId, drawings } = params;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!documentDataModel) {
return false;
}
const snapshot = documentDataModel.getSnapshot();
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
)
? DocHistoryAction.UpdateImage
: undefined;
const jsonX = JSONX.getInstance();
const actions: JSONXActions[] = [];
for (const { drawingId, key, value } of drawings) {
const source = findDocDrawing(snapshot, drawingId);
if (!source) {
return false;
}
const oldDrawing = source.drawing;
const isImageProperty = key === 'srcRect' || key === 'prstGeom' || key === 'adjustValues';
const oldValue = isImageProperty
? (key in oldDrawing ? Reflect.get(oldDrawing, key) : undefined)
: oldDrawing?.docTransform?.[key];
if (!Tools.diffValue(oldValue, value)) {
const path = isImageProperty
? [...source.path, key]
: [...source.path, 'docTransform', key];
// Optional transform fields such as flips do not exist in older documents.
const action = oldValue === undefined
? jsonX.insertOp(path, value)
: jsonX.replaceOp(path, oldValue, value);
actions.push(action);
}
}
return Boolean(commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
unitId,
historyAction,
actions: actions.reduce<JSONXActions>((acc, action) => JSONX.compose(acc, action), null),
textRanges: null,
debounce: true,
}));
},
};

View File

@@ -0,0 +1,168 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing } from '../../services/doc-drawing.service';
import {
BooleanNumber,
CommandType,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
JSONX,
PositionedObjectLayoutType,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
/**
* Controls how a document drawing participates in text layout.
*
* `INLINE`, `WRAP_SQUARE`, and `WRAP_TOP_AND_BOTTOM` participate in text layout and can cause text to reflow.
* `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` are overlay styles and do not cause text to reflow.
*/
import { findDocDrawing } from '../../services/doc-drawing-source';
export enum TextWrappingStyle {
/**
* Places the drawing in the text flow like a character.
*
* The drawing occupies layout space and does not overlap surrounding text. Its position is determined by its
* document range, so floating position settings have no visible effect.
*/
INLINE = 'inline',
/**
* Floats the drawing behind document text without causing the text to reflow.
*
* Use this style for backgrounds or watermarks. Text can overlap the drawing, so ensure that it remains readable.
*/
BEHIND_TEXT = 'behindText',
/**
* Floats the drawing in front of document text without causing the text to reflow.
*
* The drawing can cover overlapping text. Use this style only when an overlay is intentional.
*/
IN_FRONT_OF_TEXT = 'inFrontOfText',
/**
* Floats the drawing and wraps text around its rectangular bounds.
*
* Use this style for images that should appear beside body text, such as portraits or illustrations.
*/
WRAP_SQUARE = 'wrapSquare',
/**
* Floats the drawing in a horizontal band, with text only above and below it.
*
* Use this style for wide figures or standalone illustrations that should not have text beside them.
*/
WRAP_TOP_AND_BOTTOM = 'wrapTopAndBottom',
}
export const WRAPPING_STYLE_TO_LAYOUT_TYPE: Record<TextWrappingStyle, PositionedObjectLayoutType> = {
[TextWrappingStyle.INLINE]: PositionedObjectLayoutType.INLINE,
[TextWrappingStyle.WRAP_SQUARE]: PositionedObjectLayoutType.WRAP_SQUARE,
[TextWrappingStyle.WRAP_TOP_AND_BOTTOM]: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
[TextWrappingStyle.IN_FRONT_OF_TEXT]: PositionedObjectLayoutType.WRAP_NONE,
[TextWrappingStyle.BEHIND_TEXT]: PositionedObjectLayoutType.WRAP_NONE,
};
export interface IUpdateDocDrawingWrappingStyleParams {
unitId: string;
subUnitId: string;
drawings: IDocDrawing[];
wrappingStyle: TextWrappingStyle;
}
/**
* Updates document drawing wrapping styles and optional persisted positions.
*/
export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
id: 'doc.command.update-doc-drawing-wrapping-style',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IUpdateDocDrawingWrappingStyleParams) => {
if (!params) {
return false;
}
const { drawings, wrappingStyle, unitId } = params;
const commandService = accessor.get(ICommandService);
const documentDataModel = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(
unitId,
UniverInstanceType.UNIVER_DOC
);
if (!documentDataModel) {
return false;
}
const snapshot = documentDataModel.getSnapshot();
if (wrappingStyle !== TextWrappingStyle.INLINE && drawings.some(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId)) {
return false;
}
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
)
? DocHistoryAction.UpdateImage
: undefined;
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
for (const drawing of drawings) {
const source = findDocDrawing(snapshot, drawing.drawingId);
const oldDrawing = source?.drawing as IDocDrawing | undefined;
if (!oldDrawing || !source) {
continue;
}
const layoutType = WRAPPING_STYLE_TO_LAYOUT_TYPE[wrappingStyle];
if (oldDrawing.layoutType !== layoutType) {
rawActions.push(jsonX.replaceOp([...source.path, 'layoutType'], oldDrawing.layoutType, layoutType)!);
}
if (wrappingStyle === TextWrappingStyle.BEHIND_TEXT || wrappingStyle === TextWrappingStyle.IN_FRONT_OF_TEXT) {
const behindDoc = wrappingStyle === TextWrappingStyle.BEHIND_TEXT ? BooleanNumber.TRUE : BooleanNumber.FALSE;
if (oldDrawing.behindDoc !== behindDoc) {
rawActions.push(jsonX.replaceOp([...source.path, 'behindDoc'], oldDrawing.behindDoc, behindDoc)!);
}
}
if (wrappingStyle !== TextWrappingStyle.INLINE) {
for (const key of ['positionH', 'positionV'] as const) {
const value = drawing.docTransform?.[key];
const oldValue = oldDrawing.docTransform[key];
if (value && !Tools.diffValue(oldValue, value)) {
rawActions.push(jsonX.replaceOp([...source.path, 'docTransform', key], oldValue, value)!);
}
}
}
}
const mutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
historyAction,
actions: rawActions.reduce(
(actions, action) => JSONX.compose(actions, action as JSONXActions),
null as JSONXActions
),
textRanges: null,
},
};
return Boolean(commandService.syncExecuteCommand(mutation.id, mutation.params));
},
};

View File

@@ -0,0 +1,24 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 DOCS_DRAWING_PLUGIN_CONFIG_KEY = 'docs-drawing.config';
export const configSymbol = Symbol(DOCS_DRAWING_PLUGIN_CONFIG_KEY);
export interface IUniverDocsDrawingConfig {
}
export const defaultPluginConfig: IUniverDocsDrawingConfig = {};

View File

@@ -0,0 +1,101 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocDrawing } from '../../services/doc-drawing.service';
import { BooleanNumber, DrawingTypeEnum, IResourceManagerService, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { IDocDrawingService } from '../../services/doc-drawing.service';
import { DocDrawingController, DOCS_DRAWING_PLUGIN } from '../doc-drawing.controller';
function drawing(drawingId: string): IDocDrawing {
return {
drawingId,
unitId: 'test-doc',
subUnitId: 'stale-import-unit',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_NONE,
docTransform: { size: { width: 320, height: 180 }, angle: 15, positionH: { relativeFrom: 0, posOffset: 12 }, positionV: { relativeFrom: 0, posOffset: 34 } },
};
}
describe('DocDrawingController', () => {
it('serializes snapshots and loads and unloads resources through drawing services', () => {
const bed = createFacadeTestBed();
try {
const model = bed.documentDataModel;
model.resetDrawing({ d1: drawing('d1') }, ['d1']);
const resources = bed.injector.get(IResourceManagerService);
const hook = resources.getAllResourceHooks().find((hook) => hook.pluginName === DOCS_DRAWING_PLUGIN)!;
expect(hook.businesses).toEqual([UniverInstanceType.UNIVER_DOC]);
expect(JSON.parse(hook.toJson('test-doc'))).toEqual({ data: model.getDrawings(), order: ['d1'] });
expect(hook.parseJson('')).toEqual({ data: {}, order: [] });
expect(hook.parseJson('{bad json')).toEqual({ data: {}, order: [] });
hook.onLoad('test-doc', {
data: {
mask: { ...drawing('mask'), behindDoc: BooleanNumber.FALSE },
photo: { ...drawing('photo'), behindDoc: BooleanNumber.TRUE },
d2: drawing('d2'),
},
order: ['mask', 'photo', 'd2'],
});
const manager = bed.injector.get(IDrawingManagerService);
const docDrawings = bed.injector.get(IDocDrawingService);
expect(manager.getDrawingOrder('test-doc', 'test-doc')).toEqual(['photo', 'mask', 'd2']);
expect(docDrawings.getDrawingOrder('test-doc', 'test-doc')).toEqual(['mask', 'photo', 'd2']);
expect(manager.getDrawingData('test-doc', 'test-doc').d2).toMatchObject({
hidden: true,
docTransform: drawing('d2').docTransform,
});
expect(manager.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('transform');
expect(docDrawings.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('hidden');
hook.onUnLoad('test-doc');
expect(model.getDrawings()).toEqual({});
expect(model.getDrawingsOrder()).toEqual([]);
expect(manager.getDrawingData('test-doc', 'test-doc')).toEqual({});
expect(docDrawings.getDrawingData('test-doc', 'test-doc')).toEqual({});
} finally {
bed.univer.dispose();
}
});
it('normalizes imported body and footnote drawing identities without flattening persisted resources', () => {
const bed = createFacadeTestBed({
id: 'test-doc',
documentStyle: {},
body: { dataStream: '\r\n' },
drawings: { image: drawing('image') },
drawingsOrder: ['image'],
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\b\r\n' }, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] } },
});
try {
const controller = bed.injector.get(DocDrawingController);
expect(controller.loadDrawingDataForUnit('test-doc')).toBe(true);
for (const service of [bed.injector.get(IDocDrawingService), bed.injector.get(IDrawingManagerService)]) {
expect(service.getDrawingOrder('test-doc', 'test-doc')).toEqual(['image', 'note-image']);
expect(service.getDrawingData('test-doc', 'test-doc')).toMatchObject({
image: { unitId: 'test-doc', subUnitId: 'test-doc' },
'note-image': { unitId: 'test-doc', subUnitId: 'test-doc' },
});
}
expect(bed.documentDataModel.getDrawings()).not.toHaveProperty('note-image');
expect(bed.documentDataModel.getSnapshot().notes?.note.drawings?.['note-image'].subUnitId).toBe('stale-import-unit');
} finally {
bed.univer.dispose();
}
});
});

View File

@@ -0,0 +1,193 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IDrawingMapItem, IDrawingMapItemData } from '@univerjs/drawing';
import type { IDocDrawing } from '../services/doc-drawing.service';
import { BooleanNumber, Disposable, ICommandService, IResourceManagerService, IUniverInstanceService, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { InsertDocDrawingCommand } from '../commands/commands/insert-doc-drawing.command';
import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing.command';
import { SetDocDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
import { UpdateDrawingDocTransformCommand } from '../commands/commands/update-doc-drawing-transform.command';
import { UpdateDocDrawingWrappingStyleCommand } from '../commands/commands/update-doc-drawing-wrapping-style.command';
import { collectDocDrawings } from '../services/doc-drawing-source';
import { IDocDrawingService } from '../services/doc-drawing.service';
export const DOCS_DRAWING_PLUGIN = 'DOC_DRAWING_PLUGIN';
export interface IDocDrawingModel {
drawings?: IDocumentData['drawings'];
drawingsOrder?: IDocumentData['drawingsOrder'];
};
export function getDocDrawingRenderOrder(order: string[], drawings: IDocumentData['drawings'] = {}): string[] {
return order
.map((drawingId, index) => ({ drawingId, index }))
.sort((a, b) => {
const aBehind = isDocDrawingBehindText(drawings[a.drawingId]);
const bBehind = isDocDrawingBehindText(drawings[b.drawingId]);
if (aBehind !== bBehind) {
return aBehind ? -1 : 1;
}
return a.index - b.index;
})
.map(({ drawingId }) => drawingId);
}
function isDocDrawingBehindText(drawing: NonNullable<IDocumentData['drawings']>[string] | undefined): boolean {
return drawing?.layoutType === PositionedObjectLayoutType.WRAP_NONE && drawing.behindDoc === BooleanNumber.TRUE;
}
export class DocDrawingController extends Disposable {
constructor(
@IDocDrawingService private readonly _docDrawingService: IDocDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IResourceManagerService private _resourceManagerService: IResourceManagerService,
@IUniverInstanceService private _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._init();
}
private _init(): void {
this._initSnapshot();
this._initCommands();
}
private _initSnapshot() {
const toJson = (unitId: string) => {
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (doc) {
const drawings = doc.getSnapshot().drawings;
const drawingOrder = doc.getSnapshot().drawingsOrder;
const data: IDrawingMapItem<IDocDrawing> = {
data: drawings ?? {},
order: drawingOrder ?? [],
};
return JSON.stringify(data);
}
return '';
};
const parseJson = (json: string): IDrawingMapItem<IDocDrawing> => {
if (!json) {
return { data: {}, order: [] };
}
try {
return JSON.parse(json);
} catch {
return { data: {}, order: [] };
}
};
this.disposeWithMe(
this._resourceManagerService.registerPluginResource<IDrawingMapItem<IDocDrawing>>({
pluginName: DOCS_DRAWING_PLUGIN,
businesses: [UniverInstanceType.UNIVER_DOC],
toJson: (unitId) => toJson(unitId),
parseJson: (json) => parseJson(json),
onUnLoad: (unitId) => {
this._unloadDrawingDataForUnit(unitId);
},
onLoad: (unitId, value) => {
this._setDrawingDataForUnit(unitId, { data: value.data ?? {}, order: value.order ?? [] });
},
})
);
}
private _unloadDrawingDataForUnit(unitId: string): void {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
unitId,
UniverInstanceType.UNIVER_DOC
);
documentDataModel?.resetDrawing({}, []);
this._docDrawingService.removeDrawingDataForUnit(unitId);
this._drawingManagerService.removeDrawingDataForUnit(unitId);
}
private _setDrawingDataForUnit(unitId: string, drawingMapItem: IDrawingMapItem<IDocDrawing>) {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId);
if (documentDataModel == null) {
return;
}
documentDataModel.resetDrawing(drawingMapItem.data, drawingMapItem.order);
this.loadDrawingDataForUnit(unitId);
}
loadDrawingDataForUnit(unitId: string): boolean {
const dataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!dataModel) {
return false;
}
const subUnitId = unitId;
const { drawings: drawingDataModels, drawingsOrder: drawingOrderModel } = collectDocDrawings(dataModel.getSnapshot());
if (!drawingDataModels || !drawingOrderModel) {
return false;
}
// TODO@wzhudev: should move to docs-drawing.
const normalizedDrawingData: IDrawingMapItemData<IDocDrawing> = {};
for (const [drawingId, drawing] of Object.entries(drawingDataModels)) {
normalizedDrawingData[drawingId] = drawing.unitId === unitId && drawing.subUnitId === subUnitId
? drawing
: { ...drawing, unitId, subUnitId };
}
const subDrawings = {
[subUnitId]: {
unitId,
subUnitId,
data: normalizedDrawingData,
order: drawingOrderModel,
},
};
const renderDrawingData: IDrawingMapItemData<IDocDrawing> = {};
for (const [drawingId, drawing] of Object.entries(normalizedDrawingData)) {
renderDrawingData[drawingId] = { ...drawing, hidden: true };
}
const renderSubDrawings = {
[subUnitId]: {
...subDrawings[subUnitId],
data: renderDrawingData,
order: getDocDrawingRenderOrder(drawingOrderModel, drawingDataModels),
},
};
this._docDrawingService.registerDrawingData(unitId, subDrawings);
this._drawingManagerService.registerDrawingData(unitId, renderSubDrawings);
return true;
}
private _initCommands() {
[
InsertDocDrawingCommand,
RemoveDocDrawingCommand,
UpdateDrawingDocTransformCommand,
UpdateDocDrawingWrappingStyleCommand,
SetDocDrawingArrangeCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import {
DocumentFlavor,
ILogService,
IUniverInstanceService,
LogLevel,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { UniverDocsDrawingPlugin } from '../../plugin';
import '../index';
const DEFAULT_DOC_DATA: IDocumentData = {
id: 'test-doc',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: 'Hello world\r\n',
paragraphs: [{ startIndex: 11, paragraphId: 'paragraph-1' }],
sectionBreaks: [],
customBlocks: [],
},
drawings: {},
drawingsOrder: [],
};
export function createFacadeTestBed(data: IDocumentData = DEFAULT_DOC_DATA) {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
univer.registerPlugin(UniverDocsPlugin);
univer.registerPlugin(UniverDrawingPlugin);
univer.registerPlugin(UniverDocsDrawingPlugin);
const documentDataModel = univer.createUnit<IDocumentData, DocumentDataModel>(
UniverInstanceType.UNIVER_DOC,
structuredClone(data)
);
injector.get(IUniverInstanceService).focusUnit(documentDataModel.getUnitId());
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
injector,
documentDataModel,
document: FUniver.newAPI(injector).getActiveDocument()!,
};
}

View File

@@ -0,0 +1,530 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { IDocumentData, IObjectPositionH, IObjectPositionV } from '@univerjs/core';
import {
ArrangeTypeEnum,
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
DocumentFlavor,
ICommandService,
ImageSourceType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { FEnum } from '@univerjs/core/facade';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IDocDrawingService, TextWrappingStyle, UpdateDocDrawingWrappingStyleCommand } from '@univerjs/docs-drawing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoveDocDrawingCommand } from '../../commands/commands/remove-doc-drawing.command';
import { SetDocDrawingArrangeCommand } from '../../commands/commands/set-drawing-arrange.command';
import { UpdateDrawingDocTransformCommand } from '../../commands/commands/update-doc-drawing-transform.command';
import { createFacadeTestBed } from './create-test-bed';
class MockImage {
width = 800;
height = 400;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
get src(): string {
return '';
}
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
describe('FDocument image facade', () => {
let testBed: ReturnType<typeof createFacadeTestBed>;
beforeEach(() => {
vi.stubGlobal('Image', MockImage);
testBed = createFacadeTestBed();
});
afterEach(() => {
testBed.univer.dispose();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('exposes TextWrappingStyle under its Facade signature name', () => {
expect(FEnum.get().TextWrappingStyle).toBe(TextWrappingStyle);
expect(FEnum.get()).not.toHaveProperty('DocsImageWrappingStyle');
});
it('inserts an image with optional transform and text range options', async () => {
const positionH: IObjectPositionH = {
relativeFrom: ObjectRelativeFromH.MARGIN,
posOffset: 12,
};
const positionV: IObjectPositionV = {
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 34,
};
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 200,
angle: 15,
positionH,
positionV,
textRange: {
startOffset: 5,
endOffset: 5,
collapsed: true,
segmentId: '',
},
});
expect(image).not.toBeNull();
expect(image?.getSource()).toBe('data:image/png;base64,image');
expect(image?.getSourceType()).toBe(ImageSourceType.BASE64);
expect(image?.getSize()).toEqual({ width: 200, height: 100 });
expect(image?.getAngle()).toBe(15);
expect(image?.getPositionH()).toEqual(positionH);
expect(image?.getPositionV()).toEqual(positionV);
expect(image?.getImageData()).toMatchObject({
drawingId: image?.getId(),
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
});
expect(testBed.document.save().body?.dataStream).toBe('Hello\b world\r\n');
expect(testBed.document.getImage(image!.getId())).not.toBeNull();
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image!.getId()]);
});
it('inserts, resizes and removes an inline footnote image without changing body resources', async () => {
testBed.univer.dispose();
testBed = createFacadeTestBed({
id: 'test-doc',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: 'A\uFFFC\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'p' }],
sectionBreaks: [{ startIndex: 3, sectionId: 's' }],
customRanges: [{
rangeId: 'reference',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 1,
endIndex: 1,
wholeEntity: true,
properties: { noteId: 'note' },
}],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'Note\r\n',
paragraphs: [{ startIndex: 4, paragraphId: 'np' }],
} } },
});
const before = testBed.document.save();
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 20,
height: 10,
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
textRange: { startOffset: 4, endOffset: 4, collapsed: true, segmentId: 'note' },
});
if (!image) {
throw new Error('Expected footnote image insertion');
}
expect(image.getImageData()?.layoutType).toBe(PositionedObjectLayoutType.INLINE);
expect(testBed.document.getImage(image.getId())?.getSource()).toBe('data:image/png;base64,image');
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image.getId()]);
expect(image.setSize(30, 15)).toBe(true);
expect(image.getSize()).toEqual({ width: 30, height: 15 });
expect(image.setWrappingStyle(TextWrappingStyle.WRAP_SQUARE)).toBe(false);
const inserted = testBed.document.save();
expect(inserted.notes?.note.body.dataStream).toBe('Note\b\r\n');
expect(inserted.notes?.note.drawingsOrder).toEqual([image.getId()]);
expect(inserted.drawings).toEqual(before.drawings);
expect(inserted.body).toEqual(before.body);
expect(image.remove()).toBe(true);
expect(testBed.document.save().notes?.note.body.dataStream).toBe('Note\r\n');
expect(testBed.document.save().notes?.note.drawingsOrder).toEqual([]);
expect(testBed.document.getImage(image.getId())).toBeNull();
expect(testBed.document.undo()).toBe(true);
expect(testBed.document.save().notes?.note).toEqual(inserted.notes?.note);
});
it('resolves the insertion range only once', async () => {
const selectionManager = testBed.injector.get(DocSelectionManagerService);
const getActiveTextRange = vi.spyOn(selectionManager, 'getActiveTextRange');
await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
textRange: {
startOffset: 0,
endOffset: 0,
collapsed: true,
segmentId: '',
},
});
expect(getActiveTextRange).toHaveBeenCalledTimes(1);
});
it('uses a capped intrinsic size when width and height are omitted', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
textRange: {
startOffset: 0,
endOffset: 0,
collapsed: true,
segmentId: '',
},
});
expect(image?.getSize()).toEqual({ width: 500, height: 250 });
});
it('uses explicit dimensions without loading the image source', async () => {
vi.stubGlobal('Image', undefined);
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 0,
endOffset: 0,
collapsed: true,
segmentId: '',
},
});
expect(image?.getSize()).toEqual({ width: 160, height: 90 });
});
it('creates a renderable image paragraph when the range points to the document structural tail', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 12,
endOffset: 12,
collapsed: true,
segmentId: '',
},
});
const snapshot = testBed.document.save();
expect(image).not.toBeNull();
expect(snapshot.body?.dataStream).toBe('Hello world\r\b\r\n');
expect(snapshot.body?.customBlocks).toEqual([
{
startIndex: 12,
blockId: image?.getId(),
},
]);
expect(snapshot.body?.paragraphs).toEqual([
{ startIndex: 11, paragraphId: 'paragraph-1' },
expect.objectContaining({ startIndex: 13 }),
]);
});
it('anchors an image inside the current table-cell paragraph when the range points to the cell structural tail', async () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const documentData: IDocumentData = {
id: 'test-doc',
documentStyle: {},
body: {
dataStream: `${tableStream}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell-paragraph' },
{ startIndex: 12, paragraphId: 'body-paragraph' },
],
sectionBreaks: [
{ startIndex: 8, sectionId: 'cell-section' },
{ startIndex: 13, sectionId: 'body-section' },
],
customBlocks: [],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
},
drawings: {},
drawingsOrder: [],
};
testBed.univer.dispose();
testBed = createFacadeTestBed(documentData);
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 8,
endOffset: 8,
collapsed: true,
segmentId: '',
},
});
const snapshot = testBed.document.save();
const expectedDataStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`;
expect(image).not.toBeNull();
expect(snapshot.body?.dataStream).toBe(expectedDataStream);
expect(snapshot.body?.customBlocks).toEqual([{ startIndex: 7, blockId: image?.getId() }]);
expect(snapshot.body?.paragraphs).toEqual([
{ startIndex: 8, paragraphId: 'cell-paragraph' },
{ startIndex: 13, paragraphId: 'body-paragraph' },
]);
expect(testBed.document.undo()).toBe(true);
expect(testBed.document.save().body?.dataStream).toBe(documentData.body?.dataStream);
expect(testBed.document.save().drawingsOrder).toEqual([]);
expect(testBed.document.redo()).toBe(true);
expect(testBed.document.save().body?.dataStream).toBe(expectedDataStream);
expect(testBed.document.save().drawingsOrder).toEqual([image?.getId()]);
});
it('inserts an image with a non-inline wrapping style', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
wrappingStyle: TextWrappingStyle.BEHIND_TEXT,
textRange: {
startOffset: 2,
endOffset: 2,
collapsed: true,
segmentId: '',
},
});
expect(image?.getImageData()).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
});
});
it('inserts a header image with multi-page transforms', async () => {
const segmentId = testBed.document.ensurePageHeader();
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 0,
endOffset: 0,
collapsed: true,
segmentId,
},
});
const imageData = image?.getImageData();
expect(testBed.document.save().headers?.[segmentId].body.dataStream).toBe('\r\b\r\n');
expect(imageData?.isMultiTransform).toBe(BooleanNumber.TRUE);
expect(imageData?.transforms).toEqual(imageData?.transform ? [imageData.transform] : null);
});
it('arranges images from the document model when the headless drawing projection is stale', async () => {
const firstImage = await testBed.document.insertImage({
source: 'data:image/png;base64,first-image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 1,
endOffset: 1,
collapsed: true,
segmentId: '',
},
});
const secondImage = await testBed.document.insertImage({
source: 'data:image/png;base64,second-image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
});
const thirdImage = await testBed.document.insertImage({
source: 'data:image/png;base64,third-image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 5,
endOffset: 5,
collapsed: true,
segmentId: '',
},
});
expect(firstImage).not.toBeNull();
expect(secondImage).not.toBeNull();
expect(thirdImage).not.toBeNull();
if (!firstImage || !secondImage || !thirdImage) {
throw new Error('Expected all document images to be inserted');
}
const firstImageId = firstImage.getId();
const secondImageId = secondImage.getId();
const thirdImageId = thirdImage.getId();
const docDrawingService = testBed.injector.get(IDocDrawingService);
expect(docDrawingService.getDrawingOrder('test-doc', 'test-doc')).toEqual([]);
expect(testBed.document.save().drawingsOrder).toEqual([firstImageId, secondImageId, thirdImageId]);
expect(secondImage.setFront()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([firstImageId, thirdImageId, secondImageId]);
expect(secondImage.setBackward()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([firstImageId, secondImageId, thirdImageId]);
expect(firstImage.setForward()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([secondImageId, firstImageId, thirdImageId]);
expect(thirdImage.setBack()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([thirdImageId, secondImageId, firstImageId]);
expect(testBed.document.undo()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([secondImageId, firstImageId, thirdImageId]);
expect(testBed.document.redo()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([thirdImageId, secondImageId, firstImageId]);
expect(testBed.injector.get(ICommandService).syncExecuteCommand(SetDocDrawingArrangeCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingIds: [thirdImageId, secondImageId],
arrangeType: ArrangeTypeEnum.front,
})).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([firstImageId, thirdImageId, secondImageId]);
expect(testBed.document.undo()).toBe(true);
expect(testBed.document.save().drawingsOrder).toEqual([thirdImageId, secondImageId, firstImageId]);
});
it('routes image updates, arranging, and removal through docs-drawing commands', async () => {
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 160,
height: 90,
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
});
const commandService = testBed.injector.get(ICommandService);
const execute = vi.spyOn(commandService, 'syncExecuteCommand').mockReturnValue(true);
const positionH: IObjectPositionH = { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 20 };
const positionV: IObjectPositionV = { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 30 };
const drawingId = image!.getId();
expect(image!.setSize(320, 180)).toBe(true);
expect(image!.setRotate(25)).toBe(true);
expect(image!.setPositionH(positionH)).toBe(true);
expect(image!.setPositionV(positionV)).toBe(true);
expect(image!.setWrappingStyle(TextWrappingStyle.WRAP_SQUARE)).toBe(true);
expect(image!.setForward()).toBe(true);
expect(image!.setBackward()).toBe(true);
expect(image!.setBack()).toBe(true);
expect(image!.setFront()).toBe(true);
expect(image!.remove()).toBe(true);
expect(execute.mock.calls).toEqual([
[UpdateDrawingDocTransformCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId, key: 'size', value: { width: 320, height: 180 } }],
}],
[UpdateDrawingDocTransformCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId, key: 'angle', value: 25 }],
}],
[UpdateDrawingDocTransformCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId, key: 'positionH', value: positionH }],
}],
[UpdateDrawingDocTransformCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId, key: 'positionV', value: positionV }],
}],
[UpdateDocDrawingWrappingStyleCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [image!.getImageData()],
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
}],
[SetDocDrawingArrangeCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingIds: [drawingId],
arrangeType: ArrangeTypeEnum.forward,
}],
[SetDocDrawingArrangeCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingIds: [drawingId],
arrangeType: ArrangeTypeEnum.backward,
}],
[SetDocDrawingArrangeCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingIds: [drawingId],
arrangeType: ArrangeTypeEnum.back,
}],
[SetDocDrawingArrangeCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingIds: [drawingId],
arrangeType: ArrangeTypeEnum.front,
}],
[RemoveDocDrawingCommand.id, {
unitId: 'test-doc',
drawings: [{
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingId,
drawingType: 0,
}],
textRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
segmentId: '',
},
}],
]);
});
});

View File

@@ -0,0 +1,479 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { ImageSourceType, Injector, IObjectPositionH, IObjectPositionV, ISize, ITextRangeParam } from '@univerjs/core';
import type { IDocImage, IUpdateDocDrawingWrappingStyleParams, TextWrappingStyle } from '@univerjs/docs-drawing';
import type { FDocument } from '@univerjs/docs/facade';
import {
ArrangeTypeEnum,
DrawingTypeEnum,
ICommandService,
} from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import {
findDocDrawing,
RemoveDocDrawingCommand,
SetDocDrawingArrangeCommand,
UpdateDocDrawingWrappingStyleCommand,
UpdateDrawingDocTransformCommand,
} from '@univerjs/docs-drawing';
/**
* Facade API for an image in a document.
* @hideconstructor
*/
export class FDocumentImage extends FBase {
constructor(
private readonly _document: FDocument,
private readonly _imageId: string,
protected readonly _injector: Injector
) {
super();
}
/**
* Gets the id of the document containing the image.
* @returns {string} The document unit id.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getUnitId());
* }
* ```
*/
getUnitId(): string {
return this._document.getId();
}
/**
* Gets the drawing id of the image.
* @returns {string} The drawing id.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getId());
* }
* ```
*/
getId(): string {
return this._imageId;
}
/**
* Gets the image source supplied at insertion time.
* @returns {string | undefined} The image source, or `undefined` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getSource());
* }
* ```
*/
getSource(): string | undefined {
return this.getImageData()?.source;
}
/**
* Gets the image source type supplied at insertion time.
* @returns {ImageSourceType | undefined} The image source type, or `undefined` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getSourceType());
* }
* ```
*/
getSourceType(): ImageSourceType | undefined {
return this.getImageData()?.imageSourceType;
}
/**
* Gets the current image size.
* @returns {ISize | null} The width and height in pixels, or `null` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getSize());
* }
* ```
*/
getSize(): ISize | null {
return this.getImageData()?.docTransform.size ?? null;
}
/**
* Gets the current clockwise rotation angle.
* @returns {number | undefined} The rotation angle in degrees, or `undefined` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getAngle());
* }
* ```
*/
getAngle(): number | undefined {
return this.getImageData()?.docTransform.angle;
}
/**
* Gets the current horizontal position.
* @returns {IObjectPositionH | null} The horizontal position, or `null` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getPositionH());
* }
* ```
*/
getPositionH(): IObjectPositionH | null {
return this.getImageData()?.docTransform.positionH ?? null;
}
/**
* Gets the current vertical position.
* @returns {IObjectPositionV | null} The vertical position, or `null` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getPositionV());
* }
* ```
*/
getPositionV(): IObjectPositionV | null {
return this.getImageData()?.docTransform.positionV ?? null;
}
/**
* Gets the current raw document image data.
* @returns {IDocImage | null} The image data, or `null` when the image no longer exists.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* console.log(image.getImageData());
* }
* ```
*/
getImageData(): IDocImage | null {
const drawing = findDocDrawing(this._document.getDocumentDataModel().getSnapshot(), this._imageId)?.drawing;
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return null;
}
return drawing as IDocImage;
}
/**
* Sets the image size.
* @param {number} width The width in pixels.
* @param {number} height The height in pixels.
* @returns {boolean} `true` when the update command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setSize(400, 300);
* console.log(success);
* }
* ```
*/
setSize(width: number, height: number): boolean {
return this._updateTransform('size', { width, height });
}
/**
* Sets the clockwise rotation angle.
* @param {number} angle The rotation angle in degrees.
* @returns {boolean} `true` when the update command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setRotate(45);
* console.log(success);
* }
* ```
*/
setRotate(angle: number): boolean {
return this._updateTransform('angle', angle);
}
/**
* Sets the horizontal position of the image.
* Inline images are positioned by their document placeholder, so this has a visible effect only when the image
* wrapping style is not `TextWrappingStyle.INLINE`.
* @param {IObjectPositionH} positionH The horizontal position relative to the document.
* @returns {boolean} `true` when the update command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setPositionH({
* relativeFrom: univerAPI.Enum.DocsImageRelativeFromH.MARGIN,
* posOffset: 100
* });
* console.log(success);
* }
* ```
*/
setPositionH(positionH: IObjectPositionH): boolean {
return this._updateTransform('positionH', positionH);
}
/**
* Sets the vertical position of the image.
* Inline images are positioned by their document placeholder, so this has a visible effect only when the image
* wrapping style is not `TextWrappingStyle.INLINE`.
* @param {IObjectPositionV} positionV The vertical position relative to the document.
* @returns {boolean} `true` when the update command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setPositionV({
* relativeFrom: univerAPI.Enum.DocsImageRelativeFromV.MARGIN,
* posOffset: 100
* });
* console.log(success);
* }
* ```
*/
setPositionV(positionV: IObjectPositionV): boolean {
return this._updateTransform('positionV', positionV);
}
/**
* Sets the image wrapping style.
*
* Use `INLINE` to place the image in the text flow, `WRAP_SQUARE` to flow text beside it, or
* `WRAP_TOP_AND_BOTTOM` to keep text above and below it. `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` do not reserve space
* in the text layout and can overlap text.
*
* When switching from inline to a floating style in a UI environment, the current visual position is preserved.
* @param {TextWrappingStyle} wrappingStyle The wrapping style to apply.
* @returns {boolean} `true` when the update command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* // Float the image beside body text without covering it.
* const success = image.setWrappingStyle(univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE);
* console.log(success);
* }
* ```
*/
setWrappingStyle(wrappingStyle: TextWrappingStyle): boolean {
const image = this.getImageData();
if (!image) {
return false;
}
return this._injector.get(ICommandService).syncExecuteCommand<IUpdateDocDrawingWrappingStyleParams>(
UpdateDocDrawingWrappingStyleCommand.id,
{
unitId: this.getUnitId(),
subUnitId: this.getUnitId(),
drawings: [image],
wrappingStyle,
}
);
}
/**
* Moves the image forward by one level in the drawing order.
* @returns {boolean} `true` when the arrange command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setForward();
* console.log(success);
* }
* ```
*/
setForward(): boolean {
return this._arrange(ArrangeTypeEnum.forward);
}
/**
* Moves the image backward by one level in the drawing order.
* @returns {boolean} `true` when the arrange command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setBackward();
* console.log(success);
* }
* ```
*/
setBackward(): boolean {
return this._arrange(ArrangeTypeEnum.backward);
}
/**
* Moves the image to the back of the drawing order.
* @returns {boolean} `true` when the arrange command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setBack();
* console.log(success);
* }
* ```
*/
setBack(): boolean {
return this._arrange(ArrangeTypeEnum.back);
}
/**
* Moves the image to the front of the drawing order.
* @returns {boolean} `true` when the arrange command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setFront();
* console.log(success);
* }
* ```
*/
setFront(): boolean {
return this._arrange(ArrangeTypeEnum.front);
}
/**
* Removes the image and its document placeholder.
* @returns {boolean} `true` when the remove command succeeds; otherwise, `false`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.remove();
* console.log(success);
* }
* ```
*/
remove(): boolean {
const image = this.getImageData();
const textRange = this._getTextRange();
if (!image || !textRange) {
return false;
}
return this._injector.get(ICommandService).syncExecuteCommand(RemoveDocDrawingCommand.id, {
unitId: this.getUnitId(),
drawings: [{
unitId: this.getUnitId(),
subUnitId: this.getUnitId(),
drawingId: this._imageId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
}],
textRange,
});
}
private _updateTransform(
key: 'size' | 'angle' | 'positionH' | 'positionV',
value: ISize | number | IObjectPositionH | IObjectPositionV
): boolean {
return this._injector.get(ICommandService).syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId: this.getUnitId(),
subUnitId: this.getUnitId(),
drawings: [{ drawingId: this._imageId, key, value }],
});
}
private _arrange(arrangeType: ArrangeTypeEnum): boolean {
return this._injector.get(ICommandService).syncExecuteCommand(SetDocDrawingArrangeCommand.id, {
unitId: this.getUnitId(),
subUnitId: this.getUnitId(),
drawingIds: [this._imageId],
arrangeType,
});
}
private _getTextRange(): ITextRangeParam | null {
const snapshot = this._document.getDocumentDataModel().getSnapshot();
const { body, headers = {}, footers = {}, notes = {} } = snapshot;
const segments = [
{ segmentId: '', body },
...Object.entries(headers).map(([segmentId, header]) => ({ segmentId, body: header.body })),
...Object.entries(footers).map(([segmentId, footer]) => ({ segmentId, body: footer.body })),
...Object.entries(notes).map(([segmentId, note]) => ({ segmentId, body: note.body })),
];
for (const { segmentId, body } of segments) {
const customBlock = body?.customBlocks?.find((block) => block.blockId === this._imageId);
if (customBlock) {
return {
startOffset: customBlock.startIndex,
endOffset: customBlock.startIndex,
collapsed: true,
segmentId,
};
}
}
return null;
}
}

View File

@@ -0,0 +1,264 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { IObjectPositionH, IObjectPositionV, ISize, ITextRangeParam } from '@univerjs/core';
import type { IDocImage } from '@univerjs/docs-drawing';
import {
BooleanNumber,
DrawingTypeEnum,
generateRandomId,
ICommandService,
IImageIoService,
ImageSourceType,
IURLImageService,
WrapTextType,
} from '@univerjs/core';
import { buildDocTransform, docDrawingPositionToTransform } from '@univerjs/docs';
import {
collectDocDrawings,
findDocDrawing,
InsertDocDrawingCommand,
TextWrappingStyle,
WRAPPING_STYLE_TO_LAYOUT_TYPE,
} from '@univerjs/docs-drawing';
import { FDocument } from '@univerjs/docs/facade';
import { DRAWING_IMAGE_HEIGHT_LIMIT, DRAWING_IMAGE_WIDTH_LIMIT, getImageSize } from '@univerjs/drawing';
import { FDocumentImage } from './f-document-image';
/** Options for inserting an image into a document. */
export interface IFDocumentInsertImageOptions {
/** The image source. It cannot be changed after insertion. */
source: string;
/** The image source type. It cannot be changed after insertion. */
imageSourceType: ImageSourceType;
/** The width in pixels. When only width is provided, height is calculated from the intrinsic aspect ratio. */
width?: number;
/** The height in pixels. When only height is provided, width is calculated from the intrinsic aspect ratio. */
height?: number;
/** The clockwise rotation angle in degrees. Defaults to `0`. */
angle?: number;
/**
* The horizontal position relative to the document. Defaults to the page with an offset of `0`.
* It has a visible positioning effect only when `wrappingStyle` is not `INLINE`.
*/
positionH?: IObjectPositionH;
/**
* The vertical position relative to the document. Defaults to the paragraph with an offset of `0`.
* It has a visible positioning effect only when `wrappingStyle` is not `INLINE`.
*/
positionV?: IObjectPositionV;
/**
* The image wrapping style. Defaults to `TextWrappingStyle.INLINE`.
*
* Use `INLINE` to place the image in the text flow, `WRAP_SQUARE` to flow text beside it, or
* `WRAP_TOP_AND_BOTTOM` to keep text above and below it. `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` are overlay styles:
* they do not reserve space in the text layout, so text and the image can overlap.
*/
wrappingStyle?: TextWrappingStyle;
/** The document range at which to insert the image. The current selection is used when omitted. */
textRange?: ITextRangeParam;
}
/** Image facade methods mixed into `FDocument`. */
export interface IFDocumentImageMixin {
/**
* Inserts an image into the document.
*
* When width and height are both omitted, the intrinsic size is proportionally limited to 500 by 500 pixels.
* For ordinary content, prefer `INLINE`, `WRAP_SQUARE`, or `WRAP_TOP_AND_BOTTOM`. Use `BEHIND_TEXT` for
* backgrounds or watermarks and `IN_FRONT_OF_TEXT` only for intentional overlays, because these two styles do not
* cause text to reflow.
*
* @param {IFDocumentInsertImageOptions} options The image source, optional transform, and insertion range.
* @returns {Promise<FDocumentImage | null>} The inserted image facade, or `null` when the insertion command fails.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = await fDocument.insertImage({
* source: 'https://avatars.githubusercontent.com/u/61444807?s=48&v=4',
* imageSourceType: univerAPI.Enum.ImageSourceType.URL,
* width: 320,
* // Keep the image in the text flow so it cannot cover surrounding text.
* wrappingStyle: univerAPI.Enum.TextWrappingStyle.INLINE,
* textRange: {
* startOffset: 30,
* },
* });
* console.log(image);
* ```
*
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = await fDocument.insertImage({
* source: 'https://avatars.githubusercontent.com/u/61444807?s=48&v=4',
* imageSourceType: univerAPI.Enum.ImageSourceType.URL,
* width: 320,
* // Float the image and let body text flow beside its rectangular bounds.
* wrappingStyle: univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE,
* textRange: {
* startOffset: 30,
* },
* });
* console.log(image);
* ```
*/
insertImage(options: IFDocumentInsertImageOptions): Promise<FDocumentImage | null>;
/**
* Gets an image by its drawing id.
* @param {string} imageId The drawing id of the image.
* @returns {FDocumentImage | null} The image facade, or `null` when the image does not exist.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImage('image-1');
* console.log(image);
* ```
*/
getImage(imageId: string): FDocumentImage | null;
/**
* Gets all images in the document in drawing order.
* @returns {FDocumentImage[]} The image facades in drawing order.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const images = fDocument.getImages();
* console.log(images);
* ```
*/
getImages(): FDocumentImage[];
}
export class FDocumentImageMixin extends FDocument implements IFDocumentImageMixin {
override async insertImage(options: IFDocumentInsertImageOptions): Promise<FDocumentImage | null> {
const unitId = this.getId();
const imageId = generateRandomId(6);
const size = options.width != null && options.height != null
? { width: options.width, height: options.height }
: resolveImageSize(
await this._getIntrinsicSize(options.source, options.imageSourceType),
options
);
const defaultTransform = buildDocTransform(size.width, size.height);
const wrappingStyle = options.wrappingStyle ?? TextWrappingStyle.INLINE;
const docTransform = {
...defaultTransform,
angle: options.angle ?? defaultTransform.angle,
positionH: options.positionH ?? defaultTransform.positionH,
positionV: options.positionV ?? defaultTransform.positionV,
};
const transform = docDrawingPositionToTransform(docTransform);
const drawing: IDocImage = {
unitId,
subUnitId: unitId,
drawingId: imageId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: options.imageSourceType,
source: options.source,
transform,
docTransform,
behindDoc: wrappingStyle === TextWrappingStyle.BEHIND_TEXT ? BooleanNumber.TRUE : BooleanNumber.FALSE,
title: '',
description: '',
layoutType: WRAPPING_STYLE_TO_LAYOUT_TYPE[wrappingStyle],
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
};
const inserted = this._injector.get(ICommandService).syncExecuteCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: [drawing],
textRange: options.textRange,
});
if (!inserted) {
return null;
}
return this._injector.createInstance(FDocumentImage, this, imageId, this._injector);
}
override getImage(imageId: string): FDocumentImage | null {
const drawing = findDocDrawing(this.getDocumentDataModel().getSnapshot(), imageId)?.drawing;
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return null;
}
return this._injector.createInstance(FDocumentImage, this, imageId, this._injector);
}
override getImages(): FDocumentImage[] {
const documentDataModel = this.getDocumentDataModel();
const { drawings, drawingsOrder: drawingIds } = collectDocDrawings(documentDataModel.getSnapshot());
return drawingIds
.filter((drawingId) => drawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE)
.map((drawingId) => this._injector.createInstance(FDocumentImage, this, drawingId, this._injector));
}
private async _getIntrinsicSize(source: string, imageSourceType: ImageSourceType): Promise<Required<ISize>> {
const imageIoService = this._injector.has(IImageIoService) ? this._injector.get(IImageIoService) : null;
let resolvedSource = source;
if (imageSourceType === ImageSourceType.UUID && imageIoService) {
resolvedSource = await imageIoService.getImage(source);
} else if (imageSourceType === ImageSourceType.URL && this._injector.has(IURLImageService)) {
try {
resolvedSource = await this._injector.get(IURLImageService).getImage(source);
} catch {
resolvedSource = source;
}
}
const { width, height, image } = await getImageSize(resolvedSource);
imageIoService?.addImageSourceCache(source, imageSourceType, image);
return { width, height };
}
}
function resolveImageSize(
intrinsicSize: Required<ISize>,
options: Pick<IFDocumentInsertImageOptions, 'width' | 'height'>
): Required<ISize> {
const { width: intrinsicWidth, height: intrinsicHeight } = intrinsicSize;
if (options.width != null && options.height != null) {
return { width: options.width, height: options.height };
}
if (options.width != null) {
return { width: options.width, height: intrinsicHeight * options.width / intrinsicWidth };
}
if (options.height != null) {
return { width: intrinsicWidth * options.height / intrinsicHeight, height: options.height };
}
const scale = Math.min(
1,
DRAWING_IMAGE_WIDTH_LIMIT / intrinsicWidth,
DRAWING_IMAGE_HEIGHT_LIMIT / intrinsicHeight
);
return { width: intrinsicWidth * scale, height: intrinsicHeight * scale };
}
FDocument.extend(FDocumentImageMixin);
declare module '@univerjs/docs/facade' {
// eslint-disable-next-line ts/naming-convention
interface FDocument extends IFDocumentImageMixin {}
}

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { ObjectRelativeFromH, ObjectRelativeFromV } from '@univerjs/core';
import { FEnum } from '@univerjs/core/facade';
import { TextWrappingStyle } from '@univerjs/docs-drawing';
/**
* @ignore
*/
export interface IFDocumentImageEnumMixin {
/**
* Represents the text wrapping styles used by document drawings, including images and Shapes.
*
* Prefer `INLINE`, `WRAP_SQUARE`, or `WRAP_TOP_AND_BOTTOM` for ordinary document content.
* `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` are overlay styles that do not cause text to reflow.
* Use this property whenever a Facade signature references `TextWrappingStyle`.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = await fDocument.insertImage({
* source: 'https://avatars.githubusercontent.com/u/61444807?s=48&v=4',
* imageSourceType: univerAPI.Enum.ImageSourceType.URL,
* width: 320,
* // Float the image and wrap body text around its rectangular bounds.
* wrappingStyle: univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE,
* textRange: {
* startOffset: 30,
* },
* });
* console.log(image);
* ```
*/
TextWrappingStyle: typeof TextWrappingStyle;
/**
* Represents the horizontal position types used by docs image operations.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setPositionH({
* relativeFrom: univerAPI.Enum.DocsImageRelativeFromH.MARGIN,
* posOffset: 100
* });
* console.log(success);
* }
* ```
*/
DocsImageRelativeFromH: typeof ObjectRelativeFromH;
/**
* Represents the vertical position types used by docs image operations.
* @example
* ```ts
* const fDocument = univerAPI.getActiveDocument();
* const image = fDocument.getImages()[0];
*
* if (image) {
* const success = image.setPositionV({
* relativeFrom: univerAPI.Enum.DocsImageRelativeFromV.MARGIN,
* posOffset: 100
* });
* console.log(success);
* }
* ```
*/
DocsImageRelativeFromV: typeof ObjectRelativeFromV;
}
export class FDocumentImageEnumMixin extends FEnum implements IFDocumentImageEnumMixin {
override get TextWrappingStyle(): typeof TextWrappingStyle {
return TextWrappingStyle;
}
override get DocsImageRelativeFromH(): typeof ObjectRelativeFromH {
return ObjectRelativeFromH;
}
override get DocsImageRelativeFromV(): typeof ObjectRelativeFromV {
return ObjectRelativeFromV;
}
}
FEnum.extend(FDocumentImageEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEnum extends IFDocumentImageEnumMixin {}
}

View File

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

View File

@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { InsertDocDrawingCommand } from './commands/commands/insert-doc-drawing.command';
export type { IInsertDocDrawingCommandParams } from './commands/commands/insert-doc-drawing.command';
export { RemoveDocDrawingCommand } from './commands/commands/remove-doc-drawing.command';
export type { IRemoveDocDrawingCommandParam, IRemoveDocDrawingCommandParams } from './commands/commands/remove-doc-drawing.command';
export { SetDocDrawingArrangeCommand } from './commands/commands/set-drawing-arrange.command';
export type { ISetDocDrawingArrangeCommandParams } from './commands/commands/set-drawing-arrange.command';
export { UpdateDrawingDocTransformCommand } from './commands/commands/update-doc-drawing-transform.command';
export type { IDrawingDocTransform, IUpdateDrawingDocTransformCommandParams } from './commands/commands/update-doc-drawing-transform.command';
export { TextWrappingStyle, UpdateDocDrawingWrappingStyleCommand, WRAPPING_STYLE_TO_LAYOUT_TYPE } from './commands/commands/update-doc-drawing-wrapping-style.command';
export type { IUpdateDocDrawingWrappingStyleParams } from './commands/commands/update-doc-drawing-wrapping-style.command';
export type { IUniverDocsDrawingConfig } from './config/config';
export { DOCS_DRAWING_PLUGIN, getDocDrawingRenderOrder } from './controllers/doc-drawing.controller';
export type { IDocDrawingModel } from './controllers/doc-drawing.controller';
export { DocDrawingController } from './controllers/doc-drawing.controller';
export { UniverDocsDrawingPlugin } from './plugin';
export { DocDrawingAdapterService, IDocDrawingAdapterService } from './services/doc-drawing-adapter.service';
export type {
IDocDrawingAdapter,
IDocDrawingMutationInfos,
IDocDrawingRemoveMutationInfoParams,
} from './services/doc-drawing-adapter.service';
export { collectDocDrawings, findDocDrawing } from './services/doc-drawing-source';
export { DocDrawingService, IDocDrawingService } from './services/doc-drawing.service';
export type { IDocDrawing, IDocImage } from './services/doc-drawing.service';
export { type IDocFloatDom } from './services/doc-drawing.service';

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsDrawingConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, touchDependencies, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_DRAWING_PLUGIN_CONFIG_KEY } from './config/config';
import { DocDrawingController, DOCS_DRAWING_PLUGIN } from './controllers/doc-drawing.controller';
import { DocDrawingAdapterService, IDocDrawingAdapterService } from './services/doc-drawing-adapter.service';
import { DocDrawingService, IDocDrawingService } from './services/doc-drawing.service';
@DependentOn(UniverDocsPlugin, UniverDrawingPlugin)
export class UniverDocsDrawingPlugin extends Plugin {
static override pluginName = DOCS_DRAWING_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsDrawingConfig> = defaultPluginConfig,
@Inject(Injector) override _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(DOCS_DRAWING_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[DocDrawingController],
[DocDrawingAdapterService],
[IDocDrawingAdapterService, { useClass: DocDrawingAdapterService }],
[DocDrawingService],
[IDocDrawingService, { useClass: DocDrawingService }],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
touchDependencies(this._injector, [
[DocDrawingController],
]);
}
}

View File

@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { DocDrawingAdapterService } from '../doc-drawing-adapter.service';
describe('DocDrawingAdapterService', () => {
it('collects removal mutations from registered drawing adapters in order', () => {
const service = new DocDrawingAdapterService();
service.registerAdapter({
getRemoveDrawingMutationInfos: () => ({
redoMutations: [{ id: 'redo-1', params: { value: 1 } }],
undoMutations: [{ id: 'undo-1', params: { value: 1 } }],
}),
});
service.registerAdapter({
getRemoveDrawingMutationInfos: () => ({
redoMutations: [{ id: 'redo-2', params: { value: 2 } }],
undoMutations: [{ id: 'undo-2', params: { value: 2 } }],
}),
});
expect(service.getRemoveDrawingMutationInfos({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawing: { drawingId: 'drawing-1' } as never,
removeDrawings: [{ drawingId: 'drawing-1' } as never],
})).toEqual({
redoMutations: [
{ id: 'redo-1', params: { value: 1 } },
{ id: 'redo-2', params: { value: 2 } },
],
undoMutations: [
{ id: 'undo-1', params: { value: 1 } },
{ id: 'undo-2', params: { value: 2 } },
],
});
});
it('unregisters disposed adapters', () => {
const service = new DocDrawingAdapterService();
const disposable = service.registerAdapter({
getRemoveDrawingMutationInfos: () => ({
redoMutations: [{ id: 'redo-1', params: {} }],
undoMutations: [{ id: 'undo-1', params: {} }],
}),
});
disposable.dispose();
expect(service.getRemoveDrawingMutationInfos({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawing: { drawingId: 'drawing-1' } as never,
removeDrawings: [{ drawingId: 'drawing-1' } as never],
})).toEqual({
redoMutations: [],
undoMutations: [],
});
});
});

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { DocDrawingService, IDocDrawingService } from '../doc-drawing.service';
describe('DocDrawingService', () => {
let service: IDocDrawingService;
beforeEach(() => {
const injector = new Injector();
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
service = injector.get(IDocDrawingService);
});
it('initializes document drawing data and notifies renderers about existing drawings', () => {
const added: unknown[] = [];
service.add$.subscribe((items) => added.push(items));
service.registerDrawingData('doc-1', {
page1: {
data: {
image1: { drawingId: 'image1' },
},
order: ['image1'],
},
} as never);
service.initializeNotification('doc-1');
expect(service.getDrawingData('doc-1', 'page1')?.image1.drawingId).toBe('image1');
expect(added).toEqual([[{ drawingId: 'image1', unitId: 'doc-1', subUnitId: 'page1' }]]);
});
});

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IMutationInfo } from '@univerjs/core';
import type { IDocDrawing } from './doc-drawing.service';
import { createIdentifier, toDisposable } from '@univerjs/core';
export interface IDocDrawingRemoveMutationInfoParams {
unitId: string;
subUnitId: string;
drawing: IDocDrawing;
removeDrawings: readonly IDocDrawing[];
}
export interface IDocDrawingEditCommandInfoParams {
unitId: string;
subUnitId: string;
drawing: IDocDrawing;
}
export interface IDocDrawingMutationInfos {
redoMutations: IMutationInfo[];
undoMutations: IMutationInfo[];
}
export interface IDocDrawingEditCommandInfo {
label?: string;
commandId: string;
commandParams?: unknown;
disable?: boolean;
}
export interface IDocDrawingAdapter {
getRemoveDrawingMutationInfos?: (
params: IDocDrawingRemoveMutationInfoParams
) => IDocDrawingMutationInfos | null | undefined;
getEditDrawingCommandInfo?: (
params: IDocDrawingEditCommandInfoParams
) => IDocDrawingEditCommandInfo | null | undefined;
}
export interface IDocDrawingAdapterService {
registerAdapter(adapter: IDocDrawingAdapter): IDisposable;
getRemoveDrawingMutationInfos(params: IDocDrawingRemoveMutationInfoParams): IDocDrawingMutationInfos;
getEditDrawingCommandInfo(params: IDocDrawingEditCommandInfoParams): IDocDrawingEditCommandInfo | null;
}
export const IDocDrawingAdapterService = createIdentifier<IDocDrawingAdapterService>('doc.drawing-adapter.service');
export class DocDrawingAdapterService implements IDocDrawingAdapterService {
private readonly _adapters: IDocDrawingAdapter[] = [];
registerAdapter(adapter: IDocDrawingAdapter): IDisposable {
this._adapters.push(adapter);
return toDisposable(() => {
const index = this._adapters.indexOf(adapter);
if (index >= 0) {
this._adapters.splice(index, 1);
}
});
}
getRemoveDrawingMutationInfos(params: IDocDrawingRemoveMutationInfoParams): IDocDrawingMutationInfos {
const mutationInfos: IDocDrawingMutationInfos = {
redoMutations: [],
undoMutations: [],
};
for (const adapter of this._adapters) {
const nextMutationInfos = adapter.getRemoveDrawingMutationInfos?.(params);
if (!nextMutationInfos) {
continue;
}
mutationInfos.redoMutations.push(...nextMutationInfos.redoMutations);
mutationInfos.undoMutations.push(...nextMutationInfos.undoMutations);
}
return mutationInfos;
}
getEditDrawingCommandInfo(params: IDocDrawingEditCommandInfoParams): IDocDrawingEditCommandInfo | null {
for (const adapter of this._adapters) {
const commandInfo = adapter.getEditDrawingCommandInfo?.(params);
if (commandInfo) {
return commandInfo;
}
}
return null;
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData, IDrawings } from '@univerjs/core';
/** Resolve persisted drawing ownership independently of the current text selection. */
export function findDocDrawing(snapshot: IDocumentData, drawingId: string): {
drawing: IDrawings[string];
path: string[];
segmentId: string;
} | undefined {
const drawing = snapshot.drawings?.[drawingId];
if (drawing) {
return { drawing, path: ['drawings', drawingId], segmentId: '' };
}
for (const [segmentId, note] of Object.entries(snapshot.notes ?? {})) {
const drawing = note.drawings?.[drawingId];
if (drawing) {
return { drawing, path: ['notes', segmentId, 'drawings', drawingId], segmentId };
}
}
}
/** The render registry is flat; persisted note resources retain their own scope. */
export function collectDocDrawings(snapshot: IDocumentData): { drawings: IDrawings; drawingsOrder: string[] } {
let drawings = snapshot.drawings ?? {};
let drawingsOrder = snapshot.drawingsOrder ?? Object.keys(drawings);
let copied = false;
for (const note of Object.values(snapshot.notes ?? {})) {
if (!note.drawings || Object.keys(note.drawings).length === 0) {
continue;
}
if (!copied) {
drawings = { ...drawings };
drawingsOrder = [...drawingsOrder];
copied = true;
}
Object.assign(drawings, note.drawings);
drawingsOrder.push(...(note.drawingsOrder ?? Object.keys(note.drawings)));
}
return { drawings, drawingsOrder };
}

View File

@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocDrawingBase } from '@univerjs/core';
import type { IDocFloatDomData, IImageData, IUnitDrawingService } from '@univerjs/drawing';
import { createIdentifier } from '@univerjs/core';
import { UnitDrawingService } from '@univerjs/drawing';
export interface IDocImage extends IImageData, IDocDrawingBase { }
/**
* test type
*/
export interface IDocShape extends IDocDrawingBase { }
export interface IDocFloatDom extends IDocFloatDomData, IDocDrawingBase {}
export type IDocDrawing = IDocImage | IDocFloatDom | IDocShape;
type OptionalField<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type IDocUpdateDrawing = OptionalField<IDocDrawing, 'drawingType' | 'layoutType' | 'docTransform' | 'description' | 'title'>;
export class DocDrawingService extends UnitDrawingService<IDocDrawing> { }
export interface IDocDrawingService extends IUnitDrawingService<IDocDrawing> { }
export const IDocDrawingService = createIdentifier<IDocDrawingService>('univer.doc.plugin.doc-drawing.service');

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