snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
37
packages/docs-drawing/README.md
Normal file
37
packages/docs-drawing/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @univerjs/docs-drawing
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/docs-drawing)
|
||||
[](https://npmjs.com/package/@univerjs/docs-drawing)
|
||||
[](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)
|
||||
88
packages/docs-drawing/package.json
Normal file
88
packages/docs-drawing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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] })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
24
packages/docs-drawing/src/config/config.ts
Normal file
24
packages/docs-drawing/src/config/config.ts
Normal 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 = {};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
193
packages/docs-drawing/src/controllers/doc-drawing.controller.ts
Normal file
193
packages/docs-drawing/src/controllers/doc-drawing.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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()!,
|
||||
};
|
||||
}
|
||||
@@ -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: '',
|
||||
},
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
479
packages/docs-drawing/src/facade/f-document-image.ts
Normal file
479
packages/docs-drawing/src/facade/f-document-image.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
264
packages/docs-drawing/src/facade/f-document.ts
Normal file
264
packages/docs-drawing/src/facade/f-document.ts
Normal 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 {}
|
||||
}
|
||||
104
packages/docs-drawing/src/facade/f-enum.ts
Normal file
104
packages/docs-drawing/src/facade/f-enum.ts
Normal 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 {}
|
||||
}
|
||||
21
packages/docs-drawing/src/facade/index.ts
Normal file
21
packages/docs-drawing/src/facade/index.ts
Normal 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';
|
||||
42
packages/docs-drawing/src/index.ts
Normal file
42
packages/docs-drawing/src/index.ts
Normal 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';
|
||||
64
packages/docs-drawing/src/plugin.ts
Normal file
64
packages/docs-drawing/src/plugin.ts
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' }]]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
55
packages/docs-drawing/src/services/doc-drawing-source.ts
Normal file
55
packages/docs-drawing/src/services/doc-drawing-source.ts
Normal 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 };
|
||||
}
|
||||
40
packages/docs-drawing/src/services/doc-drawing.service.ts
Normal file
40
packages/docs-drawing/src/services/doc-drawing.service.ts
Normal 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');
|
||||
8
packages/docs-drawing/tsconfig.json
Normal file
8
packages/docs-drawing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/docs-drawing/tsconfig.node.json
Normal file
18
packages/docs-drawing/tsconfig.node.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/node",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
3
packages/docs-drawing/vitest.config.ts
Normal file
3
packages/docs-drawing/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user