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

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

View File

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

View File

@@ -0,0 +1,99 @@
{
"name": "@univerjs/docs-drawing-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Drawing UI 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",
"ui",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-drawing": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/drawing-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@testing-library/react": "^16.3.3",
"@univerjs-infra/shared": "workspace:*",
"@univerjs/protocol": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,197 @@
/**
* 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.
*/
/* eslint-disable ts/no-explicit-any */
import type { Dependency, DocumentDataModel, IDocumentData, Nullable } from '@univerjs/core';
import type { DocumentSkeleton, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
BooleanNumber,
DisposableCollection,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ILogService,
Inject,
Injector,
IUndoRedoService,
IUniverInstanceService,
LogLevel,
Plugin,
RxDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateChangeManagerService,
DocStateEmitService,
} from '@univerjs/docs';
import { DocIMEInputManagerService, DocSelectionRenderService } from '@univerjs/docs-ui';
import { DocumentViewModel, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { BehaviorSubject, takeUntil } from 'rxjs';
const DEFAULT_DOC_DATA: IDocumentData = {
id: 'test-doc',
body: {
dataStream: 'Hello world\r\n',
textRuns: [{
st: 0,
ed: 11,
ts: {
bl: BooleanNumber.FALSE,
},
}],
paragraphs: [{ startIndex: 11, paragraphId: 'para_drawing_test' }],
sectionBreaks: [],
customBlocks: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
export function createDocUiTestBed(docData?: IDocumentData, dependencies?: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.get(IUndoRedoService);
if (!dependencies?.some((dependency) => Array.isArray(dependency) && dependency[0] === IRenderManagerService)) {
this._injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
}
this._injector.add([DocSelectionManagerService]);
this._injector.add([DocStateEmitService]);
this._injector.add([DocStateChangeManagerService]);
this._injector.add([DocIMEInputManagerService]);
this._injector.add([DocSelectionRenderService]);
dependencies?.forEach((dependency) => this._injector.add(dependency));
}
override onReady(): void {
this._injector.get(DocStateChangeManagerService);
}
}
univer.registerPlugin(TestPlugin);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, docData || DEFAULT_DOC_DATA);
const univerInstanceService = get(IUniverInstanceService);
const fakeDocSkeletonManager = new MockDocSkeletonManagerService({
unit: doc,
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: null as any,
scene: null as any,
mainComponent: null as any,
components: null as any,
isMainScene: true,
activated$: new BehaviorSubject(true),
activate: () => {},
deactivate: () => {},
}, univerInstanceService);
injector.add([DocSkeletonManagerService, { useValue: fakeDocSkeletonManager as unknown as DocSkeletonManagerService }]);
const renderManagerService = get(IRenderManagerService);
if (renderManagerService.getRenderUnitById(doc.getUnitId()) == null) {
renderManagerService.addRender(doc.getUnitId(), {
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: new DisposableCollection() as any,
scene: new DisposableCollection() as any,
mainComponent: null as any,
components: new Map(),
isMainScene: true,
activated$: new BehaviorSubject(true),
with: injector.get.bind(injector),
activate: () => {},
deactivate: () => {},
isDisposed: () => false,
});
}
univerInstanceService.focusUnit(doc.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
injector,
get,
doc,
};
}
class MockDocSkeletonManagerService extends RxDisposable implements IRenderModule {
private _docViewModel!: DocumentViewModel;
private readonly _currentSkeleton$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeleton$ = this._currentSkeleton$.asObservable();
private readonly _currentSkeletonBefore$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeletonBefore$ = this._currentSkeletonBefore$.asObservable();
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._update();
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
.pipe(takeUntil(this.dispose$))
.subscribe((documentModel) => {
if (documentModel?.getUnitId() === this._context.unitId) {
this._update();
}
});
}
private _update() {
const documentDataModel = this._context.unit;
const unitId = this._context.unitId;
if (documentDataModel.getBody() == null) {
return;
}
if (this._docViewModel && unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
this._docViewModel.reset(documentDataModel);
} else if (!this._docViewModel) {
this._docViewModel = new DocumentViewModel(documentDataModel);
}
}
getSkeleton(): DocumentSkeleton {
throw new Error('[MockDocSkeletonManagerService]: cannot access to doc skeleton in unit tests!');
}
getViewModel(): DocumentViewModel {
return this._docViewModel;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
/**
* 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 { ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { InsertDocDrawingCommand } from '@univerjs/docs-drawing';
import { describe, expect, it, vi } from 'vitest';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../insert-shape.command';
function createAccessor() {
const executeCommand = vi.fn(async () => true);
const getCurrentUnitOfType = vi.fn((type: UniverInstanceType) => {
if (type !== UniverInstanceType.UNIVER_DOC) {
return null;
}
return {
getUnitId: () => 'test-doc',
};
});
return {
executeCommand,
accessor: {
get: (token: unknown) => {
if (token === ICommandService) {
return {
executeCommand,
};
}
if (token === IUniverInstanceService) {
return {
getCurrentUnitOfType,
};
}
throw new Error(`Unexpected token: ${String(token)}`);
},
},
};
}
describe('insert shape commands', () => {
it('inserts a rectangle shape through the drawing insertion command', async () => {
const { accessor, executeCommand } = createAccessor();
await InsertDocRectangleShapeCommand.handler(accessor as never);
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(
InsertDocDrawingCommand.id,
expect.objectContaining({
unitId: 'test-doc',
drawings: [
expect.objectContaining({
source: expect.stringContaining('data:image/svg+xml'),
}),
],
})
);
});
it('inserts an ellipse shape through the drawing insertion command', async () => {
const { accessor, executeCommand } = createAccessor();
await InsertDocEllipseShapeCommand.handler(accessor as never);
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(
InsertDocDrawingCommand.id,
expect.objectContaining({
unitId: 'test-doc',
drawings: [
expect.objectContaining({
source: expect.stringContaining('data:image/svg+xml'),
}),
],
})
);
});
});

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { groupToUngroup, ungroupToGroup } from '../utils';
vi.mock('@univerjs/engine-render', () => ({
getGroupState: vi.fn(() => ({ left: 10, top: 20, width: 100, height: 50 })),
transformObjectOutOfGroup: vi.fn((transform, groupTransform) => ({
left: (transform.left ?? 0) + (groupTransform.left ?? 0),
top: (transform.top ?? 0) + (groupTransform.top ?? 0),
width: transform.width,
height: transform.height,
})),
}));
describe('docs drawing command utils', () => {
it('rebuilds group metadata from ungrouped drawings', () => {
expect(ungroupToGroup([
{
parent: { unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'group-1' },
children: [
{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'child-1', transform: { left: 2, top: 3 } },
{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'child-2', transform: { left: 4, top: 5 } },
],
},
] as never)).toEqual([
{
parent: {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 100, height: 50 },
groupBaseBound: { left: 10, top: 20, width: 100, height: 50 },
},
children: [
{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'child-1', transform: { left: 2, top: 3 }, groupId: 'group-1' },
{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'child-2', transform: { left: 4, top: 5 }, groupId: 'group-1' },
],
},
]);
});
it('projects grouped children back out of the group transform', () => {
expect(groupToUngroup([
{
parent: {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'group-1',
transform: { left: 10, top: 20, width: 100, height: 50 },
groupBaseBound: { left: 10, top: 20, width: 100, height: 50 },
},
children: [
{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'child-1', transform: { left: 2, top: 3, width: 4, height: 5 } },
],
},
] as never)).toEqual([
{
parent: {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0 },
},
children: [
{
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'child-1',
transform: { left: 12, top: 23, width: 4, height: 5 },
groupId: undefined,
},
],
},
]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IDocDrawing, IRemoveDocDrawingCommandParams } from '@univerjs/docs-drawing';
import { CommandType, ICommandService } from '@univerjs/core';
import { IDocDrawingService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
export const DeleteDocDrawingsCommand: ICommand = {
id: 'doc.command.delete-drawing',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const docDrawingService = accessor.get(IDocDrawingService);
const drawings = docDrawingService.getFocusDrawings();
if (drawings.length === 0) {
return false;
}
const { unitId } = drawings[0];
const selectionRenderService = accessor.get(IRenderManagerService)
.getRenderUnitById(unitId)
?.with(DocSelectionRenderService);
// Keep the command and the immediately following shortcut on the document that owns the drawing.
selectionRenderService?.focus();
const newDrawings = drawings.map((drawing) => {
const { unitId, subUnitId, drawingId, drawingType } = drawing as IDocDrawing;
return {
unitId,
subUnitId,
drawingId,
drawingType,
};
});
const removed = await commandService.executeCommand<IRemoveDocDrawingCommandParams>(RemoveDocDrawingCommand.id, {
unitId,
drawings: newDrawings,
});
if (removed) {
selectionRenderService?.focus();
}
return removed;
},
};

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { CommandType } from '@univerjs/core';
// import { IDocDrawingService } from '@univerjs/docs-drawing';
/**
* The command to insert new defined name
*/
export const GroupDocDrawingCommand: ICommand = {
id: 'doc.command.group-doc-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
// const docDrawingService = accessor.get(IDocDrawingService);
// if (!params) return false;
// // if the subunit is not a doc type, return false
// const unitIds: string[] = [];
// params.forEach(({ parent, children }) => {
// unitIds.push(parent.unitId);
// children.forEach((child) => {
// unitIds.push(child.unitId);
// });
// });
// execute do mutations and add undo mutations to undo stack if completed
// const jsonOp = docDrawingService.getGroupDrawingOp(params) as IDrawingJsonUndo1;
// const { unitId, subUnitId, undo, redo, objects } = jsonOp;
// const result = commandService.syncExecuteCommand(SetDocDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.GROUP });
// if (result) {
// undoRedoService.pushUndoRedo({
// unitID: unitId,
// undoMutations: [
// { id: SetDocDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: groupToUngroup(objects as IDrawingGroupUpdateParam[]), type: DocDrawingApplyType.UNGROUP } },
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
// ],
// redoMutations: [
// { id: SetDocDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.GROUP } },
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
// ],
// });
// return true;
// }
return false;
},
};

View File

@@ -0,0 +1,36 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, Nullable } from '@univerjs/core';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { DocDrawingUpdateRenderController } from '../../controllers/render-controllers/doc-drawing-update.render-controller';
export interface IInsertDocImageCommandParams {
files: Nullable<File[]>;
};
export const InsertDocImageCommand: ICommand<IInsertDocImageCommandParams> = {
id: 'doc.command.insert-float-image',
type: CommandType.COMMAND,
handler: (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
return getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_DOC, univerInstanceService, renderManagerService)
?.with(DocDrawingUpdateRenderController)
.insertDocImage() ?? false;
},
};

View File

@@ -0,0 +1,101 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IInsertDocDrawingCommandParams } from '@univerjs/docs-drawing';
import {
BooleanNumber,
CommandType,
DrawingTypeEnum,
generateRandomId,
ICommandService,
ImageSourceType,
IUniverInstanceService,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { buildDocTransform, docDrawingPositionToTransform } from '@univerjs/docs';
import { InsertDocDrawingCommand } from '@univerjs/docs-drawing';
type DocShapeKind = 'rectangle' | 'ellipse';
const DOC_SHAPE_FILL = '#3A60F7';
const DOC_SHAPE_STROKE = '#1D3EA6';
function createShapeSvgSource(shape: DocShapeKind, width: number, height: number) {
const content = shape === 'ellipse'
? `<ellipse cx="${width / 2}" cy="${height / 2}" rx="${(width - 8) / 2}" ry="${(height - 8) / 2}" fill="${DOC_SHAPE_FILL}" stroke="${DOC_SHAPE_STROKE}" stroke-width="4" />`
: `<rect x="4" y="4" width="${width - 8}" height="${height - 8}" rx="14" fill="${DOC_SHAPE_FILL}" stroke="${DOC_SHAPE_STROKE}" stroke-width="4" />`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none">${content}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function createShapeInsertCommand(shape: DocShapeKind, width: number, height: number, id: string): ICommand {
return {
id,
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const unitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId();
if (!unitId) {
return false;
}
const docTransform = buildDocTransform(width, height);
return commandService.executeCommand<IInsertDocDrawingCommandParams>(InsertDocDrawingCommand.id, {
unitId,
drawings: [{
unitId,
subUnitId: unitId,
drawingId: generateRandomId(6),
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: createShapeSvgSource(shape, width, height),
transform: docDrawingPositionToTransform(docTransform),
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE,
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
}],
});
},
};
}
export const InsertDocRectangleShapeCommand = createShapeInsertCommand(
'rectangle',
144,
96,
'doc.command.insert-float-shape.rectangle'
);
export const InsertDocEllipseShapeCommand = createShapeInsertCommand(
'ellipse',
112,
112,
'doc.command.insert-float-shape.ellipse'
);

View File

@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IObjectPositionH, IObjectPositionV } from '@univerjs/core';
import type { IDocDrawing, IDrawingDocTransform, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import type { IDocumentSkeletonCached } from '@univerjs/engine-render';
import {
CommandType,
Direction,
ICommandService,
IUniverInstanceService,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { IDocDrawingService, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { findDrawingAnchorInPage, resolveDrawingAnchorOffsets } from '../../utils/drawing-anchor-position';
export interface IMoveDrawingsCommandParams {
direction: Direction;
}
export const MoveDocDrawingsCommand: ICommand = {
id: 'doc.command.move-drawing',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params: IMoveDrawingsCommandParams) => {
const commandService = accessor.get(ICommandService);
const docDrawingService = accessor.get(IDocDrawingService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const { direction } = params;
const drawings = docDrawingService.getFocusDrawings();
if (drawings.length === 0) {
return false;
}
const unitId = drawings[0].unitId;
const renderObject = renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return false;
}
const transformer = scene.getTransformerByCreate();
const skeletonData = renderObject?.with(DocSkeletonManagerService).getSkeleton()?.getSkeletonData();
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const newDrawings = drawings.map((drawing) => {
const { drawingId } = drawing as IDocDrawing;
const drawingData = documentDataModel?.getSnapshot().drawings?.[drawingId];
// Inline drawing can not be moved by shortcut.
if (drawingData == null || drawingData.layoutType === PositionedObjectLayoutType.INLINE) {
return null;
}
const { positionH, positionV } = drawingData.docTransform;
const anchorLookup = skeletonData == null
? { anchor: null, previewOnly: false }
: findDrawingAnchor(skeletonData, drawingId);
if (anchorLookup.previewOnly) {
return null;
}
const { anchor } = anchorLookup;
const offsets = anchor == null
? { horizontal: positionH.posOffset ?? 0, vertical: positionV.posOffset ?? 0 }
: resolveDrawingAnchorOffsets(anchor, positionH, positionV);
const newPositionH = {
...(positionH.relativeFrom == null ? {} : { relativeFrom: positionH.relativeFrom }),
posOffset: offsets.horizontal,
} as IObjectPositionH;
const newPositionV = {
...(positionV.relativeFrom == null ? {} : { relativeFrom: positionV.relativeFrom }),
posOffset: offsets.vertical,
} as IObjectPositionV;
if (direction === Direction.UP) {
newPositionV.posOffset = (newPositionV.posOffset ?? 0) - 2;
} else if (direction === Direction.DOWN) {
newPositionV.posOffset = (newPositionV.posOffset ?? 0) + 2;
} else if (direction === Direction.LEFT) {
newPositionH.posOffset = (newPositionH.posOffset ?? 0) - 2;
} else if (direction === Direction.RIGHT) {
newPositionH.posOffset = (newPositionH.posOffset ?? 0) + 2;
}
return {
drawingId,
key: direction === Direction.UP || direction === Direction.DOWN ? 'positionV' : 'positionH',
value: direction === Direction.UP || direction === Direction.DOWN ? newPositionV : newPositionH,
};
}).filter((drawing) => drawing != null) as IDrawingDocTransform[];
if (newDrawings.length === 0) {
return false;
}
const result = commandService.syncExecuteCommand<IUpdateDrawingDocTransformCommandParams>(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: newDrawings,
});
transformer.refreshControls();
return Boolean(result);
},
};
function findDrawingAnchor(
skeletonData: IDocumentSkeletonCached,
drawingId: string
) {
let foundInPreview = false;
for (const page of skeletonData.pages) {
const bodyAnchor = findDrawingAnchorInPage(page, drawingId, page.marginTop, page.marginLeft);
const header = page.headerId == null
? undefined
: skeletonData.skeHeaders.get(page.headerId)?.get(page.pageWidth);
const headerAnchor = header == null
? null
: findDrawingAnchorInPage(header, drawingId, header.marginTop, page.marginLeft);
const footer = page.footerId == null
? undefined
: skeletonData.skeFooters.get(page.footerId)?.get(page.pageWidth);
const footerTop = footer == null ? 0 : page.pageHeight - page.marginBottom + footer.marginTop;
const footerAnchor = footer == null
? null
: findDrawingAnchorInPage(footer, drawingId, footerTop, page.marginLeft);
const anchor = bodyAnchor ?? headerAnchor ?? footerAnchor;
if (anchor == null) {
continue;
}
if (!page.isLayoutPlaceholder && !page.isMaterializationPlaceholder) {
return { anchor, previewOnly: false };
}
foundInPreview = true;
}
return { anchor: null, previewOnly: foundInPreview };
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingJsonUndo1 } from '@univerjs/drawing';
import { CommandType } from '@univerjs/core';
import { IDocDrawingService } from '@univerjs/docs-drawing';
/**
* The command to insert new defined name
*/
export const UngroupDocDrawingCommand: ICommand = {
id: 'doc.command.ungroup-doc-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
const docDrawingService = accessor.get(IDocDrawingService);
if (!params) return false;
const unitIds: string[] = [];
params.forEach(({ parent, children }) => {
unitIds.push(parent.unitId);
children.forEach((child) => {
unitIds.push(child.unitId);
});
});
// execute do mutations and add undo mutations to undo stack if completed
const jsonOp = docDrawingService.getUngroupDrawingOp(params) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
// const result = commandService.syncExecuteCommand(SetDocDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.UNGROUP });
// if (result) {
// undoRedoService.pushUndoRedo({
// unitID: unitId,
// undoMutations: [
// { id: SetDocDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: ungroupToGroup(objects as IDrawingGroupUpdateParam[]), type: DocDrawingApplyType.GROUP } },
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
// ],
// redoMutations: [
// { id: SetDocDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.UNGROUP } },
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
// ],
// });
// return true;
// }
return false;
},
};

View File

@@ -0,0 +1,555 @@
/**
* 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, IDocDrawingBase, IDocDrawingPosition, IMutationInfo, JSONXActions, WrapTextType } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import {
CommandType,
getRichTextEditPath,
ICommandService,
IUniverInstanceService,
JSONX,
TextX,
TextXActionType,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
// eslint-disable-next-line max-lines-per-function
function getDeleteAndInsertCustomBlockActions(
segmentId: string,
oldSegmentId: string,
segmentPage: number,
offset: number,
drawingId: string,
documentDataModel: DocumentDataModel,
docSelectionRenderManager: DocSelectionRenderService
) {
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
const oldBody = documentDataModel.getSelfOrHeaderFooterModel(oldSegmentId)?.getBody();
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (oldBody == null || body == null) {
return;
}
const oldOffset = oldBody.customBlocks?.find((block) => block.blockId === drawingId)?.startIndex;
if (oldOffset == null) {
return;
}
// Can not put image after the last \r.
offset = Math.min(body.dataStream.length - 2, offset);
if (segmentId === oldSegmentId) {
if (offset < oldOffset) {
// Insert first.
if (offset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
}
textX.push({
t: TextXActionType.INSERT,
body: {
dataStream: '\b',
customBlocks: [{
startIndex: 0,
blockId: drawingId,
}],
},
len: 1,
});
textX.push({
t: TextXActionType.RETAIN,
len: oldOffset - offset,
});
textX.push({
t: TextXActionType.DELETE,
len: 1,
});
} else {
// Delete first.
if (oldOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: oldOffset,
});
}
textX.push({
t: TextXActionType.DELETE,
len: 1,
});
if (offset - oldOffset - 1 > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset - oldOffset - 1,
});
}
textX.push({
t: TextXActionType.INSERT,
body: {
dataStream: '\b',
customBlocks: [{
startIndex: 0,
blockId: drawingId,
}],
},
len: 1,
});
}
if (offset !== oldOffset) {
const path = getRichTextEditPath(documentDataModel, oldSegmentId);
const action = jsonX.editOp(textX.serialize(), path);
rawActions.push(action!);
}
} else {
if (oldOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: oldOffset,
});
}
textX.push({
t: TextXActionType.DELETE,
len: 1,
});
let path = getRichTextEditPath(documentDataModel, oldSegmentId);
let action = jsonX.editOp(textX.serialize(), path);
rawActions.push(action!);
textX.empty();
if (offset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
}
textX.push({
t: TextXActionType.INSERT,
body: {
dataStream: '\b',
customBlocks: [{
startIndex: 0,
blockId: drawingId,
}],
},
len: 1,
});
path = getRichTextEditPath(documentDataModel, segmentId);
action = jsonX.editOp(textX.serialize(), path);
rawActions.push(action!);
docSelectionRenderManager.setSegment(segmentId);
docSelectionRenderManager.setSegmentPage(segmentPage);
}
return rawActions;
}
interface IDist {
distT: number;
distB: number;
distL: number;
distR: number;
}
interface IUpdateDocDrawingDistanceParams {
unitId: string;
subUnitId: string;
drawings: IDocDrawing[];
dist: IDist;
}
/**
* The command to update drawing wrap text.
*/
export const UpdateDocDrawingDistanceCommand: ICommand = {
id: 'doc.command.update-doc-drawing-distance',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IUpdateDocDrawingDistanceParams) => {
if (params == null) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { drawings, dist, unitId } = params;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return false;
}
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
const { drawings: oldDrawings = {} } = documentDataModel.getSnapshot();
for (const drawing of drawings) {
const { drawingId } = drawing;
for (const [key, value] of Object.entries(dist)) {
const oldValue = oldDrawings[drawingId][key as keyof IDist];
if (oldValue !== value) {
const action = jsonX.replaceOp(['drawings', drawingId, key], oldValue, value);
rawActions.push(action!);
}
}
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: null,
},
};
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IUpdateDocDrawingWrapTextParams {
unitId: string;
subUnitId: string;
drawings: IDocDrawing[];
wrapText: WrapTextType;
}
/**
* The command to update drawing wrap text.
*/
export const UpdateDocDrawingWrapTextCommand: ICommand = {
id: 'doc.command.update-doc-drawing-wrap-text',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IUpdateDocDrawingWrapTextParams) => {
if (params == null) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { drawings, wrapText, unitId } = params;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return false;
}
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
const { drawings: oldDrawings = {} } = documentDataModel.getSnapshot();
// Update drawing layoutType.
for (const drawing of drawings) {
const { drawingId } = drawing;
const oldWrapText = oldDrawings[drawingId].wrapText;
if (oldWrapText !== wrapText) {
const action = jsonX.replaceOp(['drawings', drawingId, 'wrapText'], oldWrapText, wrapText);
rawActions.push(action!);
}
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: null,
},
};
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export interface IMoveInlineDrawingParams {
unitId: string;
subUnitId: string;
drawing: IDocDrawingBase;
offset: number;
segmentId: string;
segmentPage: number;
needRefreshDrawings?: boolean;
}
/**
* The command to move inline drawing.
*/
export const IMoveInlineDrawingCommand: ICommand = {
id: 'doc.command.move-inline-drawing',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params: IMoveInlineDrawingParams) => {
if (params == null) {
return false;
}
const { drawing, unitId, offset, segmentId: newSegmentId, segmentPage, needRefreshDrawings } = params;
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionRenderService = renderManagerService.getRenderUnitById(unitId)?.with(DocSelectionRenderService);
const docRefreshDrawingsService = accessor.get(DocRefreshDrawingsService);
const renderObject = renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
const skeleton = renderObject?.with(DocSkeletonManagerService).getSkeleton();
if (scene == null || docSelectionRenderService == null) {
return false;
}
const transformer = scene.getTransformerByCreate();
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return false;
}
// Need to refresh drawings if not find the anchor position.
if (needRefreshDrawings) {
docRefreshDrawingsService.refreshDrawings(skeleton);
transformer.refreshControls();
return true;
}
const rawActions: JSONXActions = [];
const { drawingId } = drawing;
const segmentId = docSelectionRenderService.getSegment() ?? '';
const actions = getDeleteAndInsertCustomBlockActions(
newSegmentId,
segmentId,
segmentPage,
offset,
drawingId,
documentDataModel,
docSelectionRenderService
);
if (actions == null || actions.length === 0) {
docRefreshDrawingsService.refreshDrawings(skeleton);
transformer.refreshControls();
return false;
}
rawActions.push(...actions);
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: null,
},
};
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
transformer.refreshControls();
return Boolean(result);
},
};
export interface ITransformNonInlineDrawingParams {
unitId: string;
subUnitId: string;
drawing: IDocDrawingBase;
offset: number;
docTransform: IDocDrawingPosition;
segmentId: string;
segmentPage: number;
}
/**
* The command to transform non-inline drawing.
*/
export const ITransformNonInlineDrawingCommand: ICommand = {
id: 'doc.command.transform-non-inline-drawing',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: (accessor: IAccessor, params?: ITransformNonInlineDrawingParams) => {
if (params == null) {
return false;
}
const { drawing, unitId, offset, docTransform, segmentId: newSegmentId, segmentPage } = params;
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionRenderService = renderManagerService.getRenderUnitById(unitId)?.with(DocSelectionRenderService);
const renderObject = renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (renderObject == null || scene == null || docSelectionRenderService == null) {
return false;
}
const transformer = scene.getTransformerByCreate();
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return false;
}
const rawActions: JSONXActions = [];
const { drawingId } = drawing;
const segmentId = docSelectionRenderService.getSegment() ?? '';
const actions = getDeleteAndInsertCustomBlockActions(
newSegmentId,
segmentId,
segmentPage,
offset,
drawingId,
documentDataModel,
docSelectionRenderService
);
if (actions == null) {
return false;
}
if (actions.length > 0) {
rawActions.push(...actions);
}
const jsonX = JSONX.getInstance();
const { drawings: oldDrawings = {} } = documentDataModel.getSnapshot();
const oldDocTransform = oldDrawings[drawingId].docTransform;
const { positionH: oldPositionH, positionV: oldPositionV, size: oldSize, angle: oldAngle } = oldDocTransform;
if (!Tools.diffValue(oldPositionH, docTransform.positionH)) {
const updateAction = jsonX.replaceOp(['drawings', drawingId, 'docTransform', 'positionH'], oldPositionH, docTransform.positionH);
rawActions.push(updateAction!);
}
if (!Tools.diffValue(oldPositionV, docTransform.positionV)) {
const updateAction = jsonX.replaceOp(['drawings', drawingId, 'docTransform', 'positionV'], oldPositionV, docTransform.positionV);
rawActions.push(updateAction!);
}
if (!Tools.diffValue(oldSize, docTransform.size)) {
const updateAction = jsonX.replaceOp(['drawings', drawingId, 'docTransform', 'size'], oldSize, docTransform.size);
rawActions.push(updateAction!);
}
if (!Tools.diffValue(oldAngle, docTransform.angle)) {
const updateAction = jsonX.replaceOp(['drawings', drawingId, 'docTransform', 'angle'], oldAngle, docTransform.angle);
rawActions.push(updateAction!);
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: null,
debounce: true,
},
};
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
const skeleton = renderObject.with(DocSkeletonManagerService).getSkeleton();
accessor.get(DocRefreshDrawingsService).refreshDrawings(skeleton);
transformer.refreshControls();
return Boolean(result);
},
};

View File

@@ -0,0 +1,109 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { DrawingTypeEnum } from '@univerjs/core';
import { getGroupState, transformObjectOutOfGroup } from '@univerjs/engine-render';
export function ungroupToGroup(ungroupParams: IDrawingGroupUpdateParam[]) {
const newGroupParams: IDrawingGroupUpdateParam[] = [];
ungroupParams.forEach((ungroupParam) => {
const { parent, children } = ungroupParam;
const { unitId, subUnitId, drawingId: groupId } = parent;
const groupTransform = getGroupState(0, 0, children.map((o) => o.transform || {}));
const newChildren = children.map((drawing) => {
const transform = drawing.transform || { left: 0, top: 0 };
const { unitId, subUnitId, drawingId } = drawing;
return {
unitId,
subUnitId,
drawingId,
transform: {
...transform,
// left: transform.left! - groupTransform.left,
// top: transform.top! - groupTransform.top,
},
groupId,
};
}) as IDrawingParam[];
const groupParam = {
unitId,
subUnitId,
drawingId: groupId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: groupTransform,
groupBaseBound: {
left: groupTransform.left,
top: groupTransform.top,
width: groupTransform.width,
height: groupTransform.height,
},
} as IDrawingParam;
newGroupParams.push({
parent: groupParam,
children: newChildren,
});
});
return newGroupParams;
}
export function groupToUngroup(groupParams: IDrawingGroupUpdateParam[]) {
const newGroupParams: IDrawingGroupUpdateParam[] = [];
groupParams.forEach((groupParam) => {
const { parent, children } = groupParam;
const { unitId, subUnitId, drawingId: groupId, transform: groupTransform = { width: 0, height: 0 } } = parent;
if (groupTransform == null) {
return;
}
const newChildren = children.map((object) => {
const { transform } = object;
const { unitId, subUnitId, drawingId } = object;
const newTransform = transformObjectOutOfGroup(transform || {}, groupTransform, groupTransform.width || 0, groupTransform.height || 0, parent.groupBaseBound);
return {
unitId,
subUnitId,
drawingId,
transform: newTransform,
groupId: undefined,
};
}) as IDrawingParam[];
const ungroupParam = {
unitId,
subUnitId,
drawingId: groupId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left: 0,
top: 0,
},
} as IDrawingParam;
newGroupParams.push({
parent: ungroupParam,
children: newChildren,
});
});
return newGroupParams;
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, IDrawingSearch } from '@univerjs/core';
import { ICommandService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { describe, expect, it, vi } from 'vitest';
import { EditDocDrawingOperation } from '../edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../open-drawing-panel.operation';
describe('EditDocDrawingOperation', () => {
it('returns false when no drawing is provided', () => {
const drawingManagerService = {
focusDrawing: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(),
};
const accessor = {
get(token: unknown) {
if (token === IDrawingManagerService) {
return drawingManagerService;
}
if (token === ICommandService) {
return commandService;
}
throw new Error('Unknown dependency');
},
} as IAccessor;
expect(EditDocDrawingOperation.handler(accessor, null as unknown as IDrawingSearch)).toBe(false);
expect(drawingManagerService.focusDrawing).not.toHaveBeenCalled();
expect(commandService.executeCommand).not.toHaveBeenCalled();
});
it('focuses the drawing and opens the sidebar when a drawing is provided', () => {
const drawingManagerService = {
focusDrawing: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(),
};
const accessor = {
get(token: unknown) {
if (token === IDrawingManagerService) {
return drawingManagerService;
}
if (token === ICommandService) {
return commandService;
}
throw new Error('Unknown dependency');
},
} as IAccessor;
const params: IDrawingSearch = {
unitId: 'unit-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
};
expect(EditDocDrawingOperation.handler(accessor, params)).toBe(true);
expect(drawingManagerService.focusDrawing).toHaveBeenCalledWith([params]);
expect(commandService.executeCommand).toHaveBeenCalledWith(SidebarDocDrawingOperation.id, { value: 'open' });
});
});

View File

@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import { LocaleService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { ISidebarService } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { COMPONENT_DOC_DRAWING_PANEL } from '../../../views/doc-image-panel/component-name';
import { SidebarDocDrawingOperation } from '../open-drawing-panel.operation';
function createAccessor() {
const sidebarService = { open: vi.fn(), close: vi.fn() };
const drawingManagerService = { focusDrawing: vi.fn() };
const accessor = {
get(token: unknown) {
if (token === ISidebarService) {
return sidebarService;
}
if (token === LocaleService) {
return { t: (key: string) => key };
}
if (token === IDrawingManagerService) {
return drawingManagerService;
}
throw new Error(`Unknown dependency: ${String(token)}`);
},
} as IAccessor;
return { accessor, sidebarService, drawingManagerService };
}
describe('SidebarDocDrawingOperation', () => {
it('opens the doc drawing sidebar and clears focus when closed', async () => {
const { accessor, sidebarService, drawingManagerService } = createAccessor();
await expect(SidebarDocDrawingOperation.handler(accessor, { value: 'open' })).resolves.toBe(true);
expect(sidebarService.open).toHaveBeenCalledWith({
header: { title: 'docs-drawing-ui.panel.title' },
children: { label: COMPONENT_DOC_DRAWING_PANEL },
onClose: expect.any(Function),
width: 360,
});
const onClose = sidebarService.open.mock.calls[0][0].onClose;
onClose();
expect(drawingManagerService.focusDrawing).toHaveBeenCalledWith(null);
});
it('closes the sidebar for close and default actions', async () => {
const { accessor, sidebarService } = createAccessor();
await expect(SidebarDocDrawingOperation.handler(accessor, { value: 'close' })).resolves.toBe(true);
await expect(SidebarDocDrawingOperation.handler(accessor, { value: 'anything-else' })).resolves.toBe(true);
expect(sidebarService.close).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
export const ClearDocDrawingTransformerOperation: IMutation<string[]> = {
id: 'doc.operation.clear-drawing-transformer',
type: CommandType.MUTATION,
handler: (accessor, params) => {
const renderManagerService = accessor.get(IRenderManagerService);
params.forEach((unitId) => {
renderManagerService.getRenderUnitById(unitId)?.scene.getTransformer()?.debounceRefreshControls();
});
return true;
},
};

View File

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

View File

@@ -0,0 +1,56 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IMobileSidebarMethodOptions } from '@univerjs/ui';
import type { LocaleKey } from '../../../locale/types';
import type { IUIComponentCommandParams } from '../open-drawing-panel.operation';
import { CommandType, LocaleService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { ISidebarService } from '@univerjs/ui';
import { COMPONENT_DOC_DRAWING_PANEL } from '../../../views/doc-image-panel/component-name';
import { SidebarDocDrawingOperation } from '../open-drawing-panel.operation';
export const SidebarDocDrawingMobileOperation: ICommand = {
id: SidebarDocDrawingOperation.id,
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IUIComponentCommandParams) => {
const sidebarService = accessor.get(ISidebarService);
const localeService = accessor.get(LocaleService);
const drawingManagerService = accessor.get(IDrawingManagerService);
switch (params.value) {
case 'open': {
const options: IMobileSidebarMethodOptions = {
layout: 'canvas',
header: { title: localeService.t<LocaleKey>('docs-drawing-ui.panel.title') },
children: { label: COMPONENT_DOC_DRAWING_PANEL },
onClose: () => {
drawingManagerService.focusDrawing(null);
},
width: 360,
};
sidebarService.open(options);
break;
}
case 'close':
default:
sidebarService.close();
break;
}
return true;
},
};

View File

@@ -0,0 +1,56 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, LocaleService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { ISidebarService } from '@univerjs/ui';
import {
COMPONENT_DOC_DRAWING_PANEL,
} from '../../views/doc-image-panel/component-name';
export interface IUIComponentCommandParams {
value: string;
}
export const SidebarDocDrawingOperation: ICommand = {
id: 'sidebar.operation.doc-image',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IUIComponentCommandParams) => {
const sidebarService = accessor.get(ISidebarService);
const localeService = accessor.get(LocaleService);
const drawingManagerService = accessor.get(IDrawingManagerService);
switch (params.value) {
case 'open':
sidebarService.open({
header: { title: localeService.t<LocaleKey>('docs-drawing-ui.panel.title') },
children: { label: COMPONENT_DOC_DRAWING_PANEL },
onClose: () => {
drawingManagerService.focusDrawing(null);
},
width: 360,
});
break;
case 'close':
default:
sidebarService.close();
break;
}
return true;
},
};

View File

@@ -0,0 +1,25 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY = 'docs-drawing-ui.config';
export const DOCS_DRAWING_UI_PLUGIN_NAME = 'DOC_DRAWING_UI_PLUGIN';
export const configSymbol = Symbol(DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDocsDrawingUIConfig {
}
export const defaultPluginConfig: IUniverDocsDrawingUIConfig = {};

View File

@@ -0,0 +1,349 @@
/**
* 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, JSONXActions } from '@univerjs/core';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { RenderUnit } from '@univerjs/engine-render';
import {
BooleanNumber,
CustomRangeType,
DocumentFlavor,
DrawingTypeEnum,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
RedoCommand,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateChangeManagerService,
DocStateEmitService,
RichTextEditingMutation,
} from '@univerjs/docs';
import {
DocDrawingAdapterService,
DocDrawingController,
DocDrawingService,
IDocDrawingAdapterService,
IDocDrawingService,
InsertDocDrawingCommand,
RemoveDocDrawingCommand,
TextWrappingStyle,
UpdateDocDrawingWrappingStyleCommand,
UpdateDrawingDocTransformCommand,
} from '@univerjs/docs-drawing';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import {
CanvasColorService,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
} from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { DocDrawingAddRemoveController } from '../doc-drawing-notification.controller';
describe('DocDrawingAddRemoveController with real commands and services', () => {
const unitId = 'drawing-notifications';
let univer: Univer;
let model: DocumentDataModel;
let render: RenderUnit;
let commands: ICommandService;
let manager: IDrawingManagerService;
let docDrawings: IDocDrawingService;
let refresh: DocRefreshDrawingsService;
function drawing(drawingId: string): IDocDrawing {
return {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: drawingId === 'a' ? BooleanNumber.TRUE : BooleanNumber.FALSE,
transform: { left: 20, top: 30, width: 40, height: 30 },
docTransform: {
size: { width: 40, height: 30 },
angle: 0,
positionH: { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 20 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 30 },
},
};
}
beforeEach(() => {
const context = new Proxy({
font: '',
webkitBackingStorePixelRatio: 1,
measureText: (text: string) => ({
width: text.length * 8,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
}),
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
injector.add([IDocDrawingAdapterService, { useClass: DocDrawingAdapterService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocRefreshDrawingsService]);
injector.add([DocDrawingController]);
injector.add([DocDrawingAddRemoveController]);
commands = injector.get(ICommandService);
commands.registerCommand(RichTextEditingMutation);
injector.get(IUndoRedoService);
injector.get(DocStateChangeManagerService);
const drawingController = injector.get(DocDrawingController);
model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: unitId,
body: {
dataStream: 'A\b\rB\b\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'p1' }, { startIndex: 5, paragraphId: 'p2' }],
sectionBreaks: [{ startIndex: 6, sectionId: 's1' }],
customBlocks: [{ startIndex: 1, blockId: 'a' }, { startIndex: 4, blockId: 'b' }],
},
drawings: { a: drawing('a'), b: drawing('b') },
drawingsOrder: ['a', 'b'],
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
injector.get(IUniverInstanceService).setCurrentUnitForType(unitId);
injector.get(IUniverInstanceService).focusUnit(unitId);
render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.deactivate();
render.addRenderDependencies([[DocSkeletonManagerService]]);
render.with(DocSkeletonManagerService);
drawingController.loadDrawingDataForUnit(unitId);
manager = injector.get(IDrawingManagerService);
docDrawings = injector.get(IDocDrawingService);
refresh = injector.get(DocRefreshDrawingsService);
injector.get(DocDrawingAddRemoveController);
});
afterEach(() => {
univer?.dispose();
vi.restoreAllMocks();
});
function mutate(actions: JSONXActions): void {
commands.syncExecuteCommand(RichTextEditingMutation.id, { unitId, actions, textRanges: null });
}
function resize(): void {
expect(commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a', key: 'size', value: { width: 60, height: 45 } }],
})).toBe(true);
}
it('keeps unrelated rendered positions and visibility when one drawing is resized', () => {
manager.refreshTransform([{ ...drawing('b'), hidden: true, transform: { left: 420, top: 880, width: 40, height: 30 } }]);
const before = manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'b' });
const order = manager.getDrawingOrder(unitId, unitId);
resize();
expect(model.getDrawings()!.a.docTransform.size).toEqual({ width: 60, height: 45 });
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'b' })).toBe(before);
expect(before).toMatchObject({ hidden: true, transform: { left: 420, top: 880 } });
expect(manager.getDrawingOrder(unitId, unitId)).toBe(order);
});
it('does not write layout-only transforms into the persisted drawing snapshot after a resize', () => {
resize();
const original = { ...model.getDrawings()!.a.transform };
manager.refreshTransform([{ ...drawing('a'), transform: { left: 720, top: 990, width: 60, height: 45 } }]);
expect(model.getDrawings()!.a.transform).toEqual(original);
});
it('keeps a thousand-drawing document stable during repeated single-image resizing', ({ task }) => {
const extraDrawings = Array.from({ length: 998 }, (_, index) => drawing(`extra-${index}`));
expect(commands.syncExecuteCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: extraDrawings,
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
const original = manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'extra-997' });
const order = manager.getDrawingOrder(unitId, unitId);
const mutationMs: number[] = [];
for (let index = 0; index < 20; index++) {
const start = performance.now();
commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a', key: 'size', value: { width: 60 + index, height: 45 } }],
});
mutationMs.push(performance.now() - start);
}
Object.assign(task.meta, { drawingCount: 1000, mutationMs });
expect(Object.keys(model.getDrawings()!)).toHaveLength(1000);
expect(model.getDrawings()!.a.docTransform.size.width).toBe(79);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'extra-997' })).toBe(original);
expect(manager.getDrawingOrder(unitId, unitId)).toBe(order);
});
it('mirrors actual drawing insertion and removal and emits their notifications', () => {
const added = vi.fn();
const removed = vi.fn();
const addSubscription = manager.add$.subscribe(added);
const removeSubscription = manager.remove$.subscribe(removed);
expect(commands.syncExecuteCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: [drawing('c')],
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeDefined();
expect(added).toHaveBeenCalled();
expect(commands.syncExecuteCommand(RemoveDocDrawingCommand.id, {
unitId,
drawings: [{ unitId, subUnitId: unitId, drawingId: 'c', drawingType: DrawingTypeEnum.DRAWING_IMAGE }],
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeUndefined();
expect(docDrawings.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeUndefined();
expect(removed).toHaveBeenCalled();
addSubscription.unsubscribe();
removeSubscription.unsubscribe();
});
it('registers scoped note images and removes them when their note is deleted', () => {
const body = model.getBody()!;
const referencedBody = {
...body,
dataStream: `\uFFFC${body.dataStream}`,
paragraphs: body.paragraphs?.map((paragraph) => ({ ...paragraph, startIndex: paragraph.startIndex + 1 })),
sectionBreaks: body.sectionBreaks?.map((section) => ({ ...section, startIndex: section.startIndex + 1 })),
customBlocks: body.customBlocks?.map((block) => ({ ...block, startIndex: block.startIndex + 1 })),
customRanges: [{
rangeId: 'ref',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 0,
endIndex: 0,
wholeEntity: true,
properties: { noteId: 'note' },
}],
};
const note = { type: 'footnote' as const, noteId: 'note', body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'np' }],
customBlocks: [{ startIndex: 0, blockId: 'note-image' }],
}, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] };
const jsonX = JSONX.getInstance();
mutate(JSONX.compose(jsonX.replaceOp(['body'], body, referencedBody), jsonX.insertOp(['notes', 'note'], note)));
const search = { unitId, subUnitId: unitId, drawingId: 'note-image' };
expect(manager.getDrawingByParam(search)?.drawingId).toBe('note-image');
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b', 'note-image']);
expect(commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'note-image', key: 'size', value: { width: 70, height: 35 } }],
})).toBe(true);
expect(manager.getDrawingByParam(search)).toMatchObject({ docTransform: { size: { width: 70, height: 35 } } });
expect(model.getDrawings()).not.toHaveProperty('note-image');
mutate(jsonX.replaceOp(['body'], model.getBody(), body));
expect(model.getSnapshot().notes?.note).toBeUndefined();
expect(manager.getDrawingByParam(search)).toBeUndefined();
expect(docDrawings.getDrawingByParam(search)).toBeUndefined();
expect(manager.getDrawingByParam({ ...search, drawingId: 'a' })).toBeDefined();
});
it('updates persisted order and keeps behind-text drawings below foreground drawings', () => {
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b']);
});
it('still rebuilds render order when wrapping changes the behind-text layer', () => {
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b']);
expect(commands.syncExecuteCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a' }],
wrappingStyle: TextWrappingStyle.IN_FRONT_OF_TEXT,
})).toBe(true);
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
});
it('removes optional metadata from current data while retaining it for old-state comparisons', () => {
mutate(JSONX.getInstance().insertOp(['drawings', 'a', 'description'], 'caption'));
mutate(JSONX.getInstance().removeOp(['drawings', 'a', 'description'], 'caption'));
const search = { unitId, subUnitId: unitId, drawingId: 'a' };
expect(manager.getDrawingByParam(search)).not.toHaveProperty('description');
expect(manager.getOldDrawingByParam(search)).toHaveProperty('description', 'caption');
});
it('refreshes drawing positions before controls on actual undo and redo', async () => {
manager.focusDrawing([{ unitId, subUnitId: unitId, drawingId: 'a' }]);
resize();
const refreshDrawings = vi.spyOn(refresh, 'refreshDrawings');
const refreshControls = vi.spyOn(render.scene.getTransformerByCreate(), 'refreshControls');
for (const [commandId, expectedWidth] of [[UndoCommand.id, 40], [RedoCommand.id, 60]] as const) {
refreshDrawings.mockClear();
refreshControls.mockClear();
expect(await commands.executeCommand(commandId)).toBe(true);
expect(model.getDrawings()!.a.docTransform.size.width).toBe(expectedWidth);
expect(refreshDrawings).toHaveBeenCalledWith(render.with(DocSkeletonManagerService).getSkeleton());
expect(refreshDrawings.mock.invocationCallOrder[0]).toBeLessThan(refreshControls.mock.invocationCallOrder[0]);
}
});
it('preserves the resolved anchor when changing wrapping and refreshes the drawing afterward', () => {
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const page = skeleton.getSkeletonData()!.pages[0];
const anchor = page.skeDrawings.get('a')!;
expect(anchor).toBeDefined();
const expectedLeft = anchor.aLeft - page.marginLeft;
const expectedTop = anchor.aTop - anchor.blockAnchorTop;
const refreshDrawings = vi.spyOn(refresh, 'refreshDrawings');
expect(commands.syncExecuteCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a' }],
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
})).toBe(true);
expect(model.getDrawings()!.a.docTransform).toMatchObject({
positionH: { posOffset: expectedLeft },
positionV: { posOffset: expectedTop },
});
expect(refreshDrawings).toHaveBeenCalledWith(skeleton);
});
});

View File

@@ -0,0 +1,316 @@
/**
* 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 { IGalleryProps } from '@univerjs/design';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import {
BooleanNumber,
DataStreamTreeTokenType,
DisposableCollection,
DOC_DRAWING_PRINTING_COMPONENT_KEY,
DocumentFlavor,
DrawingTypeEnum,
IImageIoService,
ImageSourceType,
IURLImageService,
LocaleService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
TableAlignmentType,
TableRowHeightRule,
TableSizeType,
TableTextWrapType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocPrintInterceptorService } from '@univerjs/docs-ui';
import { DrawingManagerService, getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { DrawingImageClipService, DrawingRenderService } from '@univerjs/drawing-ui';
import { Documents, DocumentSkeleton, DocumentViewModel, Engine, Scene } from '@univerjs/engine-render';
import { ComponentManager, IGalleryService } from '@univerjs/ui';
import { EMPTY, Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountDocPrintingFloatDom } from '../../views/DocPrintingFloatDom';
import { DocDrawingPrintingController } from '../doc-drawing-printing.controller';
vi.mock('../../views/DocPrintingFloatDom', () => ({
mountDocPrintingFloatDom: vi.fn(() => vi.fn()),
}));
describe('DocDrawingPrintingController with real layout and drawing services', () => {
let univer: Univer;
let engine: Engine;
let scene: Scene;
let skeleton: DocumentSkeleton;
let documents: Documents;
let printService: DocPrintInterceptorService;
const unitId = 'print-images';
const imageIds = ['body-first', 'cell-image', 'body-second', 'header-image', 'footer-image'];
beforeEach(() => {
const context = new Proxy({
font: '',
webkitBackingStorePixelRatio: 1,
measureText: (text: string) => ({
width: text.length * 8,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
}),
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
const cachedImage = document.createElement('img');
Object.defineProperties(cachedImage, {
complete: { value: true },
naturalWidth: { value: 16 },
naturalHeight: { value: 16 },
});
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IImageIoService, { useValue: {
change$: EMPTY,
setWaitCount: () => {},
getImage: async () => '',
saveImage: async () => null,
getImageSourceCache: () => cachedImage,
addImageSourceCache: () => {},
} }]);
injector.add([IURLImageService, { useValue: {
getImage: async (url: string) => url,
downloadImage: async () => new Blob(),
registerURLImageDownloader: () => ({ dispose: () => {} }),
} }]);
injector.add([IGalleryService, { useValue: {
gallery$: new Subject<IGalleryProps>(),
open: () => ({ dispose: () => {} }),
close: () => {},
} }]);
injector.add([DrawingImageClipService]);
injector.add([DrawingRenderService]);
injector.add([ComponentManager]);
injector.add([DocPrintInterceptorService]);
injector.add([DocDrawingPrintingController]);
const T = DataStreamTreeTokenType;
const table = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${table}${T.PARAGRAPH}${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`;
const drawings: Record<string, IDocDrawing> = Object.fromEntries(imageIds.map((drawingId) => [drawingId, {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: drawingId,
transform: { left: 10, top: 10, width: 16, height: 16, angle: 0 },
docTransform: {
size: { width: 16, height: 16 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
angle: 0,
},
layoutType: PositionedObjectLayoutType.INLINE,
}]));
const segmentBody = (blockId: string) => ({
dataStream: `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: `${blockId}-p` }],
sectionBreaks: [{ startIndex: 2, sectionId: `${blockId}-s` }],
customBlocks: [{ startIndex: 0, blockId }],
});
const snapshot: IDocumentData = {
id: unitId,
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
paragraphId: `p-${index}`,
paragraphStyle: { pageBreakBefore: index === 3 ? BooleanNumber.TRUE : BooleanNumber.FALSE },
})),
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ startIndex: match.index!, sectionId: `s-${index}` })),
customBlocks: [
{ startIndex: 0, blockId: 'body-first' },
{ startIndex: 5, blockId: 'cell-image' },
{ startIndex: dataStream.length - 3, blockId: 'body-second' },
],
tables: [{ startIndex: 2, endIndex: 2 + table.length, tableId: 'table' }],
},
headers: { header: { headerId: 'header', body: segmentBody('header-image') } },
footers: { footer: { footerId: 'footer', body: segmentBody('footer-image') } },
drawings,
drawingsOrder: imageIds,
tableSource: {
table: {
tableId: 'table',
align: TableAlignmentType.START,
indent: { v: 0 },
textWrap: TableTextWrapType.NONE,
position: {
positionH: { relativeFrom: ObjectRelativeFromH.PAGE },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE },
},
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
size: { type: TableSizeType.SPECIFIED, width: { v: 160 } },
tableRows: [{
tableCells: [{}],
trHeight: { val: { v: 25 }, hRule: TableRowHeightRule.AT_LEAST },
}],
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: 160 } } }],
},
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 240, height: 400 },
marginTop: 50,
marginBottom: 50,
marginLeft: 20,
marginRight: 20,
marginHeader: 10,
marginFooter: 10,
defaultHeaderId: 'header',
defaultFooterId: 'footer',
},
};
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, snapshot);
skeleton = DocumentSkeleton.create(new DocumentViewModel(model), injector.get(LocaleService));
skeleton.calculate();
expect(skeleton.getSkeletonData()!.pages).toHaveLength(2);
engine = new Engine('print-engine', { elementWidth: 240, elementHeight: 400 });
scene = new Scene('print-scene', engine);
documents = new Documents('print-document', skeleton);
const drawingManager = injector.get(IDrawingManagerService);
drawingManager.registerDrawingData(unitId, { [unitId]: { data: drawings, order: imageIds } });
drawingManager.setDrawingEditable(false);
printService = injector.get(DocPrintInterceptorService);
injector.get(DocDrawingPrintingController);
});
afterEach(() => {
scene?.dispose();
engine?.dispose();
skeleton?.dispose();
univer?.dispose();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function collect(pageIndex?: number): void {
printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_COMPONENT_COLLECT
)(undefined, { unitId, scene, engine, skeleton, documents, pageIndex, root: document.createElement('div') });
}
function renderedImageIds(): string[] {
return imageIds.filter((drawingId) => scene.getObject(getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId: unitId, drawingId })) != null);
}
it('prints table-cell images with their host page and omits the other body page', () => {
const page = skeleton.getSkeletonData()!.pages[0];
expect(page.skeTables.get('table')!.rows[0].cells[0].skeDrawings.has('cell-image')).toBe(true);
collect(0);
expect(renderedImageIds()).toEqual(['body-first', 'cell-image', 'header-image', 'footer-image']);
});
it('prints repeated header and footer images on the second page', () => {
const data = skeleton.getSkeletonData()!;
expect(data.skeHeaders.get(data.pages[1].headerId)!.get(data.pages[1].pageWidth)!.skeDrawings.has('header-image')).toBe(true);
expect(data.skeFooters.get(data.pages[1].footerId)!.get(data.pages[1].pageWidth)!.skeDrawings.has('footer-image')).toBe(true);
collect(1);
expect(renderedImageIds()).toEqual(['body-second', 'header-image', 'footer-image']);
});
it('keeps unfiltered drawing collection when there is no traditional page index', () => {
collect();
expect(renderedImageIds()).toEqual(imageIds);
});
it('does not render another page when the requested print page is missing', () => {
collect(2);
expect(renderedImageIds()).toEqual([]);
});
it.each([
[0, ['body-first', 'cell-image', 'header-image', 'footer-image']],
[1, ['body-second', 'header-image', 'footer-image']],
] as const)('collects DOM drawings only from page %i including its tables and repeated segments', (pageIndex, expectedIds) => {
const injector = univer.__getInjector();
const manager = injector.get(IDrawingManagerService);
const drawings = Object.fromEntries(imageIds.map((drawingId) => [drawingId, {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_DOM,
componentKey: 'custom-dom',
}]));
manager.setDrawingData(unitId, unitId, drawings);
injector.get(ComponentManager).register('custom-dom', () => null);
const disposables = new DisposableCollection();
printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT
)(disposables, {
unitId,
scene,
engine,
skeleton,
pageIndex,
root: document.createElement('div'),
offset: { x: 0, y: 0 },
bound: { top: 0, left: 0, right: 240, bottom: 400 },
});
const props = vi.mocked(mountDocPrintingFloatDom).mock.calls[0][0];
expect(props.floatDomInfos.map((drawing) => drawing.drawingId)).toEqual(expectedIds);
disposables.dispose();
});
it('mounts chart and DOM print components and disposes their DOM mount', () => {
const injector = univer.__getInjector();
const manager = injector.get(IDrawingManagerService);
const domDrawing = { unitId, subUnitId: unitId, drawingId: 'dom', drawingType: DrawingTypeEnum.DRAWING_DOM, componentKey: 'custom-dom' };
manager.setDrawingData(unitId, unitId, {
chart: { unitId, subUnitId: unitId, drawingId: 'chart', drawingType: DrawingTypeEnum.DRAWING_CHART },
dom: domDrawing,
});
manager.setDrawingOrder(unitId, unitId, ['chart', 'dom']);
const chartComponent = () => null;
const domComponent = () => null;
injector.get(ComponentManager).register(DOC_DRAWING_PRINTING_COMPONENT_KEY, chartComponent);
injector.get(ComponentManager).register('custom-dom-print', domComponent);
printService.registerPrintComponent('custom-dom', 'custom-dom-print');
const unmount = vi.fn();
vi.mocked(mountDocPrintingFloatDom).mockReturnValueOnce(unmount);
const disposables = new DisposableCollection();
const root = document.createElement('div');
const result = printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT
)(disposables, { unitId, scene, engine, root, skeleton, offset: { x: 0, y: 0 }, bound: { top: 0, left: 0, right: 240, bottom: 400 } });
expect(mountDocPrintingFloatDom).toHaveBeenCalledWith(
expect.objectContaining({
floatDomInfos: [
expect.objectContaining({ drawingId: 'chart', componentKey: chartComponent }),
expect.objectContaining({ drawingId: 'dom', componentKey: domComponent }),
],
}),
root,
injector
);
expect(result).toBe(disposables);
disposables.dispose();
expect(unmount).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,690 @@
/**
* 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 {
IDocumentSkeletonColumn,
IDocumentSkeletonDivide,
IDocumentSkeletonGlyph,
IDocumentSkeletonLine,
IDocumentSkeletonPage,
IDocumentSkeletonSection,
} from '@univerjs/engine-render';
import { BooleanNumber, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
import { UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { NodePositionConvertToCursor } from '@univerjs/docs-ui';
import { DocumentSkeletonPageType } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import {
IMoveInlineDrawingCommand,
ITransformNonInlineDrawingCommand,
} from '../../commands/commands/update-doc-drawing.command';
import {
DocDrawingTransformerController,
getDocsTableCellAnchorContext,
shouldUseDocsDrawingOuterPageOrigin,
} from '../doc-drawing-transformer-update.controller';
import { getDocsOverlayRuntimeDrawing } from '../render-controllers/doc-drawing-transform-update.controller';
function createController() {
const controller = Object.create(DocDrawingTransformerController.prototype) as any;
controller._commandService = { executeCommand: vi.fn() };
controller._drawingManagerService = {
getDrawingOKey: vi.fn((oKey: string) => ({
unitId: 'unit-1',
subUnitId: 'doc-1',
drawingId: oKey.replace('object-', 'drawing-'),
})),
};
controller._transformerCache = new Map();
controller._liquid = {
x: 0,
y: 0,
reset: vi.fn(),
translatePage: vi.fn((_page: any) => {
controller._liquid.y += 140;
}),
};
controller._renderManagerService = {
getRenderUnitById: vi.fn(() => null),
};
return controller;
}
function drawing(drawingId = 'drawing-1') {
return {
drawingId,
unitId: 'unit-1',
subUnitId: 'doc-1',
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
docTransform: {
size: { width: 80, height: 60 },
angle: 0,
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 10,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 20,
},
},
};
}
describe('DocDrawingTransformerController business methods', () => {
it('uses the latest model transform only while both drawing states are overlay-only', () => {
const skeletonOverlay = {
...drawing(),
layoutType: PositionedObjectLayoutType.WRAP_NONE,
};
const currentOverlay = {
...skeletonOverlay,
docTransform: {
...skeletonOverlay.docTransform,
size: { width: 160, height: 120 },
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 50,
},
},
};
expect(getDocsOverlayRuntimeDrawing(skeletonOverlay, currentOverlay)).toBe(currentOverlay);
expect(getDocsOverlayRuntimeDrawing(drawing(), currentOverlay)).not.toBe(currentOverlay);
expect(getDocsOverlayRuntimeDrawing(skeletonOverlay, drawing())).not.toBe(currentOverlay);
});
it('builds drawing transform updates for multi-select resize, rotation, and move changes', () => {
const controller = createController();
const drawingData = drawing();
controller._getPageContentSize = vi.fn(() => ({ width: 100, height: 90 }));
controller._transformerCache.set('drawing-1', {
drawing: drawingData,
top: 20,
left: 10,
width: 80,
height: 60,
angle: 0,
});
controller._updateMultipleDrawingDocTransform(new Map([
['object-1', {
oKey: 'object-1',
left: 25,
top: 45,
width: 160,
height: 120,
angle: 30,
}],
]));
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'unit-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'drawing-1', key: 'size', value: { width: 100, height: 90 } },
{ drawingId: 'drawing-1', key: 'angle', value: 30 },
{ drawingId: 'drawing-1', key: 'positionV', value: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 45 } },
{ drawingId: 'drawing-1', key: 'positionH', value: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 25 } },
],
});
});
it('updates inline drawing size and angle while clamping to page content size', () => {
const controller = createController();
controller._getPageContentSize = vi.fn(() => ({ width: 120, height: 90 }));
controller._updateDrawingSize({
drawing: drawing(),
top: 0,
left: 0,
width: 80,
height: 60,
angle: 0,
}, {
width: 300,
height: 100,
angle: 15,
});
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'unit-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'drawing-1', key: 'size', value: { width: 120, height: 90 } },
{ drawingId: 'drawing-1', key: 'angle', value: 15 },
],
});
});
it('moves inline drawings to the resolved anchor and refreshes when no anchor is available', () => {
const controller = createController();
controller._getInlineDrawingAnchor = vi.fn(() => ({
offset: 12,
segmentId: 'segment-1',
segmentPage: 2,
}));
controller._moveInlineDrawing(drawing(), 40, 50);
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(IMoveInlineDrawingCommand.id, {
unitId: 'unit-1',
subUnitId: 'unit-1',
drawing: drawing(),
offset: 12,
segmentId: 'segment-1',
segmentPage: 2,
needRefreshDrawings: false,
});
controller._commandService.executeCommand.mockClear();
controller._getInlineDrawingAnchor.mockReturnValue(null);
controller._moveInlineDrawing(drawing(), 400, 500);
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(IMoveInlineDrawingCommand.id, expect.objectContaining({
offset: undefined,
needRefreshDrawings: true,
}));
});
it('transforms non-inline drawings through a new anchor and falls back to direct transform when no anchor is found', () => {
const controller = createController();
const docTransform = {
size: { width: 90, height: 70 },
positionH: { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 11 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 22 },
};
controller._limitDrawingInPage = vi.fn((_drawing: any, object: any) => object);
controller._getDrawingAnchor = vi.fn(() => ({
offset: 23,
docTransform,
segmentId: 'body',
segmentPage: 0,
}));
controller._nonInlineDrawingTransform(drawing(), { left: 8, top: 9, width: 90, height: 70 });
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(ITransformNonInlineDrawingCommand.id, {
unitId: 'unit-1',
subUnitId: 'unit-1',
drawing: drawing(),
offset: 23,
docTransform,
segmentId: 'body',
segmentPage: 0,
});
controller._commandService.executeCommand.mockClear();
controller._getDrawingAnchor.mockReturnValue(null);
controller._updateMultipleDrawingDocTransform = vi.fn();
controller._nonInlineDrawingTransform(drawing(), { left: 18, top: 19, width: 90, height: 70 });
expect(controller._updateMultipleDrawingDocTransform).toHaveBeenCalledWith(new Map([['drawing-1', {
left: 18,
top: 19,
width: 90,
height: 70,
}]]));
});
it('uses the outer page origin only for WRAP_NONE page-relative positions', () => {
const controller = createController();
const previousPage = {
type: DocumentSkeletonPageType.BODY,
marginLeft: 30,
marginTop: 40,
};
const anchorPage = {
type: DocumentSkeletonPageType.BODY,
marginLeft: 48,
marginTop: 64,
};
const glyph = {} as IDocumentSkeletonGlyph;
const divide = { glyphGroup: [glyph] } as IDocumentSkeletonDivide;
const line = {
divides: [divide],
paragraphIndex: 0,
paragraphStart: true,
top: 0,
} as IDocumentSkeletonLine;
const section = { parent: anchorPage, top: 0 } as unknown as IDocumentSkeletonSection;
const column = { left: 0, lines: [line], parent: section } as IDocumentSkeletonColumn;
Object.assign(glyph, { parent: divide });
Object.assign(divide, { parent: line });
Object.assign(line, { parent: column });
const skeleton = {
findNodeByCoord: vi.fn(() => ({
node: glyph,
segmentId: '',
segmentPage: 1,
})),
findPositionByGlyph: vi.fn(() => ({})),
getSkeletonData: vi.fn(() => ({
pages: [previousPage, anchorPage],
skeFooters: new Map(),
skeHeaders: new Map(),
})),
};
const renderDependency = {
getSegment: vi.fn(() => ''),
getSegmentPage: vi.fn(() => 1),
getSkeleton: vi.fn(() => skeleton),
};
const mainComponent = {
getOffsetConfig: vi.fn(() => ({
docsLeft: 5,
docsTop: 7,
pageLayoutType: 0,
pageMarginLeft: 0,
pageMarginTop: 20,
})),
};
controller._renderManagerService.getRenderUnitById.mockReturnValue({
components: new Map(),
engine: {},
mainComponent,
scene: {
getViewports: vi.fn(() => [{}]),
getViewportScrollXY: vi.fn(() => ({ x: 0, y: 0 })),
},
with: vi.fn(() => renderDependency),
});
controller._getPageContentSize = vi.fn(() => ({ width: 1000, height: 1000 }));
controller._getTransformCoordForDocumentOffset = vi.fn(() => ({}));
controller._liquid = {
x: 0,
y: 0,
reset: vi.fn(() => {
controller._liquid.x = 0;
controller._liquid.y = 0;
}),
restorePagePadding: vi.fn((page: { marginLeft: number; marginTop: number }) => {
controller._liquid.x -= page.marginLeft;
controller._liquid.y -= page.marginTop;
}),
translatePage: vi.fn(() => {
controller._liquid.x += 300;
controller._liquid.y += 400;
}),
translatePagePadding: vi.fn((page: { marginLeft: number; marginTop: number }) => {
controller._liquid.x += page.marginLeft;
controller._liquid.y += page.marginTop;
}),
};
const rangeSpy = vi.spyOn(NodePositionConvertToCursor.prototype, 'getRangePointData').mockReturnValue({
borderBoxPointGroup: [],
contentBoxPointGroup: [],
cursorList: [{ collapsed: true, endOffset: 0, startOffset: 0 }],
} as ReturnType<NodePositionConvertToCursor['getRangePointData']>);
const overlayDrawing = {
...drawing(),
layoutType: PositionedObjectLayoutType.WRAP_NONE,
};
const object = {
angle: 0,
height: 60,
left: 395,
top: 527,
width: 80,
};
const anchor = controller._getDrawingAnchor(overlayDrawing, object);
expect(anchor?.docTransform.positionH).toEqual({
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 90,
});
expect(anchor?.docTransform.positionV).toEqual({
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 120,
});
const wrappedAnchor = controller._getDrawingAnchor(drawing(), object);
expect(wrappedAnchor?.docTransform.positionH.posOffset).toBe(42);
expect(wrappedAnchor?.docTransform.positionV.posOffset).toBe(120);
const topAndBottomAnchor = controller._getDrawingAnchor({
...drawing(),
layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
}, object);
expect(topAndBottomAnchor?.docTransform.positionH.posOffset).toBe(42);
expect(topAndBottomAnchor?.docTransform.positionV.posOffset).toBe(120);
rangeSpy.mockRestore();
});
it('matches the render-side page origin for body and header/footer drawings', () => {
const bodyPage = {
type: DocumentSkeletonPageType.BODY,
pageWidth: 816,
pageHeight: 1056,
} as IDocumentSkeletonPage;
const headerPage = {
type: DocumentSkeletonPageType.HEADER,
pageWidth: 816,
pageHeight: 120,
} as IDocumentSkeletonPage;
const overlayDrawing = {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
};
expect(shouldUseDocsDrawingOuterPageOrigin({
drawing: overlayDrawing,
height: 60,
page: bodyPage,
width: 80,
})).toBe(true);
expect(shouldUseDocsDrawingOuterPageOrigin({
drawing: overlayDrawing,
height: 60,
hostPage: bodyPage,
page: headerPage,
width: 80,
})).toBe(false);
expect(shouldUseDocsDrawingOuterPageOrigin({
drawing: overlayDrawing,
height: 1055,
hostPage: bodyPage,
page: headerPage,
width: 815,
})).toBe(true);
expect(shouldUseDocsDrawingOuterPageOrigin({
drawing: {
...overlayDrawing,
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
},
height: 60,
page: bodyPage,
width: 80,
})).toBe(false);
});
it('limits a drawing away from the gap between document pages while preserving drawings already on one page', () => {
const controller = createController();
controller._renderManagerService.getRenderUnitById.mockReturnValue({
mainComponent: {
top: 5,
pageLayoutType: 0,
pageMarginLeft: 0,
pageMarginTop: 20,
getOffsetConfig: () => ({
pageLayoutType: 0,
pageMarginLeft: 0,
pageMarginTop: 20,
}),
},
with: vi.fn(() => ({
getSkeleton: () => ({
getSkeletonData: () => ({
pages: [
{ pageHeight: 100, marginBottom: 10, marginTop: 10 },
{ pageHeight: 100, marginBottom: 10, marginTop: 10 },
],
}),
}),
})),
});
expect(controller._limitDrawingInPage(drawing(), {
left: 1,
top: 85,
width: 30,
height: 20,
angle: 0,
})).toMatchObject({ top: 75 });
expect(controller._limitDrawingInPage(drawing(), {
left: 1,
top: 30,
width: 30,
height: 20,
angle: 0,
})).toMatchObject({ top: 30 });
});
it('resolves only real table-cell anchors to their table, row, and host page context', () => {
expect(getDocsTableCellAnchorContext('unit-1', { parent: null } as any)).toBeNull();
const hostPage = { type: 'body' };
const cell = { left: 1, marginLeft: 2, marginTop: 3 };
const row = { cells: [], top: 4 };
const table = { left: 5, top: 6, tableId: 'table-1#-#0', parent: hostPage };
Object.assign(row, { cells: [cell], parent: table });
Object.assign(cell, { parent: row });
expect(getDocsTableCellAnchorContext('unit-1', cell as any)).toMatchObject({
cell,
row,
table,
hostPage,
});
});
it('limits page content size by the page containing the drawing and falls back when the skeleton is unavailable', () => {
const controller = createController();
controller._renderManagerService.getRenderUnitById.mockReturnValue({
with: vi.fn(() => ({
getSkeleton: () => ({
getSkeletonData: () => ({
pages: [
{
pageWidth: 720,
pageHeight: 960,
marginLeft: 40,
marginRight: 60,
marginTop: 50,
marginBottom: 70,
skeDrawings: new Map([['drawing-1', {}]]),
},
],
}),
}),
})),
});
expect(controller._getPageContentSize(drawing())).toEqual({ width: 620, height: 840 });
controller._renderManagerService.getRenderUnitById.mockReturnValue({
with: vi.fn(() => ({
getSkeleton: () => ({
getSkeletonData: () => ({
pages: [
{
isLayoutPlaceholder: true,
pageWidth: 720,
pageHeight: 960,
marginLeft: 40,
marginRight: 60,
marginTop: 50,
marginBottom: 70,
skeDrawings: new Map([['drawing-1', {}]]),
},
],
}),
}),
})),
});
expect(controller._getPageContentSize(drawing())).toEqual({ width: 500, height: 500 });
controller._renderManagerService.getRenderUnitById.mockReturnValue(null);
expect(controller._getPageContentSize(drawing())).toEqual({ width: 500, height: 500 });
});
it('creates and updates the inline anchor from resolved text range points', () => {
const controller = createController();
const scene = { addObject: vi.fn() };
controller._renderManagerService.getRenderUnitById.mockReturnValue({
mainComponent: {
getOffsetConfig: () => ({ docsLeft: 8, docsTop: 12 }),
},
scene,
});
controller._createOrUpdateInlineAnchor('unit-1', [[
{ x: 10, y: 20 },
{ x: 10, y: 40 },
{ x: 10, y: 60 },
]]);
expect(scene.addObject).toHaveBeenCalledTimes(1);
const anchor = controller._anchorShape;
const transformByState = vi.spyOn(anchor, 'transformByState');
const show = vi.spyOn(anchor, 'show');
controller._createOrUpdateInlineAnchor('unit-1', [[
{ x: 14, y: 26 },
{ x: 14, y: 50 },
{ x: 14, y: 76 },
]]);
expect(transformByState).toHaveBeenCalledWith(expect.objectContaining({
left: 22,
top: 38,
height: 50,
}));
expect(show).toHaveBeenCalled();
});
it('finds the scene transformer for a drawing search only when render context exists', () => {
const controller = createController();
const transformer = {};
const scene = { getTransformerByCreate: vi.fn(() => transformer) };
expect(controller._getSceneAndTransformerByDrawingSearch(null)).toBeUndefined();
controller._renderManagerService.getRenderUnitById.mockReturnValue({});
expect(controller._getSceneAndTransformerByDrawingSearch('unit-1')).toBeUndefined();
controller._renderManagerService.getRenderUnitById.mockReturnValue({ scene });
expect(controller._getSceneAndTransformerByDrawingSearch('unit-1')).toEqual({ scene, transformer });
});
it('updates the inline drawing anchor only when a single drawing is being transformed', () => {
const controller = createController();
const points = [[
{ x: 1, y: 2 },
{ x: 1, y: 3 },
{ x: 1, y: 4 },
]];
controller._getInlineDrawingAnchor = vi.fn(() => ({ contentBoxPointGroup: points }));
controller._createOrUpdateInlineAnchor = vi.fn();
controller._updateInlineDrawingAnchor(drawing(), 10, 20);
expect(controller._createOrUpdateInlineAnchor).not.toHaveBeenCalled();
controller._transformerCache.set('drawing-1', {});
controller._updateInlineDrawingAnchor(drawing(), 10, 20);
expect(controller._createOrUpdateInlineAnchor).toHaveBeenCalledWith('unit-1', points);
});
it('converts pointer offsets from viewport coordinates into document coordinates', () => {
const controller = createController();
const invertedTransform = {
applyPoint: vi.fn((point) => ({ x: point.x - 5, y: point.y - 7 })),
};
const documentTransform = {
clone: vi.fn(() => ({
invert: vi.fn(() => invertedTransform),
})),
};
const document = {
getOffsetConfig: () => ({ documentTransform }),
};
const viewport = {
transformVector2SceneCoord: vi.fn(() => ({ x: 30, y: 40 })),
};
expect(controller._getTransformCoordForDocumentOffset(document, viewport, 12, 18)).toEqual({ x: 25, y: 33 });
viewport.transformVector2SceneCoord.mockReturnValueOnce(undefined as never);
expect(controller._getTransformCoordForDocumentOffset(document, viewport, 12, 18)).toBeUndefined();
});
it('listens to transformer changes for inline drawings and commits resize or move business actions', () => {
const controller = createController();
const changeStart$ = new Subject<any>();
const changing$ = new Subject<any>();
const changeEnd$ = new Subject<any>();
const transformer = { changeStart$, changing$, changeEnd$ };
const object = {
oKey: 'object-inline',
left: 10,
top: 20,
width: 80,
height: 60,
angle: 0,
setOpacity: vi.fn(),
};
const inlineDrawing = {
...drawing('drawing-inline'),
layoutType: PositionedObjectLayoutType.INLINE,
};
controller.disposeWithMe = vi.fn();
controller._renderManagerService.getRenderUnitById.mockReturnValue({
scene: {
getTransformerByCreate: vi.fn(() => transformer),
},
});
controller._drawingManagerService.getDrawingOKey = vi.fn(() => ({
unitId: 'unit-1',
subUnitId: 'doc-1',
drawingId: 'drawing-inline',
}));
controller._univerInstanceService = {
getUnit: vi.fn(() => ({
getSnapshot: () => ({
drawings: {
'drawing-inline': inlineDrawing,
},
}),
})),
};
controller._getPageContentSize = vi.fn(() => ({ width: 500, height: 500 }));
controller._updateInlineDrawingAnchor = vi.fn();
controller._anchorShape = { hide: vi.fn() };
controller._listenTransformerChange('unit-1');
changeStart$.next({ objects: new Map([['object-inline', object]]) });
expect(object.setOpacity).toHaveBeenCalledWith(0.2);
expect(controller._transformerCache.get('drawing-inline')).toMatchObject({
drawing: inlineDrawing,
top: 20,
left: 10,
});
changing$.next({
objects: new Map([['object-inline', { ...object, left: 30 }]]),
offsetX: 40,
offsetY: 50,
});
expect(controller._updateInlineDrawingAnchor).toHaveBeenCalledWith(inlineDrawing, 40, 50);
changeEnd$.next({
objects: new Map([['object-inline', { ...object, width: 100 }]]),
offsetX: 40,
offsetY: 50,
});
expect(object.setOpacity).toHaveBeenLastCalledWith(1);
expect(controller._anchorShape.hide).toHaveBeenCalled();
expect(controller._commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'unit-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'drawing-inline', key: 'size', value: { width: 100, height: 60 } },
],
});
expect(controller._transformerCache.size).toBe(0);
});
});

View File

@@ -0,0 +1,903 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DrawingTypeEnum, UniverInstanceType } from '@univerjs/core';
import { InsertDocDrawingCommand } from '@univerjs/docs-drawing';
import { SetDocZoomRatioOperation } from '@univerjs/docs-ui';
import { Rect } from '@univerjs/engine-render';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import {
calcDocFloatDomPositionByRect,
DocFloatDomController,
mergeDocFloatDomRuntimeProps,
} from '../doc-float-dom.controller';
function createScene() {
const viewport = { viewportScrollX: 10, viewportScrollY: 20, onScrollAfter$: new Subject() };
return {
viewport,
getViewport: vi.fn(() => viewport),
getAncestorScale: vi.fn(() => ({ scaleX: 2, scaleY: 3 })),
getTransformerByCreate: vi.fn(() => ({})),
removeObject: vi.fn(),
};
}
function createController(options: {
drawing?: Record<string, unknown>;
rects?: Rect[];
page?: any;
renderType?: UniverInstanceType;
refreshDrawingOnAdd?: { left: number; top: number; width: number; height: number; angle: number };
resolveRefreshDrawing?: () => { left: number; top: number; width: number; height: number; angle: number } | undefined;
} = {}) {
const add$ = new Subject<any[]>();
const remove$ = new Subject<any[]>();
const refreshTransform$ = new Subject<any[]>();
const currentSkeleton$ = new Subject<any>();
const skeletonDirty$ = new Subject<boolean>();
const renderCreated$ = new Subject<any>();
const renderDisposed$ = new Subject<string>();
const commandHandlers: Array<(command: { id: string; params?: unknown }) => void> = [];
const scene = createScene();
const canvas = { dispatchEvent: vi.fn() };
const docRefreshDrawingsService = new DocRefreshDrawingsService();
const refreshDrawings = vi.spyOn(docRefreshDrawingsService, 'refreshDrawings');
docRefreshDrawingsService.refreshDrawings$.subscribe((skeleton) => {
const transform = options.resolveRefreshDrawing?.() ?? options.refreshDrawingOnAdd;
if (skeleton != null && transform) {
refreshTransform$.next([{
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'dom-1',
transform,
}]);
}
});
const skeleton = {
dirty$: skeletonDirty$,
getSkeletonData: () => ({ pages: [options.page ?? { pageWidth: 240, marginLeft: 20, marginRight: 30 }] }),
};
const renderUnit = {
unitId: 'doc-1',
type: options.renderType ?? UniverInstanceType.UNIVER_DOC,
scene,
engine: { getCanvasElement: () => canvas },
isDisposed: vi.fn(() => false),
with: vi.fn(() => ({
currentSkeleton$,
getSkeleton: () => skeleton,
})),
};
const renderManagerService = {
created$: renderCreated$,
disposed$: renderDisposed$,
getRenderUnitById: vi.fn(() => renderUnit),
};
const drawing = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'dom-1',
componentKey: 'FloatDom',
drawingType: DrawingTypeEnum.DRAWING_DOM,
data: { value: 1 },
...options.drawing,
};
const drawingManagerService = {
add$,
remove$,
refreshTransform$,
getDrawingByParam: vi.fn(() => drawing),
};
const drawingRenderService = {
renderFloatDom: vi.fn(() => options.rects ?? []),
};
const domLayers: Array<[string, any]> = [];
const canvasFloatDomService = {
domLayers,
addFloatDom: vi.fn((layer) => {
domLayers.push([layer.id, layer]);
}),
updateFloatDom: vi.fn((id, patch) => {
const layer = domLayers.find(([layerId]) => layerId === id);
if (layer) {
Object.assign(layer[1], patch);
}
}),
removeFloatDom: vi.fn(),
};
const doc = { getUnitId: () => 'doc-1' };
const univerInstanceService = {
getUnit: vi.fn(() => doc),
getCurrentUnitOfType: vi.fn(() => doc),
getCurrentTypeOfUnit$: vi.fn(() => new BehaviorSubject(null)),
};
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
syncExecuteCommand: vi.fn(),
};
const controller = new DocFloatDomController(
renderManagerService as never,
drawingManagerService as never,
drawingRenderService as never,
canvasFloatDomService as never,
univerInstanceService as never,
commandService as never,
docRefreshDrawingsService
);
return {
controller,
add$,
currentSkeleton$,
remove$,
refreshTransform$,
commandHandlers,
scene,
canvas,
renderUnit,
drawingRenderService,
canvasFloatDomService,
commandService,
refreshDrawings,
renderCreated$,
renderDisposed$,
skeletonDirty$,
};
}
function publishEmbedRuntimeGeometry(
refreshTransform$: ReturnType<typeof createController>['refreshTransform$'],
transform = { left: 30, top: 50, width: 50, height: 40, angle: 0 }
) {
refreshTransform$.next([{
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'dom-1',
transform,
}]);
}
describe('DocFloatDomController', () => {
it('preserves existing props while adding custom block runtime viewport', () => {
expect(mergeDocFloatDomRuntimeProps({ keep: true }, {
customBlockRenderViewport: { bleedLeft: 96, bleedWidth: 1440, contentHeight: 720, contentWidth: 1280, height: 480, pageContentWidth: 1008, viewScale: 1.5, viewportHeight: 320 },
} as never)).toEqual({
customBlockRenderViewport: { bleedLeft: 96, bleedWidth: 1440, contentHeight: 720, contentWidth: 1280, height: 480, pageContentWidth: 1008, viewScale: 1.5, viewportHeight: 320 },
keep: true,
});
});
it('keeps existing props when no valid runtime viewport is available', () => {
expect(mergeDocFloatDomRuntimeProps({ keep: true }, {
customBlockRenderViewport: { contentWidth: 0 },
} as never)).toEqual({ keep: true });
});
it('calculates float dom bounds using doc viewport scroll and ancestor scale', () => {
const scene = createScene();
expect(calcDocFloatDomPositionByRect({ left: 30, top: 50, right: 80, bottom: 90 }, scene as never, 0.4, 15)).toEqual({
startX: 40,
startY: 90,
endX: 140,
endY: 210,
width: 100,
height: 120,
rotate: 15,
absolute: { left: false, top: false },
opacity: 0.4,
});
});
it('updates float dom position after host doc zoom without rewriting runtime viewport', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
});
const { add$, canvasFloatDomService, commandHandlers, controller, scene } = createController({
rects: [rect],
drawing: {
customBlockRenderViewport: {
contentHeight: 240,
height: 240,
viewScale: 1,
viewportHeight: 120,
},
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$ as BehaviorSubject<unknown>;
scene.getAncestorScale.mockReturnValue({ scaleX: 1, scaleY: 1 });
commandHandlers.forEach((handler) => handler({
id: SetDocZoomRatioOperation.id,
params: { unitId: 'doc-1', zoomRatio: 2 },
}));
scene.getAncestorScale.mockReturnValue({ scaleX: 2, scaleY: 2 });
await Promise.resolve();
expect(position$.getValue()).toMatchObject({
startX: 40,
startY: 60,
width: 100,
height: 480,
});
expect(canvasFloatDomService.updateFloatDom).not.toHaveBeenCalled();
controller.dispose();
});
it('adds rendered float doms, updates their position, and removes them with their rect object', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
rect.setOpacity(0.8);
const { controller, add$, remove$, scene, drawingRenderService, canvasFloatDomService } = createController({ rects: [rect] });
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
expect(drawingRenderService.renderFloatDom).toHaveBeenCalledWith(expect.objectContaining({ drawingId: 'dom-1' }), scene);
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({
id: 'dom-1',
componentKey: 'FloatDom',
unitId: 'doc-1',
data: { value: 1 },
}));
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
rect.transformByState({ left: 40, top: 60 });
expect(positions.at(-1)).toMatchObject({
startX: 60,
startY: 120,
width: 100,
height: 120,
opacity: 0.8,
});
remove$.next([{ drawingId: 'dom-1' }]);
expect(canvasFloatDomService.removeFloatDom).toHaveBeenCalledWith('dom-1');
expect(scene.removeObject).toHaveBeenCalledWith(rect);
sub.unsubscribe();
controller.dispose();
});
it('keeps embed custom block float doms visible when focus moves into child units', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({
eventPassThrough: false,
preserveOnFocusChange: true,
}));
controller.dispose();
});
it('waits for layout runtime geometry before mounting an embed custom block', () => {
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const { controller, add$, refreshTransform$, drawingRenderService, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(drawingRenderService.renderFloatDom).not.toHaveBeenCalled();
expect(canvasFloatDomService.addFloatDom).not.toHaveBeenCalled();
publishEmbedRuntimeGeometry(refreshTransform$, { left: 300, top: 500, width: 50, height: 40, angle: 0 });
expect(drawingRenderService.renderFloatDom).toHaveBeenCalledOnce();
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledOnce();
expect(canvasFloatDomService.addFloatDom.mock.calls[0][0].position$.getValue()).toMatchObject({
startX: 580,
startY: 1440,
width: 100,
height: 120,
});
controller.dispose();
});
it('refreshes current layout geometry when an embed drawing is added after publication', () => {
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const { controller, add$, refreshDrawings, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
refreshDrawingOnAdd: { left: 300, top: 500, width: 50, height: 40, angle: 0 },
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(refreshDrawings).toHaveBeenCalledOnce();
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledOnce();
expect(canvasFloatDomService.addFloatDom.mock.calls[0][0].position$.getValue()).toMatchObject({
startX: 580,
startY: 1440,
width: 100,
height: 120,
});
controller.dispose();
});
it('does not resolve layout services from a disposed render unit', () => {
const { controller, add$, renderUnit, refreshDrawings } = createController({
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
renderUnit.isDisposed.mockReturnValue(true);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(renderUnit.with).not.toHaveBeenCalled();
expect(refreshDrawings).not.toHaveBeenCalled();
controller.dispose();
});
it('does not resolve Doc layout services from a non-Doc render unit', () => {
const { controller, add$, renderUnit, refreshDrawings } = createController({
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
renderType: UniverInstanceType.UNIVER_SHEET,
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(renderUnit.with).not.toHaveBeenCalled();
expect(refreshDrawings).not.toHaveBeenCalled();
controller.dispose();
});
it('resumes pending embed geometry when the host render is recreated', () => {
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const {
controller,
add$,
canvasFloatDomService,
renderCreated$,
renderDisposed$,
renderUnit,
} = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
refreshDrawingOnAdd: { left: 300, top: 500, width: 50, height: 40, angle: 0 },
});
renderUnit.isDisposed.mockReturnValue(true);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(canvasFloatDomService.addFloatDom).not.toHaveBeenCalled();
renderDisposed$.next('doc-1');
renderUnit.isDisposed.mockReturnValue(false);
renderCreated$.next(renderUnit);
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledOnce();
controller.dispose();
});
it('refreshes a pending embed drawing when the current skeleton becomes dirty', () => {
let geometry: { left: number; top: number; width: number; height: number; angle: number } | undefined;
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const { controller, add$, skeletonDirty$, refreshDrawings, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
resolveRefreshDrawing: () => geometry,
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
expect(canvasFloatDomService.addFloatDom).not.toHaveBeenCalled();
geometry = { left: 300, top: 500, width: 50, height: 40, angle: 0 };
skeletonDirty$.next(true);
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledOnce();
const refreshCountAfterMount = refreshDrawings.mock.calls.length;
skeletonDirty$.next(true);
expect(refreshDrawings).toHaveBeenCalledTimes(refreshCountAfterMount);
controller.dispose();
});
it('ignores hidden fallback geometry while an embed layout is incomplete', () => {
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const { controller, add$, refreshTransform$, drawingRenderService, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
refreshTransform$.next([{
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'dom-1',
hidden: true,
transform: { left: 10, top: 20, width: 50, height: 40, angle: 0 },
}]);
expect(drawingRenderService.renderFloatDom).not.toHaveBeenCalled();
expect(canvasFloatDomService.addFloatDom).not.toHaveBeenCalled();
publishEmbedRuntimeGeometry(refreshTransform$, { left: 300, top: 500, width: 50, height: 40, angle: 0 });
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const publishedPosition = position$.getValue();
refreshTransform$.next([{
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'dom-1',
hidden: true,
transform: { left: 10, top: 20, width: 50, height: 40, angle: 0 },
}]);
expect(position$.getValue()).toEqual(publishedPosition);
controller.dispose();
});
it('does not mount a removed embed custom block when runtime geometry arrives later', () => {
const rect = new Rect('dom-rect', {
left: 10,
top: 20,
width: 50,
height: 40,
});
const { controller, add$, remove$, refreshTransform$, drawingRenderService, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
remove$.next([{ drawingId: 'dom-1' }]);
publishEmbedRuntimeGeometry(refreshTransform$);
expect(drawingRenderService.renderFloatDom).not.toHaveBeenCalled();
expect(canvasFloatDomService.addFloatDom).not.toHaveBeenCalled();
controller.dispose();
});
it('updates float dom position from its own host viewport scroll even when current doc focus changes', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, scene, canvasFloatDomService } = createController({ rects: [rect] });
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
scene.viewport.viewportScrollY = 80;
scene.viewport.onScrollAfter$.next({} as never);
expect(positions.at(-1)).toMatchObject({
startX: 40,
startY: -90,
width: 100,
height: 120,
});
sub.unsubscribe();
controller.dispose();
});
it('updates rendered float dom bounds only from the owning document unit', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
const positionCount = positions.length;
refreshTransform$.next([{
unitId: 'doc-2',
drawingId: 'dom-1',
transform: { left: 0, top: 0, width: 160, height: 240, angle: 0 },
}]);
expect(positions).toHaveLength(positionCount);
refreshTransform$.next([{
unitId: 'doc-1',
drawingId: 'dom-1',
transform: { left: 40, top: 60, width: 160, height: 240, angle: 0 },
customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 },
}]);
expect(positions.at(-1)).toMatchObject({
startX: 60,
startY: 120,
width: 320,
height: 720,
});
expect(canvasFloatDomService.updateFloatDom).toHaveBeenCalledWith('dom-1', expect.objectContaining({
props: expect.objectContaining({
customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 },
}),
}));
sub.unsubscribe();
controller.dispose();
});
it('uses custom block viewport height when creating the initial float dom position', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 },
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
expect(positions.at(-1)).toMatchObject({
startX: 40,
startY: 90,
width: 100,
height: 720,
});
sub.unsubscribe();
controller.dispose();
});
it('uses the full content box for sheet-like embed float dom runtimes', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: {
version: 1,
embedId: 'embed-1',
hostAnchorId: 'anchor-1',
childType: UniverInstanceType.UNIVER_SHEET,
},
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({
contentBox: { contentInset: 0, wrapperInset: 0 },
eventPassThrough: false,
}));
controller.dispose();
});
it('keeps the legacy content box for other embed float dom runtimes', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: {
version: 1,
embedId: 'embed-1',
hostAnchorId: 'anchor-1',
childType: UniverInstanceType.UNIVER_SLIDE,
},
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({
contentBox: undefined,
eventPassThrough: false,
}));
controller.dispose();
});
it('falls back to custom block viewport height when refreshes omit transform data', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
refreshTransform$.next([{
unitId: 'doc-1',
drawingId: 'dom-1',
customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 },
}]);
expect(positions.at(-1)).toMatchObject({
startX: 40,
startY: 90,
width: 100,
height: 720,
});
sub.unsubscribe();
controller.dispose();
});
it('keeps the measured custom block size while following refreshed doc-flow position', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
publishEmbedRuntimeGeometry(refreshTransform$);
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
refreshTransform$.next([{
unitId: 'doc-1',
drawingId: 'dom-1',
transform: { left: 40, top: 60, width: 160, height: 240, angle: 0 },
customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 },
}]);
rect.transformByState({ left: 45, top: 65, width: 160, height: 40, angle: 0 } as never);
refreshTransform$.next([{
unitId: 'doc-1',
drawingId: 'dom-1',
transform: { left: 45, top: 65, width: 160, height: 40, angle: 0 },
}]);
expect(positions.at(-1)).toMatchObject({
startX: 70,
startY: 135,
width: 320,
height: 720,
});
sub.unsubscribe();
controller.dispose();
});
it('keeps embed custom block size while moving slide-like blocks with doc flow refreshes', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 240,
width: 720,
height: 405,
} as never);
const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
},
});
publishEmbedRuntimeGeometry(refreshTransform$, { left: 30, top: 240, width: 720, height: 405, angle: 0 });
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
rect.transformByState({ left: 30, top: 60, width: 720, height: 405, angle: 0 } as never);
refreshTransform$.next([{
unitId: 'doc-1',
drawingId: 'dom-1',
transform: { left: 30, top: 60, width: 720, height: 405, angle: 0 },
}]);
expect(positions.at(-1)).toMatchObject({
startY: 120,
height: 1215,
});
sub.unsubscribe();
controller.dispose();
});
it('falls back to custom block content height when viewport layout height is omitted', async () => {
const rect = new Rect('dom-rect', {
left: 30,
top: 50,
width: 50,
height: 40,
} as never);
const { controller, add$, canvasFloatDomService } = createController({
rects: [rect],
drawing: {
customBlockRenderViewport: { contentHeight: 240, viewportHeight: 120 },
},
});
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
await Promise.resolve();
const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$;
const positions: unknown[] = [];
const sub = position$.subscribe((position: unknown) => positions.push(position));
expect(positions.at(-1)).toMatchObject({
height: 720,
});
sub.unsubscribe();
controller.dispose();
});
it('inserts a float dom using page content width when no explicit width is provided', () => {
const { controller, commandService } = createController();
const drawingId = controller.insertFloatDom(
{ componentKey: 'FloatDom', data: { text: 'hello' } } as never,
{ height: 36, drawingId: 'fixed-dom-id' }
);
expect(drawingId).toBe('fixed-dom-id');
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(
InsertDocDrawingCommand.id,
expect.objectContaining({
unitId: 'doc-1',
drawings: [
expect.objectContaining({
drawingId: 'fixed-dom-id',
drawingType: DrawingTypeEnum.DRAWING_DOM,
layoutType: expect.any(Number),
docTransform: expect.objectContaining({
size: { width: 190, height: 36 },
}),
}),
],
})
);
controller.dispose();
});
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { COMPONENT_DOC_DRAWING_PANEL } from '../views/doc-image-panel/component-name';
import { DocDrawingPanel } from '../views/doc-image-panel/DocDrawingPanel';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager
) {
super();
this._registerComponents();
}
private _registerComponents(): void {
this.disposeWithMe(this._componentManager.register(COMPONENT_DOC_DRAWING_PANEL, DocDrawingPanel));
}
}

View File

@@ -0,0 +1,528 @@
/**
* 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, ICommandInfo, IDrawingSearch, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type {
IDocDrawing,
IUpdateDocDrawingWrappingStyleParams,
IUpdateDrawingDocTransformCommandParams,
} from '@univerjs/docs-drawing';
import type { IDrawingJsonUndo1, IDrawingMapItemData, IDrawingOrderMapParam } from '@univerjs/drawing';
import type { IDrawingAnchorInPage } from '../utils/drawing-anchor-position';
import {
BooleanNumber,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
JSON1,
JSONX,
PositionedObjectLayoutType,
RedoCommand,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import {
collectDocDrawings,
getDocDrawingRenderOrder,
IDocDrawingService,
TextWrappingStyle,
UpdateDocDrawingWrappingStyleCommand,
UpdateDrawingDocTransformCommand,
} from '@univerjs/docs-drawing';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { DocRefreshDrawingsService } from '../services/doc-refresh-drawings.service';
import { findDrawingAnchorInPage, resolveDrawingAnchorOffsets } from '../utils/drawing-anchor-position';
interface IAddOrRemoveDrawing {
type: 'add' | 'remove';
drawingId: string;
drawing?: IDocDrawing;
}
// Check whether drawings are added or deleted from the mutation and obtain the drawing ID.
// eslint-disable-next-line complexity
function getAddOrRemoveDrawings(actions: JSONXActions): Nullable<IAddOrRemoveDrawing[]> {
if (JSONX.isNoop(actions) || !Array.isArray(actions)) {
return null;
}
const drawingsOp = actions.find((action) => Array.isArray(action) && action?.[0] === 'drawings');
if (drawingsOp == null || !Array.isArray(drawingsOp) || drawingsOp.length < 3) {
return null;
}
if (typeof drawingsOp[1] === 'string' && typeof drawingsOp[2] !== 'object') {
return null;
}
if (Array.isArray(drawingsOp[1]) && typeof drawingsOp[1][1] !== 'object') {
return null;
}
const drawings: IAddOrRemoveDrawing[] = [];
if (Array.isArray(drawingsOp?.[1])) {
for (const op of drawingsOp) {
if (Array.isArray(op)) {
drawings.push({
type: (op?.[1] as any)?.i ? 'add' : 'remove',
drawingId: op?.[0] as string,
drawing: (op?.[1] as any)?.i,
});
}
}
} else {
drawings.push({
type: (drawingsOp[2] as any)?.i ? 'add' : 'remove',
drawingId: drawingsOp[1] as string,
drawing: (drawingsOp[2] as any)?.i,
});
}
return drawings;
}
// ReOrderedActions data like bellow:
// [
// "drawingsOrder",
// [ 4,
// {
// "d": 0
// }
// ],
// [ 5,
// {
// "p": 0
// }
// ]
// ]
function getReOrderedDrawings(actions: JSONXActions): number[] {
if (!Array.isArray(actions) || actions.length < 3 || actions[0] !== 'drawingsOrder') {
return [];
}
const drawingIndexes: number[] = [];
for (let i = 1; i < actions.length; i++) {
const action = actions[i];
if (Array.isArray(action) && typeof action[0] === 'number' && typeof action[1] === 'object') {
drawingIndexes.push(action[0]);
} else {
drawingIndexes.length = 0;
break;
}
}
return drawingIndexes;
}
function collectUpdatedDrawingIds(actions: JSONXActions, drawingIds = new Set<string>()): Set<string> {
if (JSONX.isNoop(actions) || !Array.isArray(actions)) {
return drawingIds;
}
if (actions[0] === 'drawings') {
const drawingKeyOrOps = actions[1];
if (typeof drawingKeyOrOps === 'string') {
drawingIds.add(drawingKeyOrOps);
return drawingIds;
}
actions.slice(1).forEach((action) => {
if (Array.isArray(action) && typeof action[0] === 'string') {
drawingIds.add(action[0]);
}
});
return drawingIds;
}
actions.forEach((action) => {
if (Array.isArray(action)) {
collectUpdatedDrawingIds(action as JSONXActions, drawingIds);
}
});
return drawingIds;
}
export class DocDrawingAddRemoveController extends Disposable {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IDocDrawingService private readonly _docDrawingService: IDocDrawingService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(DocRefreshDrawingsService) private readonly _docRefreshDrawingsService: DocRefreshDrawingsService
) {
super();
this._initialize();
}
private _initialize() {
this._commandExecutedListener();
}
// eslint-disable-next-line max-lines-per-function
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id !== RichTextEditingMutation.id) {
return;
}
const params = command.params as IRichTextEditingMutationParams;
const { unitId, actions, isSync, syncer } = params;
const addOrRemoveDrawings = getAddOrRemoveDrawings(actions);
if (addOrRemoveDrawings != null) {
for (const { type, drawingId, drawing } of addOrRemoveDrawings) {
if (isSync && drawing?.unitId === syncer) {
continue;
}
if (type === 'add') {
this._addDrawings(unitId, [drawing!]);
} else {
this._removeDrawings(unitId, [drawingId]);
}
}
}
})
);
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id !== UpdateDocDrawingWrappingStyleCommand.id) {
return;
}
this._preserveWrappingStylePosition(command.params as IUpdateDocDrawingWrappingStyleParams);
})
);
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== RichTextEditingMutation.id) {
return;
}
const params = command.params as IRichTextEditingMutationParams;
const { unitId, actions } = params;
let changedNotes = false;
if (!JSONX.isNoop(actions)) {
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
changedNotes ||= cursor.getPath()[0] === 'notes';
});
}
if (changedNotes) {
this._syncNoteDrawings(unitId);
}
const reOrderedDrawings = getReOrderedDrawings(actions);
if (reOrderedDrawings.length > 0) {
this._updateDrawingsOrder(unitId);
}
const updatedDrawingIds = [...collectUpdatedDrawingIds(actions)];
if (updatedDrawingIds.length > 0) {
this._syncDrawingDataFromSnapshot(unitId, updatedDrawingIds);
}
})
);
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (
command.id !== UpdateDrawingDocTransformCommand.id &&
command.id !== UpdateDocDrawingWrappingStyleCommand.id
) {
return;
}
const { unitId } = command.params as IUpdateDrawingDocTransformCommandParams | IUpdateDocDrawingWrappingStyleParams;
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (renderObject == null || scene == null) {
return;
}
// Transform mutations are already refreshed incrementally by
// DocDrawingTransformUpdateController from the nested rich-text
// mutation. Wrapping changes can move anchors and still need a
// conservative full refresh.
if (command.id === UpdateDocDrawingWrappingStyleCommand.id) {
this._docRefreshDrawingsService.refreshDrawings(renderObject.with(DocSkeletonManagerService).getSkeleton());
}
scene.getTransformerByCreate().refreshControls();
})
);
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== UndoCommand.id && command.id !== RedoCommand.id) {
return;
}
const unitId = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)?.getUnitId();
const focusedDrawings = this._drawingManagerService.getFocusDrawings();
if (unitId == null || focusedDrawings.length === 0) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (renderObject == null || scene == null) {
return false;
}
this._docRefreshDrawingsService.refreshDrawings(renderObject.with(DocSkeletonManagerService).getSkeleton());
const transformer = scene.getTransformerByCreate();
transformer.refreshControls();
})
);
}
private _preserveWrappingStylePosition(params: IUpdateDocDrawingWrappingStyleParams): void {
if (params.wrappingStyle === TextWrappingStyle.INLINE) {
return;
}
const { unitId } = params;
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
unitId,
UniverInstanceType.UNIVER_DOC
);
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const skeletonManager = renderObject?.with(DocSkeletonManagerService);
const skeletonData = skeletonManager?.getSkeleton().getSkeletonData();
const viewModel = skeletonManager?.getViewModel();
if (!documentDataModel || !skeletonData || !viewModel) {
return;
}
const editArea = viewModel.getEditArea();
const { pages, skeHeaders, skeFooters } = skeletonData;
const oldDrawings = documentDataModel.getDrawings() ?? {};
params.drawings = params.drawings.map((drawing) => {
const oldDrawing = oldDrawings[drawing.drawingId] as IDocDrawing | undefined;
if (!oldDrawing) {
return drawing;
}
let drawingAnchor: IDrawingAnchorInPage | null = null;
for (const page of pages) {
const { headerId, footerId, marginTop, marginLeft, marginBottom, pageWidth, pageHeight } = page;
if (editArea === DocumentEditArea.HEADER) {
const header = skeHeaders.get(headerId)?.get(pageWidth);
if (header) {
drawingAnchor = findDrawingAnchorInPage(header, drawing.drawingId, header.marginTop, marginLeft);
}
} else if (editArea === DocumentEditArea.FOOTER) {
const footer = skeFooters.get(footerId)?.get(pageWidth);
if (footer) {
drawingAnchor = findDrawingAnchorInPage(
footer,
drawing.drawingId,
pageHeight - marginBottom + footer.marginTop,
marginLeft
);
}
} else {
drawingAnchor = findDrawingAnchorInPage(page, drawing.drawingId, marginTop, marginLeft);
}
if (drawingAnchor) {
break;
}
}
if (!drawingAnchor) {
return drawing;
}
const oldPositionH = oldDrawing.docTransform.positionH;
const oldPositionV = oldDrawing.docTransform.positionV;
const { horizontal: posOffsetH, vertical: posOffsetV } = resolveDrawingAnchorOffsets(
drawingAnchor,
oldPositionH,
oldPositionV
);
return {
...oldDrawing,
...drawing,
docTransform: {
...oldDrawing.docTransform,
...drawing.docTransform,
positionH: { relativeFrom: oldPositionH.relativeFrom, posOffset: posOffsetH },
positionV: { relativeFrom: oldPositionV.relativeFrom, posOffset: posOffsetV },
},
};
});
}
private _addDrawings(unitId: string, drawings: IDocDrawing[]) {
const drawingManagerService = this._drawingManagerService;
const docDrawingService = this._docDrawingService;
const jsonOp = this._docDrawingService.getBatchAddOp(drawings) as IDrawingJsonUndo1;
const { subUnitId, redo: op, objects } = jsonOp;
drawingManagerService.applyJson1(unitId, subUnitId, op);
docDrawingService.applyJson1(unitId, subUnitId, op);
drawingManagerService.addNotification(objects as IDrawingSearch[]);
docDrawingService.addNotification(objects as IDrawingSearch[]);
}
private _removeDrawings(unitId: string, drawingIds: string[]) {
const drawingManagerService = this._drawingManagerService;
const docDrawingService = this._docDrawingService;
const jsonOp = this._docDrawingService.getBatchRemoveOp(drawingIds.map((drawingId) => {
return {
unitId,
subUnitId: unitId,
drawingId,
};
}) as IDrawingSearch[]) as IDrawingJsonUndo1;
const { subUnitId, redo: op, objects } = jsonOp;
drawingManagerService.applyJson1(unitId, subUnitId, op);
docDrawingService.applyJson1(unitId, subUnitId, op);
drawingManagerService.removeNotification(objects as IDrawingSearch[]);
docDrawingService.removeNotification(objects as IDrawingSearch[]);
}
private _updateDrawingsOrder(unitId: string) {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return;
}
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
if (drawingsOrder == null) {
return;
}
const renderOrder = getDocDrawingRenderOrder(drawingsOrder, drawings);
const drawingManagerService = this._drawingManagerService;
const docDrawingService = this._docDrawingService;
drawingManagerService.setDrawingOrder(unitId, unitId, renderOrder);
docDrawingService.setDrawingOrder(unitId, unitId, drawingsOrder);
// FIXME: @Jocs, Only need to update the affected drawings.
const objects: IDrawingOrderMapParam = {
unitId,
subUnitId: unitId,
drawingIds: renderOrder,
};
drawingManagerService.orderNotification(objects);
docDrawingService.orderNotification({
unitId,
subUnitId: unitId,
drawingIds: drawingsOrder,
});
}
private _syncNoteDrawings(unitId: string): void {
const model = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!model) {
return;
}
const { drawings } = collectDocDrawings(model.getSnapshot());
const previous = this._docDrawingService.getDrawingData(unitId, unitId) ?? {};
const added = Object.keys(drawings).filter((id) => !previous[id]);
const removed = Object.keys(previous).filter((id) => !drawings[id]);
const changed = Object.keys(drawings).filter((id) => drawings[id] !== previous[id]);
if (added.length > 0) {
this._addDrawings(unitId, added.map((id) => ({ ...drawings[id], unitId, subUnitId: unitId })));
}
if (removed.length > 0) {
this._removeDrawings(unitId, removed);
}
if (changed.length > 0) {
this._syncDrawingDataFromSnapshot(unitId, changed);
}
}
private _syncDrawingDataFromSnapshot(unitId: string, drawingIds: string[]) {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (documentDataModel == null) {
return;
}
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
const drawingData = drawings as IDrawingMapItemData<IDocDrawing>;
const previousDrawings = this._docDrawingService.getDrawingData(unitId, unitId);
const orderChanged = drawingsOrder !== this._docDrawingService.getDrawingOrder(unitId, unitId) ||
drawingIds.some((drawingId) => {
const previous = previousDrawings[drawingId];
const current = drawingData[drawingId];
const wasBehind = previous?.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
previous.behindDoc === BooleanNumber.TRUE;
const isBehind = current?.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
current.behindDoc === BooleanNumber.TRUE;
return wasBehind !== isBehind;
});
const renderedDrawings = { ...this._drawingManagerService.getDrawingData(unitId, unitId) };
for (const drawingId of drawingIds) {
const current = drawingData[drawingId];
if (current) {
// Layout mutates render transforms. Never share the persisted drawing object or
// replace unrelated drawings whose published positions are still authoritative.
renderedDrawings[drawingId] = { ...current };
} else {
delete renderedDrawings[drawingId];
}
}
this._docDrawingService.setDrawingData(unitId, unitId, drawingData);
this._drawingManagerService.setDrawingData(unitId, unitId, renderedDrawings);
if (orderChanged) {
this._docDrawingService.setDrawingOrder(unitId, unitId, drawingsOrder);
this._drawingManagerService.setDrawingOrder(unitId, unitId, getDocDrawingRenderOrder(drawingsOrder, drawings));
}
const objects = drawingIds
.filter((drawingId) => drawingData[drawingId] != null)
.map((drawingId) => ({ unitId, subUnitId: unitId, drawingId }));
if (objects.length === 0) {
return;
}
this._docDrawingService.updateNotification(objects);
this._drawingManagerService.updateNotification(objects);
}
}

View File

@@ -0,0 +1,123 @@
/**
* 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 { IDocFloatDom } from '@univerjs/docs-drawing';
import { Disposable, DOC_DRAWING_PRINTING_COMPONENT_KEY, DrawingTypeEnum, Inject, Injector } from '@univerjs/core';
import { DocPrintInterceptorService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DrawingRenderService } from '@univerjs/drawing-ui';
import { ComponentManager } from '@univerjs/ui';
import { mountDocPrintingFloatDom } from '../views/DocPrintingFloatDom';
export class DocDrawingPrintingController extends Disposable {
constructor(
@Inject(DocPrintInterceptorService) private readonly _docPrintInterceptorService: DocPrintInterceptorService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(ComponentManager) private readonly _componetManager: ComponentManager,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initPrinting();
this._initPrintingDom();
}
private _initPrinting() {
this.disposeWithMe(
this._docPrintInterceptorService.interceptor.intercept(
this._docPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_COMPONENT_COLLECT,
{
handler: (_param, pos, next) => {
const { unitId, scene, skeleton, pageIndex } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[unitId];
const visibleDrawingIds = this._docPrintInterceptorService.getPageDrawingIds(skeleton, pageIndex);
if (subUnitData) {
subUnitData.order.forEach((id) => {
if (visibleDrawingIds && !visibleDrawingIds.has(id)) {
return;
}
const drawing = subUnitData.data[id];
if (drawing.drawingType !== DrawingTypeEnum.DRAWING_CHART && drawing.drawingType !== DrawingTypeEnum.DRAWING_DOM) {
this._drawingRenderService.renderDrawing(drawing, scene);
}
});
}
return next();
},
}
)
);
}
private _initPrintingDom() {
this.disposeWithMe(
this._docPrintInterceptorService.interceptor.intercept(
this._docPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT,
{
handler: (disposableCollection, pos, next) => {
const { unitId, skeleton, pageIndex } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[unitId];
const visibleDrawingIds = this._docPrintInterceptorService.getPageDrawingIds(skeleton, pageIndex);
if (subUnitData) {
const floatDomInfos = subUnitData.order.filter((id) => !visibleDrawingIds || visibleDrawingIds.has(id)).map((id) => {
const drawing = subUnitData.data[id] as IDocFloatDom;
if (drawing.drawingType === DrawingTypeEnum.DRAWING_CHART) {
return {
...drawing,
componentKey: this._componetManager.get(DOC_DRAWING_PRINTING_COMPONENT_KEY) as any,
};
}
if (drawing.drawingType === DrawingTypeEnum.DRAWING_DOM) {
const printingComponentKey = this._docPrintInterceptorService.getPrintComponent(drawing.componentKey);
return {
...drawing,
componentKey: this._componetManager.get(printingComponentKey || drawing.componentKey) as any,
};
}
return null;
}).filter(Boolean) as IDocFloatDom[];
const unmountPrintingFloatDom = mountDocPrintingFloatDom(
{
unitId,
floatDomInfos,
scene: pos.scene,
skeleton: pos.skeleton,
offset: pos.offset,
bound: pos.bound,
},
pos.root,
this._injector
);
disposableCollection?.add(() => {
unmountPrintingFloatDom();
});
return next(disposableCollection);
}
},
}
)
);
}
}

View File

@@ -0,0 +1,717 @@
/**
* 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, IDisposable, IDrawingSearch, ITransformState, Nullable } from '@univerjs/core';
import type { IDocFloatDom, IInsertDocDrawingCommandParams } from '@univerjs/docs-drawing';
import type { ISetDocZoomRatioOperationParams } from '@univerjs/docs-ui';
import type { IDocFloatDomDataBase } from '@univerjs/drawing';
import type { IBoundRectNoAngle, IDocsCustomBlockRenderViewport, IRender, Rect, Scene } from '@univerjs/engine-render';
import type { IFloatDomLayout } from '@univerjs/ui';
import {
Disposable,
DisposableCollection,
DrawingTypeEnum,
fromEventSubject,
generateRandomId,
ICommandService,
Inject,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { docDrawingPositionToTransform, DocSkeletonManagerService, isSheetLikeDocsCustomBlockChildType } from '@univerjs/docs';
import { InsertDocDrawingCommand } from '@univerjs/docs-drawing';
import { SetDocZoomRatioOperation, VIEWPORT_KEY } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DrawingRenderService } from '@univerjs/drawing-ui';
import { CURSOR_TYPE, IRenderManagerService } from '@univerjs/engine-render';
import { CanvasFloatDomService } from '@univerjs/ui';
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
import { DocRefreshDrawingsService } from '../services/doc-refresh-drawings.service';
export function calcDocFloatDomPositionByRect(
rect: IBoundRectNoAngle,
scene: Scene,
opacity = 1,
angle = 0
): IFloatDomLayout {
const { top, left, bottom, right } = rect;
const width = right - left;
const height = bottom - top;
const viewMain = scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)!;
const { viewportScrollX, viewportScrollY } = viewMain;
const { scaleX, scaleY } = scene.getAncestorScale();
return {
startX: (left - viewportScrollX) * scaleX,
startY: (top - viewportScrollY) * scaleY,
endX: (left + width - viewportScrollX) * scaleX,
endY: (top + height - viewportScrollY) * scaleY,
width: width * scaleX,
height: height * scaleY,
rotate: angle,
absolute: {
left: false,
top: false,
},
opacity: opacity ?? 1,
};
}
function calcDocFloatDomPosition(
object: Rect,
renderUnit: IRender
): IFloatDomLayout {
const { top, left, width, height, angle, opacity } = object;
return calcDocFloatDomPositionByRect({ top, left, bottom: top + height, right: left + width }, renderUnit.scene, opacity, angle);
}
interface ICanvasFloatDomInfo {
position$: BehaviorSubject<IFloatDomLayout>;
dispose: IDisposable;
preserveRuntimeGeometry?: boolean;
rect: Rect;
runtimeTransform?: Partial<ITransformState>;
runtimeViewport?: IDocFloatDomRuntimeViewport;
unitId: string;
}
interface IDocFloatDomParams extends IDocFloatDomDataBase {
}
type IDocFloatDomRuntimeViewport = Partial<Pick<IDocsCustomBlockRenderViewport, 'bleedLeft' | 'bleedWidth' | 'contentHeight' | 'contentWidth' | 'height' | 'pageContentWidth' | 'viewScale' | 'viewportHeight'>>;
interface IDocFloatDomRuntimeGeometry {
customBlockRenderViewport?: IDocFloatDomRuntimeViewport;
hidden?: boolean;
transform?: Nullable<ITransformState>;
transforms?: Nullable<ITransformState[]>;
}
export function mergeDocFloatDomRuntimeProps(existingProps: Record<string, unknown> | undefined, param: IDocFloatDomRuntimeGeometry): Record<string, unknown> | undefined {
const customBlockRenderViewport = pickValidCustomBlockRenderViewport(param.customBlockRenderViewport);
if (!customBlockRenderViewport) {
return existingProps;
}
return {
...existingProps,
customBlockRenderViewport,
};
}
function pickValidCustomBlockRenderViewport(viewport: IDocFloatDomRuntimeViewport | undefined): IDocFloatDomRuntimeViewport | undefined {
const result: IDocFloatDomRuntimeViewport = {};
if (isNonNegativeNumber(viewport?.bleedLeft)) {
result.bleedLeft = viewport!.bleedLeft;
}
if (isPositiveNumber(viewport?.bleedWidth)) {
result.bleedWidth = viewport!.bleedWidth;
}
if (isPositiveNumber(viewport?.contentHeight)) {
result.contentHeight = viewport!.contentHeight;
}
if (isPositiveNumber(viewport?.contentWidth)) {
result.contentWidth = viewport!.contentWidth;
}
if (isPositiveNumber(viewport?.height)) {
result.height = viewport!.height;
}
if (isPositiveNumber(viewport?.pageContentWidth)) {
result.pageContentWidth = viewport!.pageContentWidth;
}
const viewScale = viewport?.viewScale;
if (isPositiveNumber(viewScale)) {
result.viewScale = viewScale;
}
if (isPositiveNumber(viewport?.viewportHeight)) {
result.viewportHeight = viewport!.viewportHeight;
}
return Object.keys(result).length ? result : undefined;
}
function isPositiveNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isNonNegativeNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
export class DocFloatDomController extends Disposable {
private _domLayerInfoMap = new Map<string, ICanvasFloatDomInfo>();
private _pendingRuntimeGeometry = new Map<string, IDocFloatDomRuntimeGeometry>();
private _pendingRuntimeGeometryInsert = new Map<string, IDrawingSearch>();
private _pendingRuntimeGeometryRefresh = new Map<string, IDisposable>();
constructor(
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService,
@Inject(CanvasFloatDomService) private readonly _canvasFloatDomService: CanvasFloatDomService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocRefreshDrawingsService) private readonly _docRefreshDrawingsService: DocRefreshDrawingsService
) {
super();
this._initialize();
}
override dispose(): void {
this._pendingRuntimeGeometry.clear();
this._pendingRuntimeGeometryInsert.clear();
for (const disposable of this._pendingRuntimeGeometryRefresh.values()) {
disposable.dispose();
}
this._pendingRuntimeGeometryRefresh.clear();
super.dispose();
}
private _initialize() {
this._renderLifecycleListener();
this._drawingAddRemoveListener();
this._drawingRuntimePropsListener();
this._initScrollAndZoomEvent();
}
private _renderLifecycleListener(): void {
this.disposeWithMe(this._renderManagerService.disposed$.subscribe((unitId) => {
this._disposePendingRuntimeGeometryRefresh(unitId);
}));
this.disposeWithMe(this._renderManagerService.created$.subscribe((render) => {
if (this._hasPendingRuntimeGeometryForUnit(render.unitId)) {
this._refreshDrawingsFromCurrentLayout(render.unitId);
}
}));
}
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
if (renderObject == null) {
return null;
}
const scene = renderObject.scene;
const transformer = scene.getTransformerByCreate();
return { scene, transformer, renderUnit: renderObject, canvas: renderObject.engine.getCanvasElement() };
}
private _drawingAddRemoveListener() {
this.disposeWithMe(
this._drawingManagerService.add$.subscribe((params) => {
const ready: IDrawingSearch[] = [];
const refreshUnitIds = new Set<string>();
for (const param of params) {
const drawing = this._drawingManagerService.getDrawingByParam(param);
if (
isEmbedFloatDomRuntimeParam(drawing) &&
this._univerInstanceService.getUnit(param.unitId, UniverInstanceType.UNIVER_DOC) != null
) {
this._pendingRuntimeGeometryInsert.set(param.drawingId, param);
refreshUnitIds.add(param.unitId);
if (this._pendingRuntimeGeometry.has(param.drawingId)) {
ready.push(param);
}
} else {
ready.push(param);
}
}
this._insertRects(ready);
for (const unitId of refreshUnitIds) {
this._refreshDrawingsFromCurrentLayout(unitId);
}
})
);
this.disposeWithMe(
this._drawingManagerService.remove$.subscribe((params) => {
params.forEach((param) => {
this._removeDom(param.drawingId);
});
})
);
}
private _refreshDrawingsFromCurrentLayout(unitId: string): void {
const render = this._renderManagerService.getRenderUnitById(unitId);
if (
render == null ||
render.type !== UniverInstanceType.UNIVER_DOC ||
render.isDisposed()
) {
return;
}
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
if (skeleton != null) {
if (!this._pendingRuntimeGeometryRefresh.has(unitId)) {
const subscription = skeleton.dirty$.subscribe(() => {
if (!this._hasPendingRuntimeGeometryForUnit(unitId)) {
this._disposePendingRuntimeGeometryRefresh(unitId);
return;
}
this._docRefreshDrawingsService.refreshDrawings(skeleton);
});
this._pendingRuntimeGeometryRefresh.set(
unitId,
toDisposable(() => subscription.unsubscribe())
);
}
this._docRefreshDrawingsService.refreshDrawings(skeleton);
}
}
private _hasPendingRuntimeGeometryForUnit(unitId: string): boolean {
for (const pending of this._pendingRuntimeGeometryInsert.values()) {
if (pending.unitId === unitId) {
return true;
}
}
return false;
}
private _disposePendingRuntimeGeometryRefresh(unitId: string): void {
this._pendingRuntimeGeometryRefresh.get(unitId)?.dispose();
this._pendingRuntimeGeometryRefresh.delete(unitId);
}
private _insertRects(params: IDrawingSearch[]) {
for (const param of params) {
const { unitId } = param;
const documentDataModel = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_DOC);
if (!documentDataModel) {
continue;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
continue;
}
const rectParam = this._drawingManagerService.getDrawingByParam(param) as IDocFloatDom;
if (rectParam == null) {
continue;
}
const preserveRuntimeGeometry = isEmbedFloatDomRuntimeParam(rectParam);
const publishedRuntimeParam = this._pendingRuntimeGeometry.get(param.drawingId);
if (
preserveRuntimeGeometry &&
(publishedRuntimeParam == null || this._pendingRuntimeGeometryInsert.get(param.drawingId) !== param)
) {
continue;
}
const rects = this._drawingRenderService.renderFloatDom(rectParam, renderObject.scene);
if (rects == null || rects.length === 0) {
continue;
}
for (const rect of rects) {
const runtimeParam: IDocFloatDomRuntimeGeometry = publishedRuntimeParam ?? rectParam;
const runtimeViewport = pickValidCustomBlockRenderViewport(runtimeParam.customBlockRenderViewport);
syncRectWithRuntimeParam(rect, runtimeParam, runtimeViewport, undefined, preserveRuntimeGeometry);
const runtimeTransform = runtimeViewport || preserveRuntimeGeometry ? createTransformFromRect(rect) : undefined;
this._addHoverForRect(rect);
const disposableCollection = new DisposableCollection();
const initPosition = calcDocFloatDomPosition(rect, renderObject.renderUnit);
const position$ = new BehaviorSubject<IFloatDomLayout>(initPosition);
const canvas = renderObject.canvas;
const data = rectParam.data;
const info: ICanvasFloatDomInfo = {
dispose: disposableCollection,
preserveRuntimeGeometry,
rect,
runtimeTransform,
runtimeViewport,
position$,
unitId,
};
this._canvasFloatDomService.addFloatDom({
position$,
id: rectParam.drawingId,
componentKey: rectParam.componentKey,
contentBox: isSheetLikeEmbedFloatDomRuntimeParam(rectParam)
? { contentInset: 0, wrapperInset: 0 }
: undefined,
eventPassThrough: preserveRuntimeGeometry ? false : undefined,
preserveOnFocusChange: preserveRuntimeGeometry,
onPointerDown: (evt) => {
canvas.dispatchEvent(new PointerEvent(evt.type, evt));
},
onPointerMove: (evt: PointerEvent | MouseEvent) => {
canvas.dispatchEvent(new PointerEvent(evt.type, evt));
},
onPointerUp: (evt: PointerEvent | MouseEvent) => {
canvas.dispatchEvent(new PointerEvent(evt.type, evt));
},
onWheel: (evt: WheelEvent) => {
canvas.dispatchEvent(new WheelEvent(evt.type, evt));
},
data,
props: mergeDocFloatDomRuntimeProps(undefined, rectParam),
unitId,
});
const listener = rect.onTransformChange$.subscribeEvent(() => {
const newPosition = calcDocFloatDomPosition(rect, renderObject.renderUnit);
position$.next(
newPosition
);
});
const scrollListener = subscribeViewportScrollAfter(
renderObject.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)?.onScrollAfter$,
() => position$.next(calcDocFloatDomPosition(rect, renderObject.renderUnit))
);
disposableCollection.add(() => {
this._canvasFloatDomService.removeFloatDom(rectParam.drawingId);
});
listener && disposableCollection.add(listener);
scrollListener && disposableCollection.add(scrollListener);
this._domLayerInfoMap.set(rectParam.drawingId, info);
}
this._pendingRuntimeGeometry.delete(param.drawingId);
this._pendingRuntimeGeometryInsert.delete(param.drawingId);
if (!this._hasPendingRuntimeGeometryForUnit(unitId)) {
this._disposePendingRuntimeGeometryRefresh(unitId);
}
}
}
private _drawingRuntimePropsListener() {
this.disposeWithMe(
this._drawingManagerService.refreshTransform$.subscribe((params) => {
params.forEach((param) => {
const floatDomInfo = this._domLayerInfoMap.get(param.drawingId);
if (!floatDomInfo) {
const pendingInsert = this._pendingRuntimeGeometryInsert.get(param.drawingId);
const drawing = this._drawingManagerService.getDrawingByParam(param);
if (pendingInsert != null || isEmbedFloatDomRuntimeParam(drawing)) {
if (param.hidden === true) {
return;
}
this._pendingRuntimeGeometry.set(param.drawingId, param);
if (pendingInsert != null) {
this._insertRects([pendingInsert]);
}
}
return;
}
if (floatDomInfo.unitId !== param.unitId) {
return;
}
if (floatDomInfo.preserveRuntimeGeometry && param.hidden === true) {
return;
}
const runtimeParam: IDocFloatDomRuntimeGeometry = param;
const runtimeViewport = pickValidCustomBlockRenderViewport(runtimeParam.customBlockRenderViewport);
if (runtimeViewport) {
floatDomInfo.runtimeViewport = runtimeViewport;
}
const synced = syncRectWithRuntimeParam(
floatDomInfo.rect,
runtimeParam,
floatDomInfo.runtimeViewport,
floatDomInfo.runtimeTransform,
floatDomInfo.preserveRuntimeGeometry
);
if (synced) {
if (runtimeViewport || floatDomInfo.preserveRuntimeGeometry) {
floatDomInfo.runtimeTransform = createTransformFromRect(floatDomInfo.rect);
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(floatDomInfo.unitId);
if (renderObject) {
floatDomInfo.position$.next(calcDocFloatDomPosition(floatDomInfo.rect, renderObject.renderUnit));
}
}
const currentProps = this._canvasFloatDomService.domLayers.find(([id]) => id === param.drawingId)?.[1].props;
this._canvasFloatDomService.updateFloatDom(param.drawingId, {
props: mergeDocFloatDomRuntimeProps(currentProps, param),
});
});
})
);
}
private _addHoverForRect(o: Rect) {
this.disposeWithMe(
toDisposable(
o.onPointerEnter$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.GRAB;
})
)
);
this.disposeWithMe(
toDisposable(
o.onPointerLeave$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.DEFAULT;
})
)
);
}
private _removeDom(id: string) {
const pendingUnitId = this._pendingRuntimeGeometryInsert.get(id)?.unitId;
this._pendingRuntimeGeometry.delete(id);
this._pendingRuntimeGeometryInsert.delete(id);
if (pendingUnitId != null && !this._hasPendingRuntimeGeometryForUnit(pendingUnitId)) {
this._disposePendingRuntimeGeometryRefresh(pendingUnitId);
}
const info = this._domLayerInfoMap.get(id);
if (!info) {
return;
}
const { unitId } = info;
this._domLayerInfoMap.delete(id);
info.dispose.dispose();
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject) {
renderObject.scene.removeObject(info.rect);
}
}
private _initScrollAndZoomEvent() {
const updateDoc = (unitId: string) => {
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (!renderObject) {
return;
}
this._domLayerInfoMap.forEach((floatDomInfo) => {
if (floatDomInfo.unitId !== unitId) return;
const position = calcDocFloatDomPosition(floatDomInfo.rect, renderObject.renderUnit);
floatDomInfo.position$.next(position);
});
};
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(
map((documentDataModel) => {
if (!documentDataModel) return null;
const unitId = documentDataModel.getUnitId();
const render = this._renderManagerService.getRenderUnitById(unitId);
return render ? { render, unitId } : null;
}),
switchMap((render) =>
render
? fromEventSubject(render.render.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)!.onScrollAfter$)
.pipe(map(() => ({ unitId: render.unitId })))
: of(null)
)
).subscribe((value) => {
if (!value) return;
const { unitId } = value;
updateDoc(unitId);
})
);
this.disposeWithMe(this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetDocZoomRatioOperation.id) {
const params = (commandInfo.params) as ISetDocZoomRatioOperationParams;
const { unitId } = params;
globalThis.queueMicrotask(() => {
if (!this._disposed) updateDoc(unitId);
});
}
}));
}
insertFloatDom(floatDom: IDocFloatDomParams, opts: { width?: number; height: number; drawingId?: string }) {
const currentDoc = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
if (!currentDoc) return false;
const render = this._getSceneAndTransformerByDrawingSearch(currentDoc.getUnitId());
if (!render) return false;
const docSkeletonManagerService = render.renderUnit.with(DocSkeletonManagerService);
const skeleton = docSkeletonManagerService.getSkeleton();
const page = skeleton.getSkeletonData()?.pages[0];
if (!page) return false;
const { pageWidth, marginLeft, marginRight } = page;
const width = pageWidth - marginLeft - marginRight;
const docTransform = {
size: {
width: opts.width ?? width,
height: opts.height,
},
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 0,
},
angle: 0,
};
const drawingId = opts.drawingId ?? generateRandomId();
const params: IInsertDocDrawingCommandParams = {
unitId: currentDoc.getUnitId(),
drawings: [
{
drawingId,
drawingType: DrawingTypeEnum.DRAWING_DOM,
subUnitId: currentDoc.getUnitId(),
unitId: currentDoc.getUnitId(),
...floatDom,
title: '',
description: '',
docTransform,
layoutType: PositionedObjectLayoutType.INLINE,
transform: docDrawingPositionToTransform(docTransform),
},
],
};
this._commandService.syncExecuteCommand<IInsertDocDrawingCommandParams>(InsertDocDrawingCommand.id, params);
return drawingId;
}
}
function syncRectWithRuntimeParam(
rect: Rect,
param: IDocFloatDomRuntimeGeometry,
fallbackViewport?: IDocFloatDomRuntimeViewport,
fallbackTransform?: Partial<ITransformState>,
preserveRuntimeGeometry?: boolean
): boolean {
const transform = getRuntimeTransform(param, rect, fallbackViewport, fallbackTransform, preserveRuntimeGeometry);
if (!transform) {
return false;
}
rect.transformByState(transform as never);
return true;
}
function getRuntimeTransform(
param: IDocFloatDomRuntimeGeometry,
rect: Rect,
fallbackViewport?: IDocFloatDomRuntimeViewport,
fallbackTransform?: Partial<ITransformState>,
preserveRuntimeGeometry?: boolean
): Partial<ITransformState> | undefined {
const transform = param.transform ?? param.transforms?.[0];
const runtimeViewport = param.customBlockRenderViewport ?? fallbackViewport;
if (!param.customBlockRenderViewport && preserveRuntimeGeometry && fallbackTransform && transform) {
return {
...transform,
width: fallbackTransform.width ?? transform.width,
height: fallbackTransform.height ?? transform.height,
};
}
if (!transform) {
const height = runtimeViewport?.height ?? runtimeViewport?.contentHeight;
if (!isPositiveNumber(height)) {
return undefined;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height,
angle: rect.angle,
};
}
const height = runtimeViewport?.height ?? runtimeViewport?.contentHeight;
if (!isPositiveNumber(height)) {
return transform;
}
return {
...transform,
height,
};
}
function isEmbedFloatDomRuntimeParam(param: unknown): param is IDocFloatDom {
if (param == null || typeof param !== 'object' || !('data' in param)) {
return false;
}
const data = param.data;
if (!data || typeof data !== 'object') {
return false;
}
const candidate = data as { embedId?: unknown; hostAnchorId?: unknown; version?: unknown };
return candidate.version === 1 && typeof candidate.embedId === 'string' && typeof candidate.hostAnchorId === 'string';
}
function isSheetLikeEmbedFloatDomRuntimeParam(param: unknown): param is IDocFloatDom {
if (!isEmbedFloatDomRuntimeParam(param)) {
return false;
}
const data = param.data;
return !!data &&
typeof data === 'object' &&
'childType' in data &&
typeof data.childType === 'number' &&
isSheetLikeDocsCustomBlockChildType(data.childType);
}
function createTransformFromRect(rect: Rect): Partial<ITransformState> {
return {
angle: rect.angle,
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
};
}
function subscribeViewportScrollAfter(scrollEvent: unknown, callback: () => void): IDisposable | undefined {
if (!scrollEvent || typeof scrollEvent !== 'object') {
return undefined;
}
const eventSubject = scrollEvent as { subscribeEvent?: (listener: () => void) => IDisposable };
if (typeof eventSubject.subscribeEvent === 'function') {
return eventSubject.subscribeEvent(callback);
}
const observable = scrollEvent as { subscribe?: (listener: () => void) => { unsubscribe?: () => void } };
if (typeof observable.subscribe === 'function') {
const subscription = observable.subscribe(callback);
return toDisposable(() => subscription.unsubscribe?.());
}
return undefined;
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { COMPONENT_DOC_DRAWING_PANEL } from '../../views/doc-image-panel/component-name';
import { MobileDocDrawingPanel } from '../../views/doc-image-panel/MobileDocDrawingPanel';
export class MobileComponentsController extends Disposable {
constructor(@Inject(ComponentManager) componentManager: ComponentManager) {
super();
this.disposeWithMe(componentManager.register(COMPONENT_DOC_DRAWING_PANEL, MobileDocDrawingPanel));
}
}

View File

@@ -0,0 +1,479 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDisposable, INeedCheckDisposable, Nullable } from '@univerjs/core';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import {
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
Inject,
IPermissionService,
isInternalEditorID,
IUniverInstanceService,
RxDisposable,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import {
canEditDocumentTargets,
getDocumentDrawingSegmentId,
getDocumentEntityParentPermissionObjectIds,
getDocumentEntityPermissionObjectId,
} from '@univerjs/docs';
import { IDocDrawingAdapterService, IDocDrawingService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing';
import { DocCanvasPopManagerService, MOBILE_DOC_ELEMENT_MENU } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import {
COMPONENT_MOBILE_IMAGE_POPUP_MENU,
ImageCropperObject,
ImageResetSizeOperation,
OpenImageCropOperation,
} from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { FloatingObjectToolbarPosition, IMenuManagerService, MenuItemType } from '@univerjs/ui';
import { takeUntil } from 'rxjs';
import { EditDocDrawingOperation } from '../../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingMobileOperation } from '../../commands/operations/mobile/open-drawing-panel.operation';
import { DocDrawingFloatingToolbarAdapterService } from '../../services/doc-drawing-floating-toolbar-adapter.service';
interface IDrawingPopupTarget {
unitId: string;
subUnitId: string;
drawingId: string;
}
export class MobileDocDrawingPopupMenuController extends RxDisposable {
private _initImagePopupMenu = new Set<string>();
private _embeddedRenderUnits = new Set<string>();
private _popupMenuListeners = new Map<string, IDisposable>();
private _disposePopupsByUnit = new Map<string, INeedCheckDisposable[]>();
private _popupTargetsByUnit = new Map<string, IDrawingPopupTarget>();
private _isDrawingPanelOpen = false;
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(DocCanvasPopManagerService) private readonly _canvasPopManagerService: DocCanvasPopManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IContextService private readonly _contextService: IContextService,
@IDocDrawingAdapterService private readonly _drawingAdapterService: IDocDrawingAdapterService,
@IDocDrawingService private readonly _docDrawingService: IDocDrawingService,
@Inject(DocDrawingFloatingToolbarAdapterService) private readonly _floatingToolbarAdapterService: DocDrawingFloatingToolbarAdapterService,
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IPermissionService private readonly _permissionService: IPermissionService
) {
super();
this._init();
}
// eslint-disable-next-line max-lines-per-function
private _init(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command) => {
const opensDrawingEditor = [...this._popupTargetsByUnit.values()].some((target) => {
const drawing = this._docDrawingService.getDrawingByParam(target);
const edit = drawing && this._drawingAdapterService.getEditDrawingCommandInfo({ ...target, drawing });
return edit?.commandId === command.id;
});
if (command.id === EditDocDrawingOperation.id || opensDrawingEditor) {
this._isDrawingPanelOpen = true;
this._clearPopups(undefined, true);
}
if (command.id === SidebarDocDrawingMobileOperation.id) {
const params = command.params as { value?: string } | undefined;
this._isDrawingPanelOpen = params?.value === 'open';
if (this._isDrawingPanelOpen) {
this._clearPopups(undefined, true);
}
}
})
);
this.disposeWithMe(
this._drawingManagerService.focus$.subscribe((params) => {
if (params.length === 0) {
this._isDrawingPanelOpen = false;
}
})
);
this.disposeWithMe(this._drawingManagerService.remove$.subscribe((drawings) => {
for (const drawing of drawings) {
for (const [unitId, target] of this._popupTargetsByUnit) {
if (
target.unitId === drawing.unitId &&
target.subUnitId === drawing.subUnitId &&
target.drawingId === drawing.drawingId
) {
this._clearPopups(unitId, true);
this._renderManagerService.getRenderUnitById(unitId)?.scene.getTransformerByCreate().clearSelectedObjects();
}
}
}
}));
this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => {
for (const [popupUnitId, drawing] of this._popupTargetsByUnit) {
if (!this._canEditDrawing(drawing.unitId, drawing.drawingId)) {
this._clearPopups(popupUnitId, true);
}
}
}));
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._create(documentDataModel))
);
this.disposeWithMe(
this._univerInstanceService.getTypeOfUnitDisposed$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._dispose(documentDataModel))
);
this.disposeWithMe(
this._renderManagerService.created$.pipe(takeUntil(this.dispose$)).subscribe((render) => {
if (render.type !== UniverInstanceType.UNIVER_DOC || render.isMainScene) {
return;
}
this._embeddedRenderUnits.add(render.unitId);
this._create(this._univerInstanceService.getUnit<DocumentDataModel>(render.unitId, UniverInstanceType.UNIVER_DOC));
})
);
this.disposeWithMe(
this._renderManagerService.disposed$.pipe(takeUntil(this.dispose$)).subscribe((unitId) => {
if (!this._embeddedRenderUnits.delete(unitId)) {
return;
}
this._disposePopupMenuListener(unitId);
})
);
this._univerInstanceService.getAllUnitsForType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).forEach((documentDataModel) => this._create(documentDataModel));
}
private _dispose(documentDataModel: DocumentDataModel) {
const unitId = documentDataModel.getUnitId();
this._clearPopups(unitId, true);
this._disposePopupMenuListener(unitId);
this._renderManagerService.removeRender(unitId);
}
private _clearPopups(unitId?: string, force = false) {
if (unitId == null) {
[...this._disposePopupsByUnit.keys()].forEach((popupUnitId) => this._clearPopups(popupUnitId, force));
return;
}
const popups = this._disposePopupsByUnit.get(unitId);
if (!popups) {
return;
}
for (let index = popups.length - 1; index >= 0; index -= 1) {
const popup = popups[index];
if (force || popup.canDispose()) {
popup.dispose();
popups.splice(index, 1);
}
}
if (popups.length === 0) {
this._disposePopupsByUnit.delete(unitId);
this._popupTargetsByUnit.delete(unitId);
}
}
private _getDisposePopups(unitId: string): INeedCheckDisposable[] {
const popups = this._disposePopupsByUnit.get(unitId);
if (popups) {
return popups;
}
const nextPopups: INeedCheckDisposable[] = [];
this._disposePopupsByUnit.set(unitId, nextPopups);
return nextPopups;
}
private _create(documentDataModel: Nullable<DocumentDataModel>) {
if (!documentDataModel) {
return;
}
const unitId = documentDataModel.getUnitId();
if (isInternalEditorID(unitId)) {
return;
}
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
const listener = this._popupMenuListener(unitId);
if (listener) {
this._popupMenuListeners.set(unitId, listener);
this._initImagePopupMenu.add(unitId);
}
}
}
private _disposePopupMenuListener(unitId: string) {
this._popupMenuListeners.get(unitId)?.dispose();
this._popupMenuListeners.delete(unitId);
this._initImagePopupMenu.delete(unitId);
this._clearPopups(unitId, true);
}
private _hasCropObject(scene: Scene) {
const objects = scene.getAllObjects();
for (const object of objects) {
if (object instanceof ImageCropperObject) {
return true;
}
}
return false;
}
private _handleClearControl(
unitId: string,
transformer: ReturnType<Scene['getTransformerByCreate']>,
changeSelf: boolean
): void {
this._clearPopups(unitId, changeSelf);
queueMicrotask(() => {
if (transformer.getSelectedObjectMap().size > 0) {
return;
}
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._drawingManagerService.focusDrawing(null);
});
}
private _registerDrawingPopup(
unitId: string,
popup: INeedCheckDisposable,
drawing: IDrawingPopupTarget
): void {
this.disposeWithMe(popup);
this._getDisposePopups(unitId).push(popup);
this._popupTargetsByUnit.set(unitId, drawing);
const focusDrawings = this._drawingManagerService.getFocusDrawings();
const alreadyFocused = focusDrawings.find((focusedDrawing) =>
focusedDrawing.unitId === drawing.unitId &&
focusedDrawing.subUnitId === drawing.subUnitId &&
focusedDrawing.drawingId === drawing.drawingId
);
if (!alreadyFocused) {
this._drawingManagerService.focusDrawing([drawing]);
}
}
private _isSamePopupTarget(previous: IDrawingPopupTarget | undefined, next: IDrawingPopupTarget): boolean {
return previous?.unitId === next.unitId &&
previous.subUnitId === next.subUnitId &&
previous.drawingId === next.drawingId;
}
// eslint-disable-next-line max-lines-per-function
private _popupMenuListener(unitId: string): IDisposable | undefined {
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (!scene) {
return;
}
const transformer = scene.getTransformerByCreate();
const subscriptions = [
// eslint-disable-next-line max-lines-per-function
transformer.createControl$.subscribe(() => {
if (this._hasCropObject(scene)) {
this._clearPopups(unitId, true);
return;
}
const selectedObjects = transformer.getSelectedObjectMap();
if (this._isDrawingPanelOpen || selectedObjects.size > 1) {
this._clearPopups(unitId, this._isDrawingPanelOpen);
return;
}
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
this._clearPopups(unitId);
return;
}
const drawingParam = this._drawingManagerService.getDrawingOKey(object.oKey);
if (
!drawingParam ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_DOM ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_SHAPE
) {
this._clearPopups(unitId);
return;
}
const { unitId: drawingUnitId, subUnitId, drawingId, drawingType } = drawingParam;
if (!this._canEditDrawing(drawingUnitId, drawingId)) {
this._clearPopups(unitId, true);
return;
}
const disposePopups = this._disposePopupsByUnit.get(unitId);
const previousPopupTarget = this._popupTargetsByUnit.get(unitId);
const popupTarget = { unitId: drawingUnitId, subUnitId, drawingId };
if (this._isSamePopupTarget(previousPopupTarget, popupTarget) && disposePopups && disposePopups.length > 0) {
return;
}
this._clearPopups(unitId, previousPopupTarget != null);
const isImage = drawingType === DrawingTypeEnum.DRAWING_IMAGE;
// Charts use the document toolbar placement, while retaining chart-specific actions and controls.
const isChart = drawingType === DrawingTypeEnum.DRAWING_CHART;
const popup = this._canvasPopManagerService.attachPopupToObject(
object,
{
componentKey: isImage || isChart ? COMPONENT_MOBILE_IMAGE_POPUP_MENU : MOBILE_DOC_ELEMENT_MENU,
requiresStableLayout: false,
direction: isImage || isChart ? 'top-center' : 'horizontal',
offset: isImage || isChart ? [0, 8] : [2, 0],
extraProps: {
onClose: () => this._clearPopups(unitId, true),
onEdit: () => {
const drawing = this._docDrawingService.getDrawingByParam(drawingParam);
const edit = drawing && this._drawingAdapterService.getEditDrawingCommandInfo({
unitId: drawingUnitId,
subUnitId,
drawing,
});
this._clearPopups(unitId, true);
return this._commandService.executeCommand(edit?.commandId ?? EditDocDrawingOperation.id, edit?.commandParams ?? { unitId: drawingUnitId, subUnitId, drawingId });
},
onDelete: () => {
this._clearPopups(unitId, true);
return this._commandService.executeCommand(RemoveDocDrawingCommand.id, {
unitId: drawingUnitId,
drawings: [{ unitId: drawingUnitId, subUnitId, drawingId }],
});
},
menuItems: this._getDrawingPopupMenuItems(drawingUnitId, subUnitId, drawingId, drawingType),
variant: isImage ? 'doc-floating-toolbar' : isChart ? 'doc-chart-floating-toolbar' : undefined,
unitId: drawingUnitId,
subUnitId,
drawingId,
},
},
drawingUnitId
);
this._registerDrawingPopup(unitId, popup, { unitId: drawingUnitId, subUnitId, drawingId });
}),
transformer.clearControl$.subscribe((changeSelf) => this._handleClearControl(unitId, transformer, changeSelf)),
transformer.changing$.subscribe(() => {
this._clearPopups(unitId, true);
}),
];
const disposable = toDisposable(() => subscriptions.forEach((subscription) => subscription.unsubscribe()));
this.disposeWithMe(disposable);
return disposable;
}
private _canEditDrawing(unitId: string, drawingId: string): boolean {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
unitId,
UniverInstanceType.UNIVER_DOC
);
if (!documentDataModel) {
return false;
}
const segmentId = getDocumentDrawingSegmentId(documentDataModel, drawingId);
return canEditDocumentTargets(this._permissionService, unitId, [
...getDocumentEntityParentPermissionObjectIds(documentDataModel, segmentId, 'drawing', drawingId),
getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawingId),
]);
}
private _getDrawingPopupMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) {
const drawing = this._docDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId });
const floatingToolbarMenuItems = drawing
? this._floatingToolbarAdapterService.getItems({ unitId, subUnitId, drawing })
: null;
const editCommandInfo = drawing
? this._drawingAdapterService.getEditDrawingCommandInfo({ unitId, subUnitId, drawing })
: null;
const defaultItems = [
{
label: editCommandInfo?.label ?? 'docs-drawing-ui.image-popup.edit',
index: 0,
commandId: editCommandInfo?.commandId ?? EditDocDrawingOperation.id,
commandParams: editCommandInfo?.commandParams ?? { unitId, subUnitId, drawingId },
disable: editCommandInfo?.disable ?? drawingType === DrawingTypeEnum.DRAWING_DOM,
hideOnClick: true,
icon: 'DrawingEditIcon',
},
{
label: 'docs-drawing-ui.image-popup.crop',
index: 1,
commandId: OpenImageCropOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
icon: 'DrawingCropIcon',
},
{
label: 'docs-drawing-ui.image-popup.delete',
index: 2,
commandId: RemoveDocDrawingCommand.id,
commandParams: { unitId, drawings: [{ unitId, subUnitId, drawingId }] },
disable: false,
icon: 'DrawingDeleteIcon',
},
{
label: 'docs-drawing-ui.image-popup.reset',
index: 3,
commandId: ImageResetSizeOperation.id,
commandParams: [{ unitId, subUnitId, drawingId }],
disable: true, // TODO: @JOCS, feature is not ready.
},
];
if (drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
return defaultItems.slice(0, 3);
}
if (drawingType === DrawingTypeEnum.DRAWING_CHART) {
return [{ ...defaultItems[0], label: 'docs-drawing-ui.image-popup.edit' }, defaultItems[2]];
}
return [...(floatingToolbarMenuItems ?? defaultItems), ...this._getFloatingObjectMenuItems()];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.DOC)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title || typeof item.icon !== 'string') {
return [];
}
return [{
type: 'button' as const,
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
icon: item.icon,
}];
});
}
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { IMenuManagerService } from '@univerjs/ui';
import { DeleteDocDrawingsCommand } from '../../commands/commands/delete-doc-drawing.command';
import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command';
import { InsertDocImageCommand } from '../../commands/commands/insert-image.command';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../../commands/commands/insert-shape.command';
import { MoveDocDrawingsCommand } from '../../commands/commands/move-drawings.command';
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
import {
IMoveInlineDrawingCommand,
ITransformNonInlineDrawingCommand,
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
} from '../../commands/commands/update-doc-drawing.command';
import { ClearDocDrawingTransformerOperation } from '../../commands/operations/clear-drawing-transformer.operation';
import { EditDocDrawingOperation } from '../../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingMobileOperation } from '../../commands/operations/mobile/open-drawing-panel.operation';
import { menuSchema } from '../../menu/schema';
export class MobileDocDrawingUIController extends Disposable {
constructor(
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initCommands();
this._initMenus();
}
private _initCommands(): void {
[
InsertDocImageCommand,
InsertDocRectangleShapeCommand,
InsertDocEllipseShapeCommand,
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
IMoveInlineDrawingCommand,
ITransformNonInlineDrawingCommand,
SidebarDocDrawingMobileOperation,
ClearDocDrawingTransformerOperation,
EditDocDrawingOperation,
GroupDocDrawingCommand,
UngroupDocDrawingCommand,
MoveDocDrawingsCommand,
DeleteDocDrawingsCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
}

View File

@@ -0,0 +1,65 @@
/**
* 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 '@univerjs/docs-drawing';
import type { IImageData } from '@univerjs/drawing';
import { DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getDocImageCropUpdates } from '../doc-drawing-crop';
function drawing(): IDocDrawing {
return {
unitId: 'doc',
subUnitId: 'doc',
drawingId: 'note-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
transform: { left: 40, top: 600, width: 100, height: 40 },
docTransform: {
angle: 0,
size: { width: 150, height: 60 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 40 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 600 },
},
};
}
describe('document image crop updates', () => {
it('does not turn an uncropped transformer notification into a second size mutation', () => {
const image = drawing();
const notification = { ...image, srcRect: undefined } as IImageData;
expect(getDocImageCropUpdates(notification, image, notification)).toEqual([]);
expect(image.docTransform.size).toEqual({ width: 150, height: 60 });
});
it('persists a real crop and permits an explicit reset without moving an inline anchor', () => {
const image = drawing();
const renderImage = image as IImageData;
const crop = { left: 10, right: 10, top: 4, bottom: 4 };
expect(getDocImageCropUpdates({
...renderImage,
srcRect: crop,
transform: { left: 50, top: 604, width: 80, height: 32 },
}, image, renderImage)).toEqual([
{ drawingId: 'note-image', key: 'srcRect', value: crop },
{ drawingId: 'note-image', key: 'size', value: { width: 80, height: 32 } },
]);
expect(getDocImageCropUpdates({ ...renderImage, srcRect: null }, image, renderImage)).toEqual([
{ drawingId: 'note-image', key: 'srcRect', value: null },
{ drawingId: 'note-image', key: 'size', value: { width: 100, height: 40 } },
]);
});
});

View File

@@ -0,0 +1,833 @@
/**
* 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 { DocumentDrawingPublicationProgress } from '../doc-drawing-transform-update.controller';
import {
AlignTypeH,
AlignTypeV,
BooleanNumber,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { Liquid, setDocsTableRenderViewportProvider } from '@univerjs/engine-render';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDocsTableCellAnchorContext } from '../../doc-drawing-transformer-update.controller';
import { doesDocMutationAffectDrawingPresentation, getDocMutationAffectedDrawingIds } from '../doc-drawing-mutation';
import {
DocDrawingPublicationTracker,
DocDrawingTransformUpdateController,
getDocsDrawingBehindText,
getDocsDrawingClipPage,
getDocsDrawingPageClipBounds,
getDocsPageRelativeDrawingAnchorPage,
getDocsPageRelativeDrawingLeft,
getDocsPageRelativeDrawingTop,
getDocsTableCellDrawingOffset,
shouldRefreshDocDrawingTransform,
} from '../doc-drawing-transform-update.controller';
describe('DocDrawingTransformUpdateController', () => {
afterEach(() => {
setDocsTableRenderViewportProvider(null);
});
it('does not eagerly refresh drawings for table metadata mutations', () => {
expect(doesDocMutationAffectDrawingPresentation([
'tableSource',
'table-1',
'tableColumns',
0,
'size',
'width',
'v',
{ r: 28, i: 32 },
] as never)).toBe(false);
expect(doesDocMutationAffectDrawingPresentation([
'drawings',
'drawing-1',
'docTransform',
{ r: {}, i: { angle: 1 } },
] as never)).toBe(true);
expect([...getDocMutationAffectedDrawingIds([
'drawings',
'drawing-1',
'docTransform',
{ r: {}, i: { angle: 1 } },
] as never)!]).toEqual(['drawing-1']);
expect(getDocMutationAffectedDrawingIds([
'drawingsOrder',
{ r: [], i: ['drawing-1'] },
] as never)).toBeNull();
});
it('filters unchanged drawing transform notifications but preserves viewport refreshes', () => {
const current = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
drawingType: 0,
behindText: false,
hidden: false,
isMultiTransform: BooleanNumber.FALSE,
transform: { left: 10, top: 20, width: 30, height: 40 },
transforms: [],
};
expect(shouldRefreshDocDrawingTransform(current as never, { ...current } as never)).toBe(false);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
transform: { ...current.transform, left: 11 },
} as never)).toBe(true);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
customBlockRenderViewport: { contentWidth: 100 },
} as never)).toBe(true);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
selectable: false,
} as never)).toBe(true);
});
it('publishes preview-only drawings as non-selectable and restores canonical drawings', () => {
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
controller._context = {
unit: { getSnapshot: () => ({ drawings: {} }) },
};
controller._liquid = new Liquid();
const drawing = {
aLeft: 10,
aTop: 20,
width: 30,
height: 40,
angle: 0,
drawingId: 'drawing-1',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
behindDoc: BooleanNumber.FALSE,
docTransform: { positionH: {}, positionV: {} },
},
};
const page = {
footerId: '',
headerId: '',
isLayoutPlaceholder: true,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
marginTop: 10,
pageHeight: 300,
pageWidth: 200,
skeColumnGroups: new Map(),
skeDrawings: new Map([['drawing-1', drawing]]),
skeTables: new Map(),
};
const previewDrawings = {} as Record<string, { selectable: boolean }>;
controller._collectPublishedPageDrawingPositions(
'doc-1',
page,
new Map(),
new Map(),
0,
0,
previewDrawings
);
expect(previewDrawings['drawing-1']).toMatchObject({ selectable: false });
page.isLayoutPlaceholder = false;
const canonicalDrawings = {} as Record<string, { selectable: boolean }>;
controller._collectPublishedPageDrawingPositions(
'doc-1',
page,
new Map(),
new Map(),
0,
0,
canonicalDrawings
);
expect(canonicalDrawings['drawing-1']).toMatchObject({ selectable: true });
});
it('refreshes only incremental publications that can change drawing transforms', () => {
const drawing = {};
const pages = [
{ headerId: '', footerId: '', pageWidth: 100, skeDrawings: new Map([['cover', drawing]]), skeTables: new Map(), skeColumnGroups: new Map() },
{ headerId: '', footerId: '', pageWidth: 100, skeDrawings: new Map(), skeTables: new Map(), skeColumnGroups: new Map() },
{ headerId: '', footerId: '', pageWidth: 100, skeDrawings: new Map([['later', drawing]]), skeTables: new Map(), skeColumnGroups: new Map() },
];
const skeleton = {
getSkeletonData: () => ({
pages,
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
const tracker = new DocDrawingPublicationTracker();
const progress = (overrides: Partial<DocumentDrawingPublicationProgress>): DocumentDrawingPublicationProgress => ({
generation: 1,
didPublish: true,
didPublishAnchor: false,
publishedPageCount: 1,
reason: 'initial',
complete: false,
...overrides,
});
expect(tracker.shouldRefresh(skeleton, progress({}))).toBe(true);
expect(tracker.shouldRefresh(skeleton, progress({ publishedPageCount: 2 }))).toBe(false);
expect(tracker.shouldRefresh(skeleton, progress({ publishedPageCount: 3 }))).toBe(true);
expect(tracker.shouldRefresh(skeleton, progress({
generation: 2,
reason: 'edit',
publishedPageCount: 3,
}))).toBe(false);
expect(tracker.shouldRefresh(skeleton, progress({
generation: 2,
reason: 'edit',
didPublishAnchor: true,
publishedPageCount: 3,
}))).toBe(true);
});
it('refreshes when a later anchor resolves a drawing onto an already published page', () => {
const firstPageDrawings = new Map();
const pages = [
{ headerId: '', footerId: '', pageWidth: 100, skeDrawings: firstPageDrawings, skeTables: new Map(), skeColumnGroups: new Map() },
{ headerId: '', footerId: '', pageWidth: 100, skeDrawings: new Map(), skeTables: new Map(), skeColumnGroups: new Map() },
];
const skeleton = {
getSkeletonData: () => ({
pages,
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
const tracker = new DocDrawingPublicationTracker();
const progress = (publishedPageCount: number): DocumentDrawingPublicationProgress => ({
generation: 1,
didPublish: true,
didPublishAnchor: false,
publishedPageCount,
reason: 'edit',
complete: false,
});
expect(tracker.shouldRefresh(skeleton, progress(1))).toBe(false);
expect(tracker.shouldRefresh(skeleton, progress(2))).toBe(false);
firstPageDrawings.set('late-drawing', {});
expect(tracker.shouldRefresh(skeleton, progress(2))).toBe(true);
});
it.each(['column', 'footnote'])('refreshes when a published %s contains drawings', (scope) => {
const nestedPage = {
skeDrawings: new Map([['column-drawing', {}]]),
skeTables: new Map(),
skeColumnGroups: new Map(),
};
const pages = [{
headerId: '',
footerId: '',
pageWidth: 100,
skeDrawings: new Map(),
skeTables: new Map(),
skeColumnGroups: new Map(scope === 'column'
? [['column-group', {
columns: [{ page: nestedPage }],
}]]
: []),
notes: scope === 'footnote' ? [{ page: nestedPage }] : [],
}];
const skeleton = {
getSkeletonData: () => ({
pages,
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
const tracker = new DocDrawingPublicationTracker();
expect(tracker.shouldRefresh(skeleton, {
generation: 1,
didPublish: true,
didPublishAnchor: false,
publishedPageCount: 1,
reason: 'initial',
complete: false,
})).toBe(true);
});
it('projects drawings in table cells through table, row, cell and scroll offsets', () => {
setDocsTableRenderViewportProvider((unitId, tableId) => {
if (unitId !== 'unit-1' || tableId !== 'table-1') {
return null;
}
return {
contentWidth: 480,
scrollLeft: 30,
viewportWidth: 160,
};
});
const table = {
left: 40,
tableId: 'table-1#-#0',
top: 80,
};
const row = {
top: 12,
};
const cell = {
left: 120,
marginLeft: 8,
marginTop: 6,
};
expect(getDocsTableCellDrawingOffset('unit-1', table as never, row as never, cell as never)).toEqual({
left: 138,
top: 98,
});
});
it('resolves a table cell drawing anchor to the host page and scrolled cell offset', () => {
setDocsTableRenderViewportProvider((unitId, tableId) => {
if (unitId !== 'unit-1' || tableId !== 'table-1') {
return null;
}
return {
contentWidth: 480,
scrollLeft: 30,
viewportWidth: 160,
};
});
const hostPage = { type: 'body' };
const cell = {
left: 120,
marginLeft: 8,
marginTop: 6,
};
const row = {
cells: [cell],
top: 12,
};
const table = {
left: 40,
parent: hostPage,
rows: [row],
tableId: 'table-1#-#0',
top: 80,
};
Object.assign(row, { parent: table });
Object.assign(cell, { parent: row });
expect(getDocsTableCellAnchorContext('unit-1', cell as never)).toMatchObject({
hostPage,
offset: {
left: 138,
top: 98,
},
});
});
it('refreshes normal, table-cell, header, and multi-transform drawings into drawing notifications', () => {
const selectedShape = {};
const currentDrawings: Record<string, unknown> = {};
const transformer = {
getSelectedObjectMap: vi.fn(() => new Map([['multi-drawing', selectedShape]])),
setSelectedControl: vi.fn(),
};
const context = {
unitId: 'unit-1',
unit: {
getSnapshot: vi.fn(() => ({ drawings: currentDrawings })),
},
mainComponent: {
left: 5,
top: 7,
pageLayoutType: 0,
pageMarginLeft: 0,
pageMarginTop: 20,
},
scene: {
getTransformerByCreate: vi.fn(() => transformer),
getObject: vi.fn((key) => key === 'multi-drawing' ? selectedShape : null),
},
};
const drawingManagerService = {
refreshTransform: vi.fn(),
getDrawingByParam: vi.fn((drawing) => ({ ...drawing })),
getDrawingData: vi.fn(() => ({
'multi-drawing': { drawingId: 'multi-drawing', isMultiTransform: BooleanNumber.TRUE },
'stale-multi': { drawingId: 'stale-multi', isMultiTransform: BooleanNumber.TRUE },
'stale-normal': {
drawingId: 'stale-normal',
transform: { left: 0, top: 0, width: 100, height: 100, angle: 0 },
},
})),
removeNotification: vi.fn(),
addNotification: vi.fn(),
};
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
controller._context = context;
controller._drawingManagerService = drawingManagerService;
controller._liquid = new Liquid();
const defaultDocTransform = {
positionH: {},
positionV: {},
};
const normalDrawing = {
aLeft: 10,
aTop: 20,
width: 30,
height: 40,
angle: 5,
drawingId: 'normal-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
docTransform: defaultDocTransform,
},
};
const pageRelativeDrawing = {
...normalDrawing,
drawingId: 'page-relative-drawing',
drawingOrigin: {
...normalDrawing.drawingOrigin,
docTransform: {
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 99 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 88 },
},
},
};
currentDrawings['page-relative-drawing'] = {
...pageRelativeDrawing.drawingOrigin,
behindDoc: BooleanNumber.FALSE,
docTransform: {
angle: 6,
size: { width: 31, height: 41 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 109 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 98 },
},
};
const squareDrawing = {
...normalDrawing,
drawingId: 'square-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
behindDoc: BooleanNumber.FALSE,
docTransform: pageRelativeDrawing.drawingOrigin.docTransform,
},
};
const topBottomDrawing = {
...squareDrawing,
aLeft: 12,
aTop: 22,
drawingId: 'top-bottom-drawing',
drawingOrigin: {
...squareDrawing.drawingOrigin,
layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
},
};
const headerDrawing = {
aLeft: 3,
aTop: 4,
width: 9,
height: 10,
angle: 0,
drawingId: 'header-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
docTransform: defaultDocTransform,
},
};
const tableCellDrawing = {
aLeft: 1,
aTop: 2,
width: 11,
height: 12,
angle: 0,
drawingId: 'cell-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
docTransform: defaultDocTransform,
},
};
const columnDrawing = {
...tableCellDrawing,
aLeft: 2,
aTop: 3,
drawingId: 'column-drawing',
};
const columnTableCellDrawing = {
...tableCellDrawing,
drawingId: 'column-table-cell-drawing',
};
const inlineDrawing = {
aLeft: 15,
aTop: 25,
width: 35,
height: 45,
angle: 0,
drawingId: 'inline-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.INLINE,
behindDoc: BooleanNumber.FALSE,
docTransform: {
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 99 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 88 },
},
},
};
const multiDrawingOnPage = {
aLeft: 6,
aTop: 8,
width: 13,
height: 14,
angle: 2,
drawingId: 'multi-drawing',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
behindDoc: BooleanNumber.FALSE,
isMultiTransform: BooleanNumber.TRUE,
docTransform: defaultDocTransform,
},
};
const multiDrawingInHeader = {
...multiDrawingOnPage,
aLeft: 16,
aTop: 18,
};
const cell = {
left: 7,
marginLeft: 2,
marginTop: 3,
skeDrawings: new Map([['cell-drawing', tableCellDrawing]]),
skeTables: [],
};
const table = {
left: 30,
top: 40,
tableId: 'table-1#-#0',
rows: [{
top: 4,
cells: [cell],
}],
};
const columnTableCell = {
left: 3,
marginLeft: 1,
marginTop: 1,
skeDrawings: new Map([['column-table-cell-drawing', columnTableCellDrawing]]),
skeTables: new Map(),
skeColumnGroups: new Map(),
};
const columnTable = {
left: 4,
top: 5,
tableId: 'column-table#-#0',
rows: [{
top: 2,
cells: [columnTableCell],
}],
};
const columnPage = {
marginLeft: 0,
marginTop: 0,
skeDrawings: new Map([['column-drawing', columnDrawing]]),
skeTables: new Map([['column-table', columnTable]]),
skeColumnGroups: new Map(),
};
const columnGroup = {
left: 50,
top: 60,
columns: [{
left: 8,
top: 9,
page: columnPage,
}],
};
const page = {
headerId: 'header-1',
pageWidth: 200,
pageHeight: 300,
marginLeft: 11,
marginTop: 13,
marginBottom: 17,
skeDrawings: new Map([
['normal-drawing', normalDrawing],
['page-relative-drawing', pageRelativeDrawing],
['square-drawing', squareDrawing],
['top-bottom-drawing', topBottomDrawing],
['inline-drawing', inlineDrawing],
['multi-drawing', multiDrawingOnPage],
]),
skeTables: [table],
skeColumnGroups: new Map([['column-group', columnGroup]]),
};
const headerPage = {
marginTop: 19,
skeDrawings: new Map([
['header-drawing', headerDrawing],
['multi-drawing', multiDrawingInHeader],
]),
skeTables: [],
};
const skeleton = {
getSkeletonData: () => ({
pages: [page],
skeHeaders: new Map([['header-1', new Map([[200, headerPage]])]]),
skeFooters: new Map(),
}),
};
controller._refreshDrawing(skeleton);
expect(drawingManagerService.refreshTransform).toHaveBeenCalledWith([
expect.objectContaining({
drawingId: 'header-drawing',
transform: expect.objectContaining({ left: 19, top: 30, width: 9, height: 10, angle: 0 }),
}),
expect.objectContaining({
drawingId: 'normal-drawing',
behindText: true,
transform: expect.objectContaining({ left: 26, top: 40, width: 30, height: 40, angle: 5 }),
}),
expect.objectContaining({
drawingId: 'page-relative-drawing',
behindText: false,
transform: expect.objectContaining({ left: 114, top: 105, width: 31, height: 41, angle: 6 }),
}),
expect.objectContaining({
drawingId: 'square-drawing',
transform: expect.objectContaining({ left: 26, top: 40, width: 30, height: 40, angle: 5 }),
}),
expect.objectContaining({
drawingId: 'top-bottom-drawing',
transform: expect.objectContaining({ left: 28, top: 42, width: 30, height: 40, angle: 5 }),
}),
expect.objectContaining({
drawingId: 'inline-drawing',
transform: expect.objectContaining({ left: 31, top: 45, width: 35, height: 45, angle: 0 }),
}),
expect.objectContaining({
drawingId: 'cell-drawing',
transform: expect.objectContaining({ left: 56, top: 69, width: 11, height: 12, angle: 0 }),
}),
expect.objectContaining({
drawingId: 'column-drawing',
transform: expect.objectContaining({ left: 76, top: 92, width: 11, height: 12, angle: 0 }),
}),
expect.objectContaining({
drawingId: 'column-table-cell-drawing',
transform: expect.objectContaining({ left: 83, top: 99, width: 11, height: 12, angle: 0 }),
}),
expect.objectContaining({
drawingId: 'stale-normal',
hidden: true,
transform: expect.objectContaining({ left: 0, top: 0, width: 100, height: 100, angle: 0 }),
}),
]);
expect(drawingManagerService.removeNotification).toHaveBeenCalledWith([
{ drawingId: 'multi-drawing', isMultiTransform: BooleanNumber.TRUE },
{ drawingId: 'stale-multi', isMultiTransform: BooleanNumber.TRUE },
]);
expect(drawingManagerService.addNotification).toHaveBeenCalledWith([
expect.objectContaining({
drawingId: 'multi-drawing',
isMultiTransform: BooleanNumber.TRUE,
transforms: [
expect.objectContaining({ left: 32, top: 44, width: 13, height: 14, angle: 2 }),
expect.objectContaining({ left: 22, top: 28, width: 13, height: 14, angle: 2 }),
],
}),
]);
expect(transformer.setSelectedControl).toHaveBeenCalledWith(selectedShape);
});
it('projects a document page clip bound in scene coordinates before page padding is applied', () => {
expect(getDocsDrawingPageClipBounds({
docsLeft: 20,
docsTop: 30,
pageOffsetLeft: 100,
pageOffsetTop: 200,
page: {
pageWidth: 793,
pageHeight: 1122,
marginLeft: 48,
marginTop: 48,
},
} as never)).toEqual({
left: 120,
top: 230,
width: 793,
height: 1122,
});
});
it('projects a table cell drawing clip bound through the cell offset', () => {
expect(getDocsDrawingPageClipBounds({
docsLeft: 20,
docsTop: 30,
pageOffsetLeft: 100,
pageOffsetTop: 200,
clipOffsetLeft: 138,
clipOffsetTop: 98,
page: {
pageWidth: 321,
pageHeight: 300,
},
})).toEqual({
left: 258,
top: 328,
width: 321,
height: 300,
});
});
it('clips page-sized header background drawings to the host document page', () => {
const headerPage = {
pageWidth: 589,
pageHeight: 476,
};
const hostPage = {
pageWidth: 816,
pageHeight: 1056,
};
expect(getDocsDrawingClipPage({
drawing: {
behindText: true,
transform: {
width: 815,
height: 1055,
},
},
hostPage,
page: headerPage,
})).toBe(hostPage);
expect(getDocsDrawingClipPage({
drawing: {
behindText: true,
transform: {
width: 120,
height: 40,
},
},
hostPage,
page: headerPage,
})).toBe(headerPage);
});
it('clips overflowing header cover drawings to the host document page', () => {
const headerPage = {
pageWidth: 601,
pageHeight: 510,
};
const hostPage = {
pageWidth: 794,
pageHeight: 1123,
};
expect(getDocsDrawingClipPage({
drawing: {
behindText: true,
transform: {
width: 803,
height: 682,
},
},
hostPage,
page: headerPage,
})).toBe(hostPage);
});
it('positions page-sized header backgrounds against the host page width', () => {
expect(getDocsPageRelativeDrawingLeft({
hostPage: {
pageWidth: 816,
},
positionH: {
align: AlignTypeH.RIGHT,
relativeFrom: ObjectRelativeFromH.PAGE,
},
width: 815,
})).toBe(1);
});
it('positions page-sized header backgrounds against the host page height', () => {
expect(getDocsPageRelativeDrawingTop({
hostPage: {
pageHeight: 1056,
},
positionV: {
align: AlignTypeV.TOP,
relativeFrom: ObjectRelativeFromV.PAGE,
},
height: 1055,
})).toBe(0);
expect(getDocsPageRelativeDrawingTop({
hostPage: {
pageHeight: 1056,
},
positionV: {
align: AlignTypeV.BOTTOM,
relativeFrom: ObjectRelativeFromV.PAGE,
},
height: 1055,
})).toBe(1);
});
it('uses body pages as page-relative drawing anchors when no host page exists', () => {
const bodyPage = {
pageWidth: 816,
pageHeight: 1056,
};
expect(getDocsPageRelativeDrawingAnchorPage({
page: bodyPage,
clipPage: bodyPage,
})).toBe(bodyPage);
});
it('renders header and footer drawings in the host page behind-text layer', () => {
expect(getDocsDrawingBehindText({
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
},
hostPage: {
pageWidth: 816,
pageHeight: 1056,
},
} as never)).toBe(true);
expect(getDocsDrawingBehindText({
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
},
} as never)).toBe(false);
});
});

View File

@@ -0,0 +1,684 @@
/**
* 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 { BooleanNumber, DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, ICommandService, IContextService, IImageIoService, Injector, IPermissionService, LocaleService, ObjectRelativeFromH, ObjectRelativeFromV, PermissionService, PositionedObjectLayoutType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, setDocumentPermissionValue } from '@univerjs/docs';
import { IDocDrawingService, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { GroupDocDrawingCommand } from '../../../commands/commands/group-doc-drawing.command';
import { UngroupDocDrawingCommand } from '../../../commands/commands/ungroup-doc-drawing.command';
import { DocRefreshDrawingsService } from '../../../services/doc-refresh-drawings.service';
import { DocDrawingUpdateRenderController } from '../doc-drawing-update.render-controller';
import { MobileDocDrawingUpdateRenderController } from '../mobile/doc-drawing-update.render-controller';
function createController(options: {
editArea?: DocumentEditArea;
drawings?: Record<string, unknown>;
isFocusing?: boolean;
mobile?: boolean;
openFile?: () => Promise<File[]>;
saveImage?: (file: File) => Promise<unknown>;
} = {}) {
const featurePluginOrderUpdate$ = new Subject<any>();
const featurePluginUpdate$ = new Subject<any[]>();
const featurePluginGroupUpdate$ = new Subject<any>();
const featurePluginUngroupUpdate$ = new Subject<any>();
const focus$ = new Subject<any[] | null>();
const add$ = new Subject<Array<{ unitId: string }>>();
const update$ = new Subject<Array<{ unitId: string }>>();
const changeEnd$ = new Subject<any>();
const editAreaChange$ = new Subject<void>();
const refreshDrawings$ = new Subject<unknown>();
const onFocus$ = new Subject<unknown>();
const onBlur$ = new Subject<unknown>();
const commandHandlers: Array<(command: { id: string; params?: Record<string, unknown> }) => void> = [];
let editArea = options.editArea ?? DocumentEditArea.BODY;
let isFocusing = options.isFocusing ?? true;
let focusDrawings: Array<{ unitId: string; subUnitId: string; drawingId: string }> = [];
focus$.subscribe((drawings) => {
focusDrawings = drawings ?? [];
});
const transformer = {
changeEnd$,
clearControlByIds: vi.fn(),
resetProps: vi.fn(),
};
const shapeByDrawingId = new Map<string, any>();
const getShape = (drawingId: string) => {
if (!shapeByDrawingId.has(drawingId)) {
shapeByDrawingId.set(drawingId, {
drawingId,
evented: true,
oKey: `doc-1#-#doc-1#-#${drawingId}`,
setOpacity: vi.fn(),
transformerConfig: {},
});
}
return shapeByDrawingId.get(drawingId);
};
Object.keys(options.drawings ?? {}).forEach(getShape);
const scene = {
getTransformerByCreate: vi.fn(() => transformer),
fuzzyMathObjects: vi.fn((objectKey: string) => {
const drawingId = objectKey.split('#-#').at(-1) ?? objectKey;
return [getShape(drawingId)];
}),
getAllObjects: vi.fn(() => [...shapeByDrawingId.values()]),
attachTransformerTo: vi.fn(),
detachTransformerFrom: vi.fn(),
getTransformer: vi.fn(() => transformer),
};
const viewModel = {
editAreaChange$,
getEditArea: vi.fn(() => editArea),
};
const renderUnit = {
with: vi.fn(() => ({
getViewModel: () => viewModel,
})),
};
const snapshot = {
body: {
dataStream: 'abcdefghij\r\n',
customBlocks: [
{ blockId: 'body-drawing', startIndex: 4 },
],
},
headers: {
'header-1': {
body: {
dataStream: 'abcdefghij\r\n',
customBlocks: [
{ blockId: 'header-drawing', startIndex: 2 },
],
},
},
},
footers: {
'footer-1': {
body: {
dataStream: 'abcdefghij\r\n',
customBlocks: [
{ blockId: 'footer-drawing', startIndex: 7 },
],
},
},
},
drawings: options.drawings ?? {},
};
const context = {
unitId: 'doc-1',
unit: {
getDrawings: vi.fn(() => snapshot.drawings),
getMutationRevision: vi.fn(() => 0),
getSnapshot: vi.fn(() => snapshot),
getBody: vi.fn(() => snapshot.body),
getSelfOrHeaderFooterModel: vi.fn((segmentId: string) => ({
getBody: () => segmentId ? snapshot.headers[segmentId as keyof typeof snapshot.headers]?.body ?? snapshot.footers[segmentId as keyof typeof snapshot.footers]?.body : snapshot.body,
})),
},
scene,
mainComponent: {
getOffsetConfig: () => ({ docsLeft: 12, docsTop: 18 }),
},
};
const commandService = {
executeCommand: vi.fn(),
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
};
const docSelectionManagerService = {
getActiveTextRange: vi.fn(() => null),
refreshSelection: vi.fn(),
replaceDocRanges: vi.fn(),
};
const renderManagerSrv = {
getRenderUnitById: vi.fn(() => renderUnit),
};
const docDrawingService = {
focusDrawing: vi.fn(),
getDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => options.drawings?.[drawingId]),
};
const drawingManagerService = {
add$,
featurePluginUpdate$,
featurePluginOrderUpdate$,
featurePluginGroupUpdate$,
featurePluginUngroupUpdate$,
focus$,
update$,
getDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => options.drawings?.[drawingId]),
focusDrawing: vi.fn(),
getFocusDrawings: vi.fn(() => focusDrawings),
};
const contextService = {
getContextValue: vi.fn(() => false),
setContextValue: vi.fn(),
};
const docSelectionRenderService = {
get isFocusing() {
return isFocusing;
},
getActiveTextRange: vi.fn(() => null),
getAllTextRanges: vi.fn(() => []),
getSegment: vi.fn(() => ''),
onBlur$,
onFocus$,
setSegment: vi.fn(),
};
const imageIoService = {
addImageSourceCache: vi.fn(),
saveImage: vi.fn(options.saveImage),
};
const fileOpenerService = {
openFile: vi.fn(options.openFile ?? (async () => [])),
};
const injector = new Injector([
[IPermissionService, { useClass: PermissionService }],
[ICommandService, { useValue: commandService }],
[DocSelectionManagerService, { useValue: docSelectionManagerService }],
[IRenderManagerService, { useValue: renderManagerSrv }],
[IImageIoService, { useValue: imageIoService }],
[IDocDrawingService, { useValue: docDrawingService }],
[IDrawingManagerService, { useValue: drawingManagerService }],
[IContextService, { useValue: contextService }],
[IMessageService, { useValue: { show: vi.fn() } }],
[LocaleService],
[DocSelectionRenderService, { useValue: docSelectionRenderService }],
[DocRefreshDrawingsService, { useValue: { refreshDrawings$ } }],
[ILocalFileService, { useValue: fileOpenerService }],
]);
const permissionService = injector.get(IPermissionService);
const controller = injector.createInstance(
options.mobile ? MobileDocDrawingUpdateRenderController : DocDrawingUpdateRenderController,
context as never
);
return {
controller,
commandHandlers,
commandService,
contextService,
docDrawingService,
docSelectionManagerService,
docSelectionRenderService,
drawingManagerService,
editAreaChange$,
featurePluginUpdate$,
focus$,
getShape: (drawingId: string) => shapeByDrawingId.get(drawingId),
onBlur$,
onFocus$,
permissionService,
refreshDrawings$,
scene,
setEditArea: (value: DocumentEditArea) => {
editArea = value;
},
setIsFocusing: (value: boolean) => {
isFocusing = value;
},
transformer,
update$,
};
}
describe('DocDrawingUpdateRenderController', () => {
it('persists inline image crop data without moving its text anchor', () => {
const drawing = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'body-drawing',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
transform: { left: 10, top: 20, width: 100, height: 80 },
docTransform: {
size: { width: 100, height: 80 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 10 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 20 },
angle: 0,
},
};
const { commandService, featurePluginUpdate$ } = createController({ drawings: { 'body-drawing': drawing } });
featurePluginUpdate$.next([{
...drawing,
transform: { left: 25, top: 30, width: 70, height: 60 },
srcRect: { left: 15, top: 10, right: 15, bottom: 10 },
}]);
expect(commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'body-drawing', key: 'srcRect', value: { left: 15, top: 10, right: 15, bottom: 10 } },
{ drawingId: 'body-drawing', key: 'size', value: { width: 70, height: 60 } },
],
});
});
it('persists floating crop position and ignores unrelated drawing updates', () => {
const drawing = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'body-drawing',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
transform: { left: 10, top: 20, width: 100, height: 80 },
docTransform: {
size: { width: 100, height: 80 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 40 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 50 },
angle: 0,
},
};
const { commandService, featurePluginUpdate$ } = createController({ drawings: { 'body-drawing': drawing } });
featurePluginUpdate$.next([{ ...drawing, transform: { left: 12, top: 24, width: 90, height: 70 } }]);
featurePluginUpdate$.next([{ ...drawing, unitId: 'other-doc', srcRect: null }]);
expect(commandService.executeCommand).not.toHaveBeenCalled();
featurePluginUpdate$.next([{
...drawing,
transform: { left: 25, top: 26, width: 70, height: 60 },
srcRect: { left: 15, top: 6, right: 15, bottom: 14 },
}]);
expect(commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'body-drawing', key: 'srcRect', value: { left: 15, top: 6, right: 15, bottom: 14 } },
{ drawingId: 'body-drawing', key: 'size', value: { width: 70, height: 60 } },
{ drawingId: 'body-drawing', key: 'positionH', value: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 55 } },
{ drawingId: 'body-drawing', key: 'positionV', value: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 56 } },
],
});
});
it('persists shared crop data for repeated header and footer drawings without moving each occurrence', () => {
const drawing = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'header-drawing',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
isMultiTransform: BooleanNumber.TRUE,
transform: { left: 10, top: 20, width: 100, height: 80 },
transforms: [
{ left: 10, top: 20, width: 100, height: 80 },
{ left: 10, top: 820, width: 100, height: 80 },
],
docTransform: {
size: { width: 100, height: 80 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 10 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 20 },
angle: 0,
},
};
const { commandService, featurePluginUpdate$ } = createController({ drawings: { 'header-drawing': drawing } });
featurePluginUpdate$.next([{ ...drawing, srcRect: { left: 10, top: 0, right: 10, bottom: 0 } }]);
expect(commandService.executeCommand).toHaveBeenCalledWith(UpdateDrawingDocTransformCommand.id, {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawings: [
{ drawingId: 'header-drawing', key: 'srcRect', value: { left: 10, top: 0, right: 10, bottom: 0 } },
{ drawingId: 'header-drawing', key: 'size', value: { width: 100, height: 80 } },
],
});
});
it('forwards drawing order and group events to doc drawing commands', () => {
const { commandService, drawingManagerService } = createController();
drawingManagerService.featurePluginOrderUpdate$.next({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingIds: ['drawing-1'],
arrangeType: 'forward',
});
drawingManagerService.featurePluginGroupUpdate$.next({ unitId: 'doc-1', drawingIds: ['a', 'b'] });
drawingManagerService.featurePluginUngroupUpdate$.next({ unitId: 'doc-1', drawingIds: ['group-1'] });
expect(commandService.executeCommand).toHaveBeenNthCalledWith(1, SetDocDrawingArrangeCommand.id, {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingIds: ['drawing-1'],
arrangeType: 'forward',
});
expect(commandService.executeCommand).toHaveBeenNthCalledWith(2, GroupDocDrawingCommand.id, { unitId: 'doc-1', drawingIds: ['a', 'b'] });
expect(commandService.executeCommand).toHaveBeenNthCalledWith(3, UngroupDocDrawingCommand.id, { unitId: 'doc-1', drawingIds: ['group-1'] });
});
it('keeps document selection and segment in sync when drawings gain or lose focus', () => {
const {
contextService,
docDrawingService,
docSelectionManagerService,
docSelectionRenderService,
focus$,
transformer,
} = createController();
focus$.next([]);
expect(contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, false);
expect(docDrawingService.focusDrawing).toHaveBeenCalledWith([]);
expect(transformer.resetProps).toHaveBeenLastCalledWith({ zeroTop: 0, zeroLeft: 0 });
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
expect(contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, true);
expect(docDrawingService.focusDrawing).toHaveBeenLastCalledWith([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([{ startOffset: 4, endOffset: 5, segmentId: '' }]);
expect(docSelectionRenderService.setSegment).not.toHaveBeenCalled();
expect(transformer.resetProps).toHaveBeenLastCalledWith({ zeroTop: 18, zeroLeft: 12 });
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'header-drawing' }]);
expect(docSelectionRenderService.setSegment).toHaveBeenCalledWith('header-1');
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenLastCalledWith([{ startOffset: 2, endOffset: 3, segmentId: 'header-1' }]);
});
it('refreshes doc selection after transformer changes finish', async () => {
vi.useFakeTimers();
const { docSelectionManagerService, transformer } = createController();
transformer.changeEnd$.next({});
await vi.advanceTimersByTimeAsync(30);
expect(docSelectionManagerService.refreshSelection).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it.each([false, true])('configures selection-first movement for mobile mode %s', (mobile) => {
const { transformer } = createController({ mobile });
expect(transformer.resetProps).toHaveBeenCalledWith({ moveOnlyWhenSelected: mobile });
});
it('cancels an image insertion when the render controller is disposed while saving', async () => {
let finishSaving: (value: unknown) => void = () => {};
const saveImage = vi.fn(() => new Promise<unknown>((resolve) => {
finishSaving = resolve;
}));
const { commandService, controller } = createController({
openFile: async () => [{} as File],
saveImage,
});
const insertion = controller.insertDocImage();
await vi.waitFor(() => expect(saveImage).toHaveBeenCalledTimes(1));
controller.dispose();
finishSaving({
imageId: 'image-1',
imageSourceType: 'URL',
source: 'image.png',
base64Cache: 'data:image/png;base64,',
});
await expect(insertion).resolves.toBe(false);
expect(commandService.executeCommand).not.toHaveBeenCalled();
});
it('does not open the image picker when Document Edit is denied', async () => {
const openFile = vi.fn(async () => [{} as File]);
const { commandService, controller, permissionService } = createController({ openFile });
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
await expect(controller.insertDocImage()).resolves.toBe(false);
expect(openFile).not.toHaveBeenCalled();
expect(commandService.executeCommand).not.toHaveBeenCalled();
});
it('does not insert an image when Document Edit is revoked during upload', async () => {
let finishSaving: (value: unknown) => void = () => {};
const saveImage = vi.fn(() => new Promise<unknown>((resolve) => {
finishSaving = resolve;
}));
const { commandService, controller, permissionService } = createController({
openFile: async () => [{} as File],
saveImage,
});
const insertion = controller.insertDocImage();
await vi.waitFor(() => expect(saveImage).toHaveBeenCalledTimes(1));
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
finishSaving({
imageId: 'image-1',
imageSourceType: 'URL',
source: 'image.png',
base64Cache: 'data:image/png;base64,',
});
await expect(insertion).resolves.toBe(false);
expect(commandService.executeCommand).not.toHaveBeenCalled();
});
it('toggles drawing editability between body and header/footer edit areas', () => {
const bodyDrawing = {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
};
const headerDrawing = {
drawingId: 'header-drawing',
isMultiTransform: BooleanNumber.TRUE,
};
const { editAreaChange$, getShape, scene, setEditArea } = createController({
drawings: {
'body-drawing': bodyDrawing,
'header-drawing': headerDrawing,
},
});
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(0.5);
scene.attachTransformerTo.mockClear();
setEditArea(DocumentEditArea.HEADER);
editAreaChange$.next();
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('header-drawing'));
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(0.5);
});
it('removes read-only drawings from picking and transformer interaction, then restores both', async () => {
const { getShape, permissionService, scene, transformer } = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
},
});
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(getShape('body-drawing').evented).toBe(false);
expect(transformer.clearControlByIds).toHaveBeenCalledWith(['doc-1#-#doc-1#-#body-drawing']);
expect(getShape('body-drawing').transformerConfig).toMatchObject({
moveEnabled: false,
resizeEnabled: false,
rotateEnabled: false,
});
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, true);
expect(getShape('body-drawing').evented).toBe(true);
expect(scene.attachTransformerTo).toHaveBeenLastCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').transformerConfig).not.toHaveProperty('moveEnabled', false);
expect(getShape('body-drawing').transformerConfig).not.toHaveProperty('resizeEnabled', false);
expect(getShape('body-drawing').transformerConfig).not.toHaveProperty('rotateEnabled', false);
});
it('keeps a remotely updated drawing non-interactive while local permission is read-only', async () => {
const { getShape, permissionService, scene, update$ } = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
},
});
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
scene.attachTransformerTo.mockClear();
update$.next([{ unitId: 'doc-1' }]);
await Promise.resolve();
expect(getShape('body-drawing').evented).toBe(false);
expect(scene.attachTransformerTo).not.toHaveBeenCalled();
});
it('keeps all drawings opaque while the document input is not focused', () => {
const bodyDrawing = {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
};
const headerDrawing = {
drawingId: 'header-drawing',
isMultiTransform: BooleanNumber.TRUE,
};
const {
getShape,
onBlur$,
onFocus$,
scene,
setIsFocusing,
} = createController({
drawings: {
'body-drawing': bodyDrawing,
'header-drawing': headerDrawing,
},
isFocusing: false,
});
expect(scene.attachTransformerTo).not.toHaveBeenCalled();
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
setIsFocusing(true);
onFocus$.next({});
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(0.5);
setIsFocusing(false);
onBlur$.next({});
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
});
it('indexes scene drawings once and ignores duplicate focus notifications', () => {
const { getShape, onFocus$, scene } = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
'header-drawing': {
drawingId: 'header-drawing',
isMultiTransform: BooleanNumber.TRUE,
},
},
isFocusing: true,
});
expect(scene.getAllObjects).toHaveBeenCalledTimes(1);
expect(scene.fuzzyMathObjects).not.toHaveBeenCalled();
getShape('body-drawing').setOpacity.mockClear();
getShape('header-drawing').setOpacity.mockClear();
onFocus$.next({});
expect(scene.getAllObjects).toHaveBeenCalledTimes(1);
expect(getShape('body-drawing').setOpacity).not.toHaveBeenCalled();
expect(getShape('header-drawing').setOpacity).not.toHaveBeenCalled();
});
it('keeps peer drawings interactive while a drawing owns focus', () => {
const {
focus$,
getShape,
scene,
} = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
'peer-drawing': {
drawingId: 'peer-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
},
isFocusing: false,
});
scene.attachTransformerTo.mockClear();
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('peer-drawing'));
});
it('ignores rich text mutations from other document units', async () => {
const { commandHandlers, getShape, scene } = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
},
});
scene.attachTransformerTo.mockClear();
getShape('body-drawing').setOpacity.mockClear();
commandHandlers.forEach((handler) => handler({
id: RichTextEditingMutation.id,
params: { unitId: 'other-doc' },
}));
await Promise.resolve();
expect(scene.attachTransformerTo).not.toHaveBeenCalled();
expect(getShape('body-drawing').setOpacity).not.toHaveBeenCalled();
commandHandlers.forEach((handler) => handler({
id: RichTextEditingMutation.id,
params: { unitId: 'doc-1' },
}));
await Promise.resolve();
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').setOpacity).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { IDocDrawing, IDrawingDocTransform } from '@univerjs/docs-drawing';
import type { IImageData } from '@univerjs/drawing';
import { BooleanNumber, DrawingTypeEnum, PositionedObjectLayoutType } from '@univerjs/core';
export function getDocImageCropUpdates(
param: IDrawingParam & Partial<IImageData>,
docDrawing: IDocDrawing | undefined,
renderDrawing: IDrawingParam | undefined | null
): IDrawingDocTransform[] {
const { drawingId, transform } = param;
if (docDrawing?.drawingType !== DrawingTypeEnum.DRAWING_IMAGE || renderDrawing?.transform == null) {
return [];
}
const shapeKeys: Array<'prstGeom' | 'adjustValues'> = ['prstGeom', 'adjustValues'];
const drawings: IDrawingDocTransform[] = shapeKeys
.filter((key) => Object.prototype.hasOwnProperty.call(param, key))
.map((key) => ({ drawingId, key, value: param[key] ?? null }));
// Transformer updates without crop data already persist size through the transformer controller.
if (transform == null || param.srcRect === undefined) {
return drawings;
}
const nextTransform = { ...renderDrawing.transform, ...transform };
drawings.push({ drawingId, key: 'srcRect', value: param.srcRect });
if (nextTransform.width != null && nextTransform.height != null) {
drawings.push({
drawingId,
key: 'size',
value: { width: nextTransform.width, height: nextTransform.height },
});
}
if (docDrawing.layoutType === PositionedObjectLayoutType.INLINE || renderDrawing.isMultiTransform === BooleanNumber.TRUE) {
return drawings;
}
const horizontalDelta = (nextTransform.left ?? 0) - (renderDrawing.transform.left ?? 0);
const verticalDelta = (nextTransform.top ?? 0) - (renderDrawing.transform.top ?? 0);
const { positionH, positionV } = docDrawing.docTransform;
if (horizontalDelta !== 0 && positionH.posOffset != null) {
drawings.push({
drawingId,
key: 'positionH',
value: { relativeFrom: positionH.relativeFrom, posOffset: positionH.posOffset + horizontalDelta },
});
}
if (verticalDelta !== 0 && positionV.posOffset != null) {
drawings.push({
drawingId,
key: 'positionV',
value: { relativeFrom: positionV.relativeFrom, posOffset: positionV.posOffset + verticalDelta },
});
}
return drawings;
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { JSONXActions } from '@univerjs/core';
import { JSON1 } from '@univerjs/core';
export type DocMutationAffectedDrawingIds = ReadonlySet<string> | null;
/**
* Returns the drawing ids that can be refreshed independently. `null` means
* the mutation is drawing-related but needs a conservative full refresh.
*/
export function getDocMutationAffectedDrawingIds(actions: JSONXActions): DocMutationAffectedDrawingIds {
if (!Array.isArray(actions) || actions.length === 0) {
return null;
}
const drawingIds = new Set<string>();
let sawComponent = false;
let needsFullRefresh = false;
try {
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
sawComponent = true;
const path = cursor.getPath();
const root = String(path[0]);
const drawingIndex = root === 'notes' ? 2 : 0;
if (path[drawingIndex] === 'drawings') {
const drawingId = path[drawingIndex + 1];
if (typeof drawingId === 'string' && drawingId.length > 0) {
drawingIds.add(drawingId);
} else {
needsFullRefresh = true;
}
} else if (path[drawingIndex] === 'drawingsOrder' || (root === 'notes' && path.length <= 2) || path.includes('customBlocks')) {
needsFullRefresh = true;
}
});
} catch {
return null;
}
return !sawComponent || needsFullRefresh ? null : drawingIds;
}
/**
* Drawing editability and immediate overlay transforms only need refreshing
* when a rich-text mutation changes drawing metadata or custom-block records.
* Text, table, list, and document-style mutations are repainted from the next
* document-layout publication instead of scanning every drawing eagerly.
*/
export function doesDocMutationAffectDrawingPresentation(actions: JSONXActions): boolean {
const drawingIds = getDocMutationAffectedDrawingIds(actions);
return drawingIds == null || drawingIds.size > 0;
}

View File

@@ -0,0 +1,700 @@
/**
* 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, ICommandInfo, IDocDrawingPosition, IDrawingParam, IImageIoServiceParam, ITextRangeParam, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing, IDrawingDocTransform, IInsertDocDrawingCommandParams, ISetDocDrawingArrangeCommandParams, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import type { BaseObject, Documents, Image, IRenderContext, IRenderModule, ITransformerConfig } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import {
BooleanNumber,
Disposable,
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
IImageIoService,
ImageUploadStatusType,
Inject,
IPermissionService,
LocaleService,
PositionedObjectLayoutType,
WrapTextType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import {
buildDocTransform,
canEditDocumentTargets,
docDrawingPositionToTransform,
DocSelectionManagerService,
DocSkeletonManagerService,
getDocumentDrawingSegmentId,
getDocumentEditTargetObjectIds,
getDocumentEntityParentPermissionObjectIds,
getDocumentEntityPermissionObjectId,
RichTextEditingMutation,
} from '@univerjs/docs';
import { collectDocDrawings, findDocDrawing, IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import {
DRAWING_IMAGE_ALLOW_IMAGE_LIST,
DRAWING_IMAGE_COUNT_LIMIT,
DRAWING_IMAGE_HEIGHT_LIMIT,
DRAWING_IMAGE_WIDTH_LIMIT,
getDrawingImageAllowSize,
getDrawingShapeKeyByDrawingSearch,
getImageSize,
IDrawingManagerService,
} from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { ILocalFileService, IMessageService } from '@univerjs/ui';
import { debounceTime } from 'rxjs';
import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command';
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { getDocImageCropUpdates } from './doc-drawing-crop';
import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation';
interface IImageInsertPosition {
left: number;
top: number;
}
export class DocDrawingUpdateRenderController extends Disposable implements IRenderModule {
private readonly _editableTransformerConfigs = new WeakMap<BaseObject, ITransformerConfig>();
private readonly _editableEventedStates = new WeakMap<BaseObject, boolean>();
private _editStatusUpdateScheduled = false;
private _pendingEditStatusDrawingIds: Set<string> | null | undefined;
private _lastEditStatusContextKey: string | null = null;
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IImageIoService private readonly _imageIoService: IImageIoService,
@IDocDrawingService private readonly _docDrawingService: IDocDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IPermissionService private readonly _permissionService: IPermissionService,
@IContextService private readonly _contextService: IContextService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService,
@Inject(DocRefreshDrawingsService) private readonly _docRefreshDrawingsService: DocRefreshDrawingsService,
@ILocalFileService private readonly _fileOpenerService: ILocalFileService
) {
super();
this._updateOrderListener();
this._updateImageCropListener();
this._groupDrawingListener();
this._focusDrawingListener();
this._transformDrawingListener();
this._editAreaChangeListener();
const scheduleEditStatusUpdate = (drawings: Array<{ unitId: string; drawingId: string }>) => {
const relevantDrawings = drawings.filter((drawing) => drawing.unitId === this._context?.unitId);
if (relevantDrawings.length > 0) {
const drawingIds = relevantDrawings
.map((drawing) => drawing.drawingId)
.filter((drawingId): drawingId is string => typeof drawingId === 'string' && drawingId.length > 0);
this._scheduleDrawingsEditStatusUpdate(
drawingIds.length === relevantDrawings.length ? new Set(drawingIds) : undefined
);
}
};
this.disposeWithMe(this._drawingManagerService.add$.subscribe(scheduleEditStatusUpdate));
this.disposeWithMe(this._drawingManagerService.update$.subscribe(scheduleEditStatusUpdate));
this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => {
this._updateDrawingsEditStatus(undefined, true);
}));
}
override dispose(): void {
super.dispose();
// @ts-ignore
delete this._context;
}
async insertDocImage(): Promise<boolean> {
const insertPosition = this._getCurrentImageInsertPosition();
const textRange = this._getCurrentImageInsertTextRange();
if (!this._canInsertDocImage(textRange)) {
return false;
}
const files = await this._fileOpenerService.openFile({
multiple: true,
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
if (this._disposed || !this._canInsertDocImage(textRange)) {
return false;
}
const fileLength = files.length;
if (fileLength > DRAWING_IMAGE_COUNT_LIMIT) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('docs-drawing-ui.update-status.exceedMaxCount', String(DRAWING_IMAGE_COUNT_LIMIT)),
});
return false;
} else if (fileLength === 0) {
return false;
}
return await this._insertFloatImages(files, insertPosition, textRange);
}
// eslint-disable-next-line max-lines-per-function
private async _insertFloatImages(
files: File[],
insertPosition: Nullable<IImageInsertPosition>,
textRange: Nullable<ITextRangeParam>
) {
let imageParams: Nullable<IImageIoServiceParam>[] = [];
try {
imageParams = await Promise.all(files.map((file) => this._imageIoService.saveImage(file)));
} catch (error) {
const type = (error as Error).message;
let content = '';
switch (type) {
case ImageUploadStatusType.ERROR_EXCEED_SIZE:
content = this._localeService.t<LocaleKey>('docs-drawing-ui.update-status.exceedMaxSize', String(getDrawingImageAllowSize() / (1024 * 1024)));
break;
case ImageUploadStatusType.ERROR_IMAGE_TYPE:
content = this._localeService.t<LocaleKey>('docs-drawing-ui.update-status.invalidImageType');
break;
case ImageUploadStatusType.ERROR_IMAGE:
content = this._localeService.t<LocaleKey>('docs-drawing-ui.update-status.invalidImage');
break;
default:
break;
}
this._messageService.show({
type: MessageType.Error,
content,
});
}
if (this._disposed || imageParams.length === 0 || !this._canInsertDocImage(textRange)) {
return false;
}
const { unitId } = this._context;
const docDrawingParams: IDocDrawing[] = [];
for (const imageParam of imageParams) {
if (imageParam == null) {
continue;
}
const { imageId, imageSourceType, source, base64Cache } = imageParam;
const { width, height, image } = await getImageSize(base64Cache || '');
if (this._disposed || !this._canInsertDocImage(textRange)) {
return false;
}
this._imageIoService.addImageSourceCache(imageId, imageSourceType, image);
let scale = 1;
if (width > DRAWING_IMAGE_WIDTH_LIMIT || height > DRAWING_IMAGE_HEIGHT_LIMIT) {
const scaleWidth = DRAWING_IMAGE_WIDTH_LIMIT / width;
const scaleHeight = DRAWING_IMAGE_HEIGHT_LIMIT / height;
scale = Math.min(scaleWidth, scaleHeight);
}
const imagePosition = insertPosition ?? this._getCurrentImageInsertPosition();
const docTransform = this._getImagePosition(width * scale, height * scale, imagePosition);
if (docTransform == null) {
return false;
}
const transform = docDrawingPositionToTransform(docTransform);
if (transform != null && imagePosition != null) {
transform.top = imagePosition.top;
}
const docDrawingParam: IDocDrawing = {
unitId,
subUnitId: unitId,
drawingId: imageId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType,
source,
transform,
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE, // Insert inline drawing by default.
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
};
const isInHeaderFooter = this._isInsertInHeaderFooter();
if (isInHeaderFooter) {
docDrawingParam.isMultiTransform = BooleanNumber.TRUE;
docDrawingParam.transforms = docDrawingParam.transform ? [docDrawingParam.transform] : null;
}
docDrawingParams.push(docDrawingParam);
}
return await this._commandService.executeCommand<IInsertDocDrawingCommandParams>(InsertDocDrawingCommand.id, {
unitId,
drawings: docDrawingParams,
textRange: textRange ?? undefined,
});
}
private _isInsertInHeaderFooter() {
const { unitId } = this._context;
const viewModel = this._renderManagerSrv.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getViewModel();
const editArea = viewModel?.getEditArea();
return editArea === DocumentEditArea.HEADER || editArea === DocumentEditArea.FOOTER;
}
private _getImagePosition(
imageWidth: number,
imageHeight: number,
insertPosition?: Nullable<IImageInsertPosition>
): Nullable<IDocDrawingPosition> {
// TODO: NO need to get the cursor position, because the insert image is inline.
const position = insertPosition ?? this._getCurrentImageInsertPosition() ?? {
left: 0,
top: 0,
};
return buildDocTransform(imageWidth, imageHeight, {
left: position.left,
});
}
private _getCurrentImageInsertPosition(): Nullable<IImageInsertPosition> {
const position = this._docSelectionRenderService.getActiveTextRange()?.getAbsolutePosition();
if (position == null) {
return null;
}
return {
left: position.left,
top: position.top,
};
}
private _getCurrentImageInsertTextRange(): Nullable<ITextRangeParam> {
return this._docSelectionRenderService.getAllTextRanges().find((range) => range.isActive)
?? this._docSelectionManagerService.getActiveTextRange();
}
private _updateOrderListener() {
this.disposeWithMe(
this._drawingManagerService.featurePluginOrderUpdate$.subscribe((params) => {
const { unitId, subUnitId, drawingIds, arrangeType } = params;
this._commandService.executeCommand<ISetDocDrawingArrangeCommandParams>(SetDocDrawingArrangeCommand.id, {
unitId,
subUnitId,
drawingIds,
arrangeType,
});
})
);
}
private _updateImageCropListener() {
this.disposeWithMe(
this._drawingManagerService.featurePluginUpdate$.subscribe((params) => {
const drawings = params.flatMap((param) => this._getImageCropUpdates(param));
if (drawings.length > 0) {
const { unitId } = this._context;
this._commandService.executeCommand<IUpdateDrawingDocTransformCommandParams>(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings,
});
}
})
);
}
private _getImageCropUpdates(param: IDrawingParam): IDrawingDocTransform[] {
const { unitId, subUnitId, drawingId } = param;
if ([unitId, subUnitId].some((id) => id !== this._context.unitId)) {
return [];
}
return getDocImageCropUpdates(
param,
this._getDocDrawing(drawingId),
this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId }) ?? undefined
);
}
private _getDocDrawing(drawingId: string): IDocDrawing | undefined {
return findDocDrawing(this._context.unit.getSnapshot(), drawingId)?.drawing as IDocDrawing | undefined;
}
private _groupDrawingListener() {
this.disposeWithMe(
this._drawingManagerService.featurePluginGroupUpdate$.subscribe((params) => {
this._commandService.executeCommand(GroupDocDrawingCommand.id, params);
})
);
this.disposeWithMe(
this._drawingManagerService.featurePluginUngroupUpdate$.subscribe((params) => {
this._commandService.executeCommand(UngroupDocDrawingCommand.id, params);
})
);
}
private _getCurrentSceneAndTransformer() {
const { scene, mainComponent } = this._context;
if (scene == null || mainComponent == null) {
return;
}
const transformer = scene.getTransformerByCreate();
const { docsLeft, docsTop } = (mainComponent as Documents).getOffsetConfig();
return { scene, transformer, docsLeft, docsTop };
}
protected _getTransformerInteractionOptions(): ITransformerConfig {
return { moveOnlyWhenSelected: false };
}
protected _isDocumentInteractionFocusing(unitId: string): boolean {
return this._docSelectionRenderService.isFocusing
|| this._drawingManagerService.getFocusDrawings().some((drawing) => drawing.unitId === unitId);
}
private _transformDrawingListener() {
const res = this._getCurrentSceneAndTransformer();
if (res && res.transformer) {
res.transformer.resetProps(this._getTransformerInteractionOptions());
this.disposeWithMe(res.transformer.changeEnd$.pipe(debounceTime(30)).subscribe(() => {
this._docSelectionManagerService.refreshSelection();
}));
} else {
throw new Error('transformer is not init');
}
}
private _focusDrawingListener() {
this.disposeWithMe(
this._drawingManagerService.focus$.subscribe((params) => {
const { transformer, docsLeft, docsTop } = this._getCurrentSceneAndTransformer() ?? {};
if (params == null || params.length === 0) {
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._docDrawingService.focusDrawing([]);
if (transformer) {
transformer.resetProps({
zeroTop: 0,
zeroLeft: 0,
});
}
} else {
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true);
this._docDrawingService.focusDrawing(params);
this._setDrawingSelections(params);
const prevSegmentId = this._docSelectionRenderService.getSegment();
const segmentId = getDocumentDrawingSegmentId(this._context.unit, params[0].drawingId);
// Change segmentId when click drawing in different segment.
if (prevSegmentId !== segmentId) {
this._docSelectionRenderService.setSegment(segmentId);
}
if (transformer) {
transformer.resetProps({
zeroTop: docsTop,
zeroLeft: docsLeft,
});
}
}
this._updateDrawingsEditStatus();
})
);
}
// Update drawings edit status and opacity. You can not edit header footer images when you are editing body. and vice verse.
private _updateDrawingsEditStatus(drawingIds?: ReadonlySet<string>, force = false) {
if (!this._context) return;
const { unit: docDataModel, scene, unitId } = this._context;
const viewModel = this._renderManagerSrv
.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getViewModel();
if (viewModel == null || docDataModel == null) {
return;
}
const snapshot = docDataModel.getSnapshot();
const { drawings } = collectDocDrawings(snapshot);
const isEditBody = viewModel.getEditArea() === DocumentEditArea.BODY;
const isDocInteractionFocusing = this._isDocumentInteractionFocusing(unitId);
const contextKey = `${viewModel.getEditArea()}:${isDocInteractionFocusing}`;
const effectiveDrawingIds = drawingIds != null && contextKey === this._lastEditStatusContextKey
? drawingIds
: undefined;
if (effectiveDrawingIds == null) {
if (!force && contextKey === this._lastEditStatusContextKey) {
return;
}
this._lastEditStatusContextKey = contextKey;
}
const readOnlyDrawingIds = new Set<string>();
const drawingShapesById = this._indexDrawingShapes(scene.getAllObjects(), unitId);
for (const key of Object.keys(drawings)) {
const drawing = drawings[key];
if (effectiveDrawingIds != null && !effectiveDrawingIds.has(drawing.drawingId)) {
continue;
}
const segmentId = getDocumentDrawingSegmentId(docDataModel, drawing.drawingId);
const editable = canEditDocumentTargets(this._permissionService, unitId, [
...getDocumentEntityParentPermissionObjectIds(docDataModel, segmentId, 'drawing', drawing.drawingId),
getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawing.drawingId),
]);
this._recordReadOnlyDrawing(readOnlyDrawingIds, drawing.drawingId, editable);
const drawingShapes = drawingShapesById.get(drawing.drawingId) ?? [];
if (drawingShapes.length) {
for (const shape of drawingShapes) {
scene.detachTransformerFrom(shape);
this._setTransformerEditable(shape, editable);
try {
(shape as Image).setOpacity(isDocInteractionFocusing ? 0.5 : 1);
} catch {
}
if (!isDocInteractionFocusing) {
continue;
}
if (
(isEditBody && drawing.isMultiTransform !== BooleanNumber.TRUE)
|| (!isEditBody && drawing.isMultiTransform === BooleanNumber.TRUE)
) {
this._attachTransformerIfEditable(shape, editable, drawing.allowTransform);
try {
(shape as Image).setOpacity(1);
} catch {
}
}
}
}
}
this._clearReadOnlyDrawingFocus(unitId, readOnlyDrawingIds);
}
private _indexDrawingShapes(shapes: BaseObject[], unitId: string): Map<string, BaseObject[]> {
const prefix = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId: unitId, drawingId: '' });
const result = new Map<string, BaseObject[]>();
for (const shape of shapes) {
if (!shape.oKey.startsWith(prefix)) {
continue;
}
const drawingId = shape.oKey.slice(prefix.length).split('#-#', 1)[0];
if (!drawingId) {
continue;
}
const drawingShapes = result.get(drawingId) ?? [];
drawingShapes.push(shape);
result.set(drawingId, drawingShapes);
}
return result;
}
private _recordReadOnlyDrawing(readOnlyDrawingIds: Set<string>, drawingId: string, editable: boolean): void {
if (!editable) {
readOnlyDrawingIds.add(drawingId);
}
}
private _attachTransformerIfEditable(shape: BaseObject, editable: boolean, allowTransform?: boolean): void {
if (editable && allowTransform !== false) {
this._context.scene.attachTransformerTo(shape);
}
}
private _clearReadOnlyDrawingFocus(unitId: string, readOnlyDrawingIds: Set<string>): void {
if (readOnlyDrawingIds.size === 0) {
return;
}
const focusedDrawings = this._drawingManagerService.getFocusDrawings();
const editableFocus = focusedDrawings.filter((drawing) =>
drawing.unitId !== unitId || !readOnlyDrawingIds.has(drawing.drawingId));
if (editableFocus.length !== focusedDrawings.length) {
this._drawingManagerService.focusDrawing(editableFocus);
}
}
private _setTransformerEditable(shape: BaseObject, editable: boolean): void {
if (editable) {
const config = this._editableTransformerConfigs.get(shape);
if (config) {
shape.transformerConfig = config;
this._editableTransformerConfigs.delete(shape);
}
const evented = this._editableEventedStates.get(shape);
if (evented !== undefined) {
shape.evented = evented;
this._editableEventedStates.delete(shape);
}
return;
}
if (!this._editableTransformerConfigs.has(shape)) {
this._editableTransformerConfigs.set(shape, shape.transformerConfig);
}
if (!this._editableEventedStates.has(shape)) {
this._editableEventedStates.set(shape, shape.evented);
}
shape.evented = false;
this._context.scene.getTransformer()?.clearControlByIds([shape.oKey]);
shape.transformerConfig = {
...shape.transformerConfig,
moveEnabled: false,
resizeEnabled: false,
rotateEnabled: false,
};
}
private _scheduleDrawingsEditStatusUpdate(drawingIds?: ReadonlySet<string>): void {
if (drawingIds == null) {
this._pendingEditStatusDrawingIds = null;
this._lastEditStatusContextKey = null;
} else if (this._pendingEditStatusDrawingIds !== null) {
this._pendingEditStatusDrawingIds ??= new Set<string>();
drawingIds.forEach((drawingId) => this._pendingEditStatusDrawingIds?.add(drawingId));
}
if (this._editStatusUpdateScheduled) {
return;
}
this._editStatusUpdateScheduled = true;
queueMicrotask(() => {
this._editStatusUpdateScheduled = false;
if (!this._disposed) {
const pendingDrawingIds = this._pendingEditStatusDrawingIds;
this._pendingEditStatusDrawingIds = undefined;
this._updateDrawingsEditStatus(pendingDrawingIds ?? undefined);
}
});
}
private _canInsertDocImage(textRange: Nullable<ITextRangeParam>): boolean {
const { unit, unitId } = this._context;
const objectIds = textRange?.startOffset == null || textRange.endOffset == null
? []
: getDocumentEditTargetObjectIds(unit, textRange.segmentId ?? '', textRange);
return canEditDocumentTargets(this._permissionService, unitId, objectIds);
}
private _editAreaChangeListener() {
const { unitId } = this._context;
const viewModel = this._renderManagerSrv
.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getViewModel();
if (viewModel == null) {
return;
}
this._updateDrawingsEditStatus();
this.disposeWithMe(
viewModel.editAreaChange$.subscribe(() => {
this._updateDrawingsEditStatus();
})
);
this.disposeWithMe(
this._docSelectionRenderService.onFocus$.subscribe(() => {
this._updateDrawingsEditStatus();
})
);
this.disposeWithMe(
this._docSelectionRenderService.onBlur$.subscribe(() => {
this._updateDrawingsEditStatus();
})
);
this.disposeWithMe(
this._docRefreshDrawingsService.refreshDrawings$.subscribe((skeleton) => {
if (skeleton == null) {
return;
}
// To wait the image is rendered.
queueMicrotask(() => {
this._updateDrawingsEditStatus(undefined, true);
});
})
);
this.disposeWithMe(
this._commandService.onCommandExecuted(async (command: ICommandInfo) => {
if (command.id !== RichTextEditingMutation.id) {
return;
}
const params = command.params as Partial<IRichTextEditingMutationParams> | undefined;
if (params?.unitId && params.unitId !== this._context.unitId) {
return;
}
const drawingIds = getDocMutationAffectedDrawingIds(params?.actions ?? null);
if (drawingIds?.size === 0) {
return;
}
// To wait the image is rendered.
this._scheduleDrawingsEditStatusUpdate(drawingIds ?? undefined);
})
);
}
private _setDrawingSelections(params: IDrawingParam[]) {
const { unit } = this._context;
const ranges = params.flatMap((item) => {
const segmentId = getDocumentDrawingSegmentId(unit, item.drawingId);
const body = unit.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const block = body?.customBlocks?.find((block) => block.blockId === item.drawingId);
return block ? [{ startOffset: block.startIndex, endOffset: block.startIndex + 1, segmentId }] : [];
});
this._docSelectionManagerService.replaceDocRanges(ranges);
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { DocDrawingUpdateRenderController } from '../doc-drawing-update.render-controller';
export class MobileDocDrawingUpdateRenderController extends DocDrawingUpdateRenderController {
protected override _getTransformerInteractionOptions() {
return { moveOnlyWhenSelected: true };
}
protected override _isDocumentInteractionFocusing(_unitId: string): boolean {
return true;
}
}

View File

@@ -0,0 +1,94 @@
/**
* 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 { Direction, FOCUSING_COMMON_DRAWINGS, FOCUSING_DOC, FOCUSING_UNIVER_EDITOR } from '@univerjs/core';
import { KeyCode } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { DeleteDocDrawingsCommand } from '../../../commands/commands/delete-doc-drawing.command';
import { MoveDocDrawingsCommand } from '../../../commands/commands/move-drawings.command';
import {
DeleteDrawingsShortcutItem,
MoveDrawingDownShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
MoveDrawingUpShortcutItem,
whenDocDrawingFocused,
} from '../drawing.shortcut';
function createContext(values: Record<string, boolean>) {
return {
getContextValue: vi.fn((key: string) => values[key]),
};
}
describe('doc drawing shortcuts', () => {
it('enables drawing shortcuts only when doc editor and common drawing focus are both active', () => {
expect(whenDocDrawingFocused(createContext({
[FOCUSING_DOC]: true,
[FOCUSING_UNIVER_EDITOR]: true,
[FOCUSING_COMMON_DRAWINGS]: true,
}) as never)).toBe(true);
expect(whenDocDrawingFocused(createContext({
[FOCUSING_DOC]: true,
[FOCUSING_UNIVER_EDITOR]: true,
[FOCUSING_COMMON_DRAWINGS]: false,
}) as never)).toBe(false);
});
it('binds arrow keys to one-pixel drawing move commands', () => {
expect([
MoveDrawingDownShortcutItem,
MoveDrawingUpShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
]).toEqual([
expect.objectContaining({
id: MoveDocDrawingsCommand.id,
binding: KeyCode.ARROW_DOWN,
staticParameters: { direction: Direction.DOWN },
priority: 100,
}),
expect.objectContaining({
id: MoveDocDrawingsCommand.id,
binding: KeyCode.ARROW_UP,
staticParameters: { direction: Direction.UP },
priority: 100,
}),
expect.objectContaining({
id: MoveDocDrawingsCommand.id,
binding: KeyCode.ARROW_LEFT,
staticParameters: { direction: Direction.LEFT },
priority: 100,
}),
expect.objectContaining({
id: MoveDocDrawingsCommand.id,
binding: KeyCode.ARROW_RIGHT,
staticParameters: { direction: Direction.RIGHT },
priority: 100,
}),
]);
});
it('binds delete and backspace to drawing deletion with the same focus guard', () => {
expect(DeleteDrawingsShortcutItem).toEqual(expect.objectContaining({
id: DeleteDocDrawingsCommand.id,
binding: KeyCode.DELETE,
mac: KeyCode.BACKSPACE,
preconditions: whenDocDrawingFocused,
}));
});
});

View File

@@ -0,0 +1,93 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IContextService } from '@univerjs/core';
import type { IShortcutItem } from '@univerjs/ui';
import type { IMoveDrawingsCommandParams } from '../../commands/commands/move-drawings.command';
import { Direction, FOCUSING_COMMON_DRAWINGS, FOCUSING_DOC, FOCUSING_UNIVER_EDITOR } from '@univerjs/core';
import { KeyCode } from '@univerjs/ui';
import { DeleteDocDrawingsCommand } from '../../commands/commands/delete-doc-drawing.command';
import { MoveDocDrawingsCommand } from '../../commands/commands/move-drawings.command';
export function whenDocDrawingFocused(contextService: IContextService): boolean {
return (
contextService.getContextValue(FOCUSING_DOC) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)
);
}
export const MoveDrawingDownShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDocDrawingsCommand.id,
description: 'docs-drawing-ui.shortcut.drawing-move-down',
group: '4_drawing-view',
groupTitle: 'docs-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_DOWN,
priority: 100,
preconditions: whenDocDrawingFocused,
staticParameters: {
direction: Direction.DOWN,
},
};
export const MoveDrawingUpShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDocDrawingsCommand.id,
description: 'docs-drawing-ui.shortcut.drawing-move-up',
group: '4_drawing-view',
groupTitle: 'docs-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_UP,
priority: 100,
preconditions: whenDocDrawingFocused,
staticParameters: {
direction: Direction.UP,
},
};
export const MoveDrawingLeftShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDocDrawingsCommand.id,
description: 'docs-drawing-ui.shortcut.drawing-move-left',
group: '4_drawing-view',
groupTitle: 'docs-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_LEFT,
priority: 100,
preconditions: whenDocDrawingFocused,
staticParameters: {
direction: Direction.LEFT,
},
};
export const MoveDrawingRightShortcutItem: IShortcutItem<IMoveDrawingsCommandParams> = {
id: MoveDocDrawingsCommand.id,
description: 'docs-drawing-ui.shortcut.drawing-move-right',
group: '4_drawing-view',
groupTitle: 'docs-drawing-ui.shortcut.drawing-view',
binding: KeyCode.ARROW_RIGHT,
priority: 100,
preconditions: whenDocDrawingFocused,
staticParameters: {
direction: Direction.RIGHT,
},
};
export const DeleteDrawingsShortcutItem: IShortcutItem = {
id: DeleteDocDrawingsCommand.id,
description: 'docs-drawing-ui.shortcut.drawing-delete',
group: '4_drawing-view',
groupTitle: 'docs-drawing-ui.shortcut.drawing-view',
// when focusing on any other input tag do not trigger this shortcut
preconditions: whenDocDrawingFocused,
binding: KeyCode.DELETE,
mac: KeyCode.BACKSPACE,
};

View File

@@ -0,0 +1,99 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { IMenuManagerService, IShortcutService } from '@univerjs/ui';
import { DeleteDocDrawingsCommand } from '../commands/commands/delete-doc-drawing.command';
import { GroupDocDrawingCommand } from '../commands/commands/group-doc-drawing.command';
import { InsertDocImageCommand } from '../commands/commands/insert-image.command';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
import { MoveDocDrawingsCommand } from '../commands/commands/move-drawings.command';
import { UngroupDocDrawingCommand } from '../commands/commands/ungroup-doc-drawing.command';
import {
IMoveInlineDrawingCommand,
ITransformNonInlineDrawingCommand,
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
} from '../commands/commands/update-doc-drawing.command';
import { ClearDocDrawingTransformerOperation } from '../commands/operations/clear-drawing-transformer.operation';
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
import { menuSchema } from '../menu/schema';
import {
DeleteDrawingsShortcutItem,
MoveDrawingDownShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
MoveDrawingUpShortcutItem,
} from './shortcuts/drawing.shortcut';
export class DocDrawingUIController extends Disposable {
constructor(
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@ICommandService private readonly _commandService: ICommandService,
@IShortcutService private readonly _shortcutService: IShortcutService
) {
super();
this._init();
}
private _initCustomComponents(): void {
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands() {
[
InsertDocImageCommand,
InsertDocRectangleShapeCommand,
InsertDocEllipseShapeCommand,
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
IMoveInlineDrawingCommand,
ITransformNonInlineDrawingCommand,
SidebarDocDrawingOperation,
ClearDocDrawingTransformerOperation,
EditDocDrawingOperation,
GroupDocDrawingCommand,
UngroupDocDrawingCommand,
MoveDocDrawingsCommand,
DeleteDocDrawingsCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initShortcuts(): void {
[
// sheet drawing shortcuts
MoveDrawingDownShortcutItem,
MoveDrawingUpShortcutItem,
MoveDrawingLeftShortcutItem,
MoveDrawingRightShortcutItem,
DeleteDrawingsShortcutItem,
].forEach((item) => {
this.disposeWithMe(this._shortcutService.registerShortcut(item));
});
}
private _init(): void {
this._initCommands();
this._initCustomComponents();
this._initMenus();
this._initShortcuts();
}
}

View File

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

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export { DeleteDocDrawingsCommand } from './commands/commands/delete-doc-drawing.command';
export { GroupDocDrawingCommand } from './commands/commands/group-doc-drawing.command';
export { InsertDocImageCommand } from './commands/commands/insert-image.command';
export { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from './commands/commands/insert-shape.command';
export { MoveDocDrawingsCommand } from './commands/commands/move-drawings.command';
export { UngroupDocDrawingCommand } from './commands/commands/ungroup-doc-drawing.command';
export { ClearDocDrawingTransformerOperation } from './commands/operations/clear-drawing-transformer.operation';
export { EditDocDrawingOperation } from './commands/operations/edit-doc-drawing.operation';
export { SidebarDocDrawingOperation } from './commands/operations/open-drawing-panel.operation';
export type { IUniverDocsDrawingUIConfig } from './config/config';
export { DocFloatDomController } from './controllers/doc-float-dom.controller';
export { DOCS_IMAGE_MENU_ID } from './menu/image.menu';
export { menuSchema as DocsDrawingUIMenuSchema } from './menu/schema';
export { DOCS_SHAPE_BELOW_MENU_ID, DOCS_SHAPE_MENU_ID } from './menu/shape.menu';
export { UniverDocsDrawingMobileUIPlugin } from './mobile-plugin';
export { UniverDocsDrawingUIPlugin } from './plugin';
export { DocDrawingFloatingToolbarAdapterService } from './services/doc-drawing-floating-toolbar-adapter.service';
export type {
IDocDrawingFloatingToolbarAdapter,
IDocDrawingFloatingToolbarButtonItem,
IDocDrawingFloatingToolbarItem,
IDocDrawingFloatingToolbarOption,
IDocDrawingFloatingToolbarParams,
IDocDrawingFloatingToolbarSelectItem,
} from './services/doc-drawing-floating-toolbar-adapter.service';
export { DocDrawingPosition } from './views/doc-image-panel/DocDrawingPosition';
export { DocDrawingTextWrap } from './views/doc-image-panel/DocDrawingTextWrap';
export { MobileDocDrawingPosition } from './views/doc-image-panel/MobileDocDrawingPosition';
export { MobileDocDrawingTextWrap } from './views/doc-image-panel/MobileDocDrawingTextWrap';

View File

@@ -0,0 +1,91 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'صورة',
upload: {
float: 'إدراج صورة',
},
shape: {
insert: {
title: 'إدراج شكل',
rectangle: 'إدراج مستطيل',
ellipse: 'إدراج قطع ناقص',
},
},
panel: {
title: 'تحرير الصورة',
},
'image-popup': {
delete: 'حذف',
edit: 'تحرير',
crop: 'قص',
reset: 'إعادة تعيين الحجم',
},
'image-text-wrap': {
title: 'التفاف النص',
wrappingStyle: 'نمط التفاف',
square: 'مربع',
topAndBottom: 'أعلى وأسفل',
inline: 'في نفس السطر مع النص',
behindText: 'خلف النص',
inFrontText: 'أمام النص',
wrapText: 'تفاف النص',
bothSide: 'كلا الجانبين',
leftOnly: 'يسار فقط',
rightOnly: 'يمين فقط',
distanceFromText: 'المسافة من النص',
top: 'أعلى(بكسل)',
left: 'يسار(بكسل)',
bottom: 'أسفل(بكسل)',
right: 'يمين(بكسل)',
},
'image-position': {
title: 'موضع',
horizontal: 'أفقي',
vertical: 'عمودي',
absolutePosition: 'موضع مطلق(بكسل)',
toTheRightOf: 'إلى يمين',
bellow: 'أسفل',
options: 'خيارات',
moveObjectWithText: 'نقل الكائن مع النص',
column: 'عمود',
margin: 'هامش',
page: 'صفحة',
line: 'سطر',
paragraph: 'فقرة',
},
'update-status': {
exceedMaxSize: 'حجم الصورة يتجاوز الحد، الحد هو {0}م',
invalidImageType: 'نوع الصورة غير صالح',
exceedMaxCount: 'يمكن رفع {0} صور فقط في المرة الواحدة',
invalidImage: 'صورة غير صالحة',
},
shortcut: {
'drawing-view': 'عرض الرسم',
'drawing-move-down': 'نقل الرسم لأسفل',
'drawing-move-up': 'نقل الرسم لأعلى',
'drawing-move-left': 'نقل الرسم لليسار',
'drawing-move-right': 'نقل الرسم لليمين',
'drawing-delete': 'حذف الرسم',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Imatge',
upload: {
float: 'Insereix imatge',
},
shape: {
insert: {
title: 'Insereix forma',
rectangle: 'Insereix rectangle',
ellipse: 'Insereix el·lipse',
},
},
panel: {
title: 'Edita la imatge',
},
'image-popup': {
delete: 'Elimina',
edit: 'Edita',
crop: 'Retalla',
reset: 'Restableix la mida',
},
'image-text-wrap': {
title: 'Ajust del text',
wrappingStyle: 'Estil d\'ajust',
square: 'Quadrat',
topAndBottom: 'A dalt i a baix',
inline: 'En línia amb el text',
behindText: 'Darrere del text',
inFrontText: 'Davant del text',
wrapText: 'Ajusta el text',
bothSide: 'Ambdós costats',
leftOnly: 'Només esquerra',
rightOnly: 'Només dreta',
distanceFromText: 'Distància del text',
top: 'A dalt(px)',
left: 'Esquerra(px)',
bottom: 'A baix(px)',
right: 'Dreta(px)',
},
'image-position': {
title: 'Posició',
horizontal: 'Horitzontal',
vertical: 'Vertical',
absolutePosition: 'Posició absoluta(px)',
toTheRightOf: 'a la dreta de',
bellow: 'a sota',
options: 'Opcions',
moveObjectWithText: 'Mou l\'objecte amb el text',
column: 'Columna',
margin: 'Marge',
page: 'Pàgina',
line: 'Línia',
paragraph: 'Paràgraf',
},
'update-status': {
exceedMaxSize: 'La mida de la imatge supera el límit, el límit és {0}M',
invalidImageType: 'Tipus d\'imatge no vàlid',
exceedMaxCount: 'Només es poden pujar {0} imatges alhora',
invalidImage: 'Imatge no vàlida',
},
shortcut: {
'drawing-view': 'Vista del dibuix',
'drawing-move-down': 'Mou el dibuix cap avall',
'drawing-move-up': 'Mou el dibuix cap amunt',
'drawing-move-left': 'Mou el dibuix a l’esquerra',
'drawing-move-right': 'Mou el dibuix a la dreta',
'drawing-delete': 'Elimina el dibuix',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Bild',
upload: {
float: 'Bild einfügen',
},
shape: {
insert: {
title: 'Form einfügen',
rectangle: 'Rechteck einfügen',
ellipse: 'Ellipse einfügen',
},
},
panel: {
title: 'Bild bearbeiten',
},
'image-popup': {
delete: 'Löschen',
edit: 'Bearbeiten',
crop: 'Zuschneiden',
reset: 'Größe zurücksetzen',
},
'image-text-wrap': {
title: 'Textumbruch',
wrappingStyle: 'Umbruchstil',
square: 'Quadratisch',
topAndBottom: 'Oben und unten',
inline: 'Im Textfluss',
behindText: 'Hinter dem Text',
inFrontText: 'Vor dem Text',
wrapText: 'Text umbrechen',
bothSide: 'Beide Seiten',
leftOnly: 'Nur links',
rightOnly: 'Nur rechts',
distanceFromText: 'Abstand vom Text',
top: 'Oben(px)',
left: 'Links(px)',
bottom: 'Unten(px)',
right: 'Rechts(px)',
},
'image-position': {
title: 'Position',
horizontal: 'Horizontal',
vertical: 'Vertikal',
absolutePosition: 'Absolute Position(px)',
toTheRightOf: 'rechts von',
bellow: 'unterhalb',
options: 'Optionen',
moveObjectWithText: 'Objekt mit Text verschieben',
column: 'Spalte',
margin: 'Rand',
page: 'Seite',
line: 'Zeile',
paragraph: 'Absatz',
},
'update-status': {
exceedMaxSize: 'Bildgröße überschreitet das Limit, das Limit ist {0}M',
invalidImageType: 'Ungültiger Bildtyp',
exceedMaxCount: 'Es können nur {0} Bilder gleichzeitig hochgeladen werden',
invalidImage: 'Ungültiges Bild',
},
shortcut: {
'drawing-view': 'Zeichnungsansicht',
'drawing-move-down': 'Zeichnung nach unten verschieben',
'drawing-move-up': 'Zeichnung nach oben verschieben',
'drawing-move-left': 'Zeichnung nach links verschieben',
'drawing-move-right': 'Zeichnung nach rechts verschieben',
'drawing-delete': 'Zeichnung löschen',
},
},
};
export default locale;

View File

@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const locale = {
'docs-drawing-ui': {
title: 'Image',
upload: {
float: 'Insert Image',
},
shape: {
insert: {
title: 'Insert Shape',
rectangle: 'Insert Rectangle',
ellipse: 'Insert Ellipse',
},
},
panel: {
title: 'Edit Image',
},
'image-popup': {
delete: 'Delete',
edit: 'Edit',
crop: 'Crop',
reset: 'Reset Size',
},
'image-text-wrap': {
title: 'Text Wrapping',
wrappingStyle: 'Wrapping Style',
square: 'Square',
topAndBottom: 'Top and Bottom',
inline: 'In line with text',
behindText: 'Behind text',
inFrontText: 'In front of text',
wrapText: 'Wrap text',
bothSide: 'Both sides',
leftOnly: 'Left only',
rightOnly: 'Right only',
distanceFromText: 'Distance from text',
top: 'Top(px)',
left: 'Left(px)',
bottom: 'Bottom(px)',
right: 'Right(px)',
},
'image-position': {
title: 'Position',
horizontal: 'Horizontal',
vertical: 'Vertical',
absolutePosition: 'Absolute Position(px)',
toTheRightOf: 'to the right of',
bellow: 'bellow',
options: 'Options',
moveObjectWithText: 'Move object with text',
column: 'Column',
margin: 'Margin',
page: 'Page',
line: 'Line',
paragraph: 'Paragraph',
},
'update-status': {
exceedMaxSize: 'Image size exceeds limit, limit is {0}M',
invalidImageType: 'Invalid image type',
exceedMaxCount: 'Only {0} images can be uploaded at a time',
invalidImage: 'Invalid image',
},
shortcut: {
'drawing-view': 'Drawing View',
'drawing-move-down': 'Move Drawing down',
'drawing-move-up': 'Move Drawing up',
'drawing-move-left': 'Move Drawing left',
'drawing-move-right': 'Move Drawing right',
'drawing-delete': 'Delete Drawing',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Imagen',
upload: {
float: 'Insertar imagen',
},
shape: {
insert: {
title: 'Insertar forma',
rectangle: 'Insertar rectángulo',
ellipse: 'Insertar elipse',
},
},
panel: {
title: 'Editar imagen',
},
'image-popup': {
delete: 'Eliminar',
edit: 'Editar',
crop: 'Recortar',
reset: 'Restablecer tamaño',
},
'image-text-wrap': {
title: 'Ajuste de texto',
wrappingStyle: 'Estilo de ajuste',
square: 'Cuadrado',
topAndBottom: 'Arriba y abajo',
inline: 'En línea con el texto',
behindText: 'Detrás del texto',
inFrontText: 'Delante del texto',
wrapText: 'Ajustar texto',
bothSide: 'Ambos lados',
leftOnly: 'Solo izquierda',
rightOnly: 'Solo derecha',
distanceFromText: 'Distancia del texto',
top: 'Arriba(px)',
left: 'Izquierda(px)',
bottom: 'Abajo(px)',
right: 'Derecha(px)',
},
'image-position': {
title: 'Posición',
horizontal: 'Horizontal',
vertical: 'Vertical',
absolutePosition: 'Posición absoluta(px)',
toTheRightOf: 'a la derecha de',
bellow: 'debajo',
options: 'Opciones',
moveObjectWithText: 'Mover objeto con el texto',
column: 'Columna',
margin: 'Margen',
page: 'Página',
line: 'Línea',
paragraph: 'Párrafo',
},
'update-status': {
exceedMaxSize: 'El tamaño de la imagen excede el límite, el límite es {0}M',
invalidImageType: 'Tipo de imagen no válido',
exceedMaxCount: 'Solo se pueden subir {0} imágenes a la vez',
invalidImage: 'Imagen no válida',
},
shortcut: {
'drawing-view': 'Vista de dibujo',
'drawing-move-down': 'Mover dibujo hacia abajo',
'drawing-move-up': 'Mover dibujo hacia arriba',
'drawing-move-left': 'Mover dibujo a la izquierda',
'drawing-move-right': 'Mover dibujo a la derecha',
'drawing-delete': 'Eliminar dibujo',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'تصویر',
upload: {
float: 'درج تصویر',
},
shape: {
insert: {
title: 'درج شکل',
rectangle: 'درج مستطیل',
ellipse: 'بیضی را درج کنید',
},
},
panel: {
title: 'ویرایش تصویر',
},
'image-popup': {
delete: 'حذف',
edit: 'ویرایش',
crop: 'کراپ',
reset: 'بازنشانی اندازه',
},
'image-text-wrap': {
title: 'پیچش متن',
wrappingStyle: 'سبک پیچش',
square: 'مربع',
topAndBottom: 'بالا و پایین',
inline: 'در یک خط با متن',
behindText: 'پشت متن',
inFrontText: 'جلوی متن',
wrapText: 'پیچش متن',
bothSide: 'هر دو طرف',
leftOnly: 'فقط سمت چپ',
rightOnly: 'فقط سمت راست',
distanceFromText: 'فاصله از متن',
top: 'بالا(px)',
left: 'چپ(px)',
bottom: 'پایین(px)',
right: 'راست(px)',
},
'image-position': {
title: 'موقعیت',
horizontal: 'افقی',
vertical: 'عمودی',
absolutePosition: 'موقعیت مطلق(px)',
toTheRightOf: 'سمت راست',
bellow: 'زیر',
options: 'گزینه‌ها',
moveObjectWithText: 'جابه‌جایی شیء با متن',
column: 'ستون',
margin: 'حاشیه',
page: 'صفحه',
line: 'خط',
paragraph: 'پاراگراف',
},
'update-status': {
exceedMaxSize: 'اندازه تصویر از حد مجاز فراتر رفته است، حد مجاز {0}M است',
invalidImageType: 'نوع تصویر نامعتبر است',
exceedMaxCount: 'فقط {0} تصویر می‌توانند همزمان آپلود شوند',
invalidImage: 'تصویر نامعتبر است',
},
shortcut: {
'drawing-view': 'نمای طراحی',
'drawing-move-down': 'جابه‌جایی طراحی به پایین',
'drawing-move-up': 'جابه‌جایی طراحی به بالا',
'drawing-move-left': 'جابه‌جایی طراحی به چپ',
'drawing-move-right': 'جابه‌جایی طراحی به راست',
'drawing-delete': 'حذف طراحی',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Image',
upload: {
float: 'Insérer une image',
},
shape: {
insert: {
title: 'Insérer une forme',
rectangle: 'Insérer un rectangle',
ellipse: 'Insérer une ellipse',
},
},
panel: {
title: 'Modifier l\'image',
},
'image-popup': {
delete: 'Supprimer',
edit: 'Modifier',
crop: 'Rogner',
reset: 'Réinitialiser la taille',
},
'image-text-wrap': {
title: 'Habillage du texte',
wrappingStyle: 'Style d\'habillage',
square: 'Carré',
topAndBottom: 'Haut et bas',
inline: 'Aligné avec le texte',
behindText: 'Derrière le texte',
inFrontText: 'Devant le texte',
wrapText: 'Habiller le texte',
bothSide: 'Des deux côtés',
leftOnly: 'Seulement à gauche',
rightOnly: 'Seulement à droite',
distanceFromText: 'Distance du texte',
top: 'Haut(px)',
left: 'Gauche(px)',
bottom: 'Bas(px)',
right: 'Droite(px)',
},
'image-position': {
title: 'Position',
horizontal: 'Horizontal',
vertical: 'Vertical',
absolutePosition: 'Position absolue(px)',
toTheRightOf: 'à droite de',
bellow: 'en dessous',
options: 'Options',
moveObjectWithText: 'Déplacer l\'objet avec le texte',
column: 'Colonne',
margin: 'Marge',
page: 'Page',
line: 'Ligne',
paragraph: 'Paragraphe',
},
'update-status': {
exceedMaxSize: 'La taille de l\'image dépasse la limite, la limite est de {0}M',
invalidImageType: 'Type d\'image invalide',
exceedMaxCount: 'Seulement {0} images peuvent être téléchargées à la fois',
invalidImage: 'Image invalide',
},
shortcut: {
'drawing-view': 'Vue du dessin',
'drawing-move-down': 'Déplacer le dessin vers le bas',
'drawing-move-up': 'Déplacer le dessin vers le haut',
'drawing-move-left': 'Déplacer le dessin vers la gauche',
'drawing-move-right': 'Déplacer le dessin vers la droite',
'drawing-delete': 'Supprimer le dessin',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Gambar',
upload: {
float: 'Sisipkan Gambar',
},
shape: {
insert: {
title: 'Sisipkan Bentuk',
rectangle: 'Sisipkan Persegi Panjang',
ellipse: 'Sisipkan Elips',
},
},
panel: {
title: 'Edit Gambar',
},
'image-popup': {
delete: 'Hapus',
edit: 'Edit',
crop: 'Pangkas',
reset: 'Atur Ulang Ukuran',
},
'image-text-wrap': {
title: 'Pembungkus Teks',
wrappingStyle: 'Gaya Pembungkus',
square: 'Kotak',
topAndBottom: 'Atas dan Bawah',
inline: 'Sejajar dengan teks',
behindText: 'Di belakang teks',
inFrontText: 'Di depan teks',
wrapText: 'Bungkus teks',
bothSide: 'Kedua sisi',
leftOnly: 'Kiri saja',
rightOnly: 'Kanan saja',
distanceFromText: 'Jarak dari teks',
top: 'Atas(px)',
left: 'Kiri(px)',
bottom: 'Bawah(px)',
right: 'Kanan(px)',
},
'image-position': {
title: 'Posisi',
horizontal: 'Horizontal',
vertical: 'Vertikal',
absolutePosition: 'Posisi Absolut(px)',
toTheRightOf: 'di sebelah kanan',
bellow: 'di bawah',
options: 'Opsi',
moveObjectWithText: 'Pindahkan objek dengan teks',
column: 'Kolom',
margin: 'Margin',
page: 'Halaman',
line: 'Baris',
paragraph: 'Paragraf',
},
'update-status': {
exceedMaxSize: 'Ukuran gambar melebihi batas, batasnya adalah {0}M',
invalidImageType: 'Jenis gambar tidak valid',
exceedMaxCount: 'Hanya {0} gambar yang dapat diunggah dalam satu waktu',
invalidImage: 'Gambar tidak valid',
},
shortcut: {
'drawing-view': 'Tampilan Gambar',
'drawing-move-down': 'Pindahkan Gambar ke Bawah',
'drawing-move-up': 'Pindahkan Gambar ke Atas',
'drawing-move-left': 'Pindahkan Gambar ke Kiri',
'drawing-move-right': 'Pindahkan Gambar ke Kanan',
'drawing-delete': 'Hapus Gambar',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Immagine',
upload: {
float: 'Inserisci immagine',
},
shape: {
insert: {
title: 'Inserisci Forma',
rectangle: 'Inserisci Rettangolo',
ellipse: 'Inserisci Ellisse',
},
},
panel: {
title: 'Modifica immagine',
},
'image-popup': {
delete: 'Elimina',
edit: 'Modifica',
crop: 'Ritaglia',
reset: 'Reimposta dimensione',
},
'image-text-wrap': {
title: 'Testo a capo',
wrappingStyle: 'Stile a capo',
square: 'Quadrato',
topAndBottom: 'Superiore e inferiore',
inline: 'In linea con il testo',
behindText: 'Dietro il testo',
inFrontText: 'Davanti al testo',
wrapText: 'Testo a capo',
bothSide: 'Entrambi i lati',
leftOnly: 'Solo sinistra',
rightOnly: 'Solo destra',
distanceFromText: 'Distanza dal testo',
top: 'Superiore(px)',
left: 'Sinistra(px)',
bottom: 'Inferiore(px)',
right: 'Destra(px)',
},
'image-position': {
title: 'Posizione',
horizontal: 'Orizzontale',
vertical: 'Verticale',
absolutePosition: 'Posizione assoluta(px)',
toTheRightOf: 'a destra di',
bellow: 'sotto',
options: 'Opzioni',
moveObjectWithText: 'Sposta oggetto con il testo',
column: 'Colonna',
margin: 'Margine',
page: 'Pagina',
line: 'Riga',
paragraph: 'Paragrafo',
},
'update-status': {
exceedMaxSize: 'La dimensione dell\'immagine supera il limite, il limite è {0}M',
invalidImageType: 'Tipo di immagine non valido',
exceedMaxCount: 'È possibile caricare solo {0} immagini alla volta',
invalidImage: 'Immagine non valida',
},
shortcut: {
'drawing-view': 'Vista disegno',
'drawing-move-down': 'Sposta disegno in basso',
'drawing-move-up': 'Sposta disegno in alto',
'drawing-move-left': 'Sposta disegno a sinistra',
'drawing-move-right': 'Sposta disegno a destra',
'drawing-delete': 'Elimina disegno',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: '画像',
upload: {
float: '画像を挿入',
},
shape: {
insert: {
title: '図形の挿入',
rectangle: '四角形を挿入',
ellipse: '楕円を挿入',
},
},
panel: {
title: '画像の編集',
},
'image-popup': {
delete: '削除',
edit: '編集',
crop: 'トリミング',
reset: 'サイズをリセット',
},
'image-text-wrap': {
title: '文字列の折り返し',
wrappingStyle: '折り返しのスタイル',
square: '四角形',
topAndBottom: '上下',
inline: '行内',
behindText: '文字列の背面',
inFrontText: '文字列の前面',
wrapText: '文字列の折り返し',
bothSide: '両側',
leftOnly: '左のみ',
rightOnly: '右のみ',
distanceFromText: '文字列からの距離',
top: '上(px)',
left: '左(px)',
bottom: '下(px)',
right: '右(px)',
},
'image-position': {
title: '位置',
horizontal: '水平方向',
vertical: '垂直方向',
absolutePosition: '絶対位置(px)',
toTheRightOf: '右側に',
bellow: '下に',
options: 'オプション',
moveObjectWithText: '文字列と一緒にオブジェクトを移動',
column: '列',
margin: '余白',
page: 'ページ',
line: '行',
paragraph: '段落',
},
'update-status': {
exceedMaxSize: '画像のサイズが制限を超えています。制限: {0}MB',
invalidImageType: '無効な画像形式です',
exceedMaxCount: '一度にアップロードできる最大数は{0}件です',
invalidImage: '無効な画像です',
},
shortcut: {
'drawing-view': '図形表示',
'drawing-move-down': '図形を下へ移動',
'drawing-move-up': '図形を上へ移動',
'drawing-move-left': '図形を左へ移動',
'drawing-move-right': '図形を右へ移動',
'drawing-delete': '図形を削除',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: '이미지',
upload: {
float: '이미지 삽입',
},
shape: {
insert: {
title: '도형 삽입',
rectangle: '사각형 삽입',
ellipse: '타원 삽입',
},
},
panel: {
title: '이미지 편집',
},
'image-popup': {
delete: '삭제',
edit: '편집',
crop: '자르기',
reset: '크기 초기화',
},
'image-text-wrap': {
title: '텍스트 감싸기',
wrappingStyle: '감싸기 스타일',
square: '사각형',
topAndBottom: '위아래',
inline: '텍스트와 같은 줄',
behindText: '텍스트 뒤',
inFrontText: '텍스트 앞',
wrapText: '텍스트 감싸기',
bothSide: '양쪽',
leftOnly: '왼쪽만',
rightOnly: '오른쪽만',
distanceFromText: '텍스트와 거리',
top: '위(px)',
left: '왼쪽(px)',
bottom: '아래(px)',
right: '오른쪽(px)',
},
'image-position': {
title: '위치',
horizontal: '수평',
vertical: '수직',
absolutePosition: '절대 위치(px)',
toTheRightOf: '우측에',
bellow: '아래에',
options: '옵션',
moveObjectWithText: '텍스트와 함께 객체 이동',
column: '열',
margin: '여백',
page: '페이지',
line: '줄',
paragraph: '문단',
},
'update-status': {
exceedMaxSize: '이미지 크기가 제한을 초과했습니다. 제한: {0}MB',
invalidImageType: '유효하지 않은 이미지 유형입니다',
exceedMaxCount: '한 번에 최대 {0}개만 업로드할 수 있습니다',
invalidImage: '유효하지 않은 이미지입니다',
},
shortcut: {
'drawing-view': '그리기 보기',
'drawing-move-down': '그리기 아래로 이동',
'drawing-move-up': '그리기 위로 이동',
'drawing-move-left': '그리기 왼쪽으로 이동',
'drawing-move-right': '그리기 오른쪽으로 이동',
'drawing-delete': '그리기 삭제',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Obraz',
upload: {
float: 'Wstaw obraz',
},
shape: {
insert: {
title: 'Wstaw kształt',
rectangle: 'Wstaw prostokąt',
ellipse: 'Wstaw elipsę',
},
},
panel: {
title: 'Edytuj obraz',
},
'image-popup': {
delete: 'Usuń',
edit: 'Edytuj',
crop: 'Przytnij',
reset: 'Resetuj rozmiar',
},
'image-text-wrap': {
title: 'Zawijanie tekstu',
wrappingStyle: 'Styl zawijania',
square: 'Kwadrat',
topAndBottom: 'Góra i dół',
inline: 'W linii z tekstem',
behindText: 'Za tekstem',
inFrontText: 'Przed tekstem',
wrapText: 'Zawijaj tekst',
bothSide: 'Obie strony',
leftOnly: 'Tylko lewa',
rightOnly: 'Tylko prawa',
distanceFromText: 'Odległość od tekstu',
top: 'Góra(px)',
left: 'Lewo(px)',
bottom: 'Dół(px)',
right: 'Prawo(px)',
},
'image-position': {
title: 'Pozycja',
horizontal: 'Pozioma',
vertical: 'Pionowa',
absolutePosition: 'Pozycja bezwzględna(px)',
toTheRightOf: 'po prawej stronie',
bellow: 'poniżej',
options: 'Opcje',
moveObjectWithText: 'Przesuwaj obiekt razem z tekstem',
column: 'Kolumna',
margin: 'Margines',
page: 'Strona',
line: 'Wiersz',
paragraph: 'Akapit',
},
'update-status': {
exceedMaxSize: 'Rozmiar obrazu przekracza limit, limit wynosi {0}M',
invalidImageType: 'Nieprawidłowy typ obrazu',
exceedMaxCount: 'Jednocześnie można przesłać tylko {0} obrazów',
invalidImage: 'Nieprawidłowy obraz',
},
shortcut: {
'drawing-view': 'Widok rysunku',
'drawing-move-down': 'Przesuń rysunek w dół',
'drawing-move-up': 'Przesuń rysunek w górę',
'drawing-move-left': 'Przesuń rysunek w lewo',
'drawing-move-right': 'Przesuń rysunek w prawo',
'drawing-delete': 'Usuń rysunek',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Imagem',
upload: {
float: 'Inserir imagem',
},
shape: {
insert: {
title: 'Inserir Forma',
rectangle: 'Inserir Retângulo',
ellipse: 'Inserir Elipse',
},
},
panel: {
title: 'Editar imagem',
},
'image-popup': {
delete: 'Excluir',
edit: 'Editar',
crop: 'Cortar',
reset: 'Redefinir tamanho',
},
'image-text-wrap': {
title: 'Quebra de texto',
wrappingStyle: 'Estilo de quebra',
square: 'Quadrado',
topAndBottom: 'Superior e inferior',
inline: 'Em linha com o texto',
behindText: 'Atrás do texto',
inFrontText: 'À frente do texto',
wrapText: 'Quebrar texto automaticamente',
bothSide: 'Ambos os lados',
leftOnly: 'Somente esquerda',
rightOnly: 'Somente direita',
distanceFromText: 'Distância do texto',
top: 'Superior(px)',
left: 'Esquerda(px)',
bottom: 'Inferior(px)',
right: 'Direita(px)',
},
'image-position': {
title: 'Posição',
horizontal: 'Horizontal',
vertical: 'Vertical',
absolutePosition: 'Posição absoluta(px)',
toTheRightOf: 'à direita de',
bellow: 'abaixo',
options: 'Opções',
moveObjectWithText: 'Mover objeto com o texto',
column: 'Coluna',
margin: 'Margem',
page: 'Página',
line: 'Linha',
paragraph: 'Parágrafo',
},
'update-status': {
exceedMaxSize: 'O tamanho da imagem excede o limite, o limite é {0}M',
invalidImageType: 'Tipo de imagem inválido',
exceedMaxCount: 'Apenas {0} imagens podem ser enviadas de cada vez',
invalidImage: 'Imagem inválida',
},
shortcut: {
'drawing-view': 'Visualização do desenho',
'drawing-move-down': 'Mover desenho para baixo',
'drawing-move-up': 'Mover desenho para cima',
'drawing-move-left': 'Mover desenho para esquerda',
'drawing-move-right': 'Mover desenho para direita',
'drawing-delete': 'Excluir desenho',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Изображение',
upload: {
float: 'Вставить изображение',
},
shape: {
insert: {
title: 'Вставить фигуру',
rectangle: 'Вставить прямоугольник',
ellipse: 'Вставить эллипс',
},
},
panel: {
title: 'Редактировать изображение',
},
'image-popup': {
delete: 'Удалить',
edit: 'Редактировать',
crop: 'Обрезать',
reset: 'Сбросить размер',
},
'image-text-wrap': {
title: 'Обтекание текстом',
wrappingStyle: 'Стиль обтекания',
square: 'Квадрат',
topAndBottom: 'Сверху и снизу',
inline: 'В тексте',
behindText: 'За текстом',
inFrontText: 'Перед текстом',
wrapText: 'Обтекание текстом',
bothSide: 'С обеих сторон',
leftOnly: 'Только слева',
rightOnly: 'Только справа',
distanceFromText: 'Расстояние от текста',
top: 'Сверху (px)',
left: 'Слева (px)',
bottom: 'Снизу (px)',
right: 'Справа (px)',
},
'image-position': {
title: 'Положение',
horizontal: 'По горизонтали',
vertical: 'По вертикали',
absolutePosition: 'Абсолютное положение (px)',
toTheRightOf: 'справа от',
bellow: 'снизу',
options: 'Параметры',
moveObjectWithText: 'Перемещать объект с текстом',
column: 'Колонка',
margin: 'Поле',
page: 'Страница',
line: 'Строка',
paragraph: 'Абзац',
},
'update-status': {
exceedMaxSize: 'Размер изображения превышает лимит, лимит составляет {0}М',
invalidImageType: 'Недопустимый тип изображения',
exceedMaxCount: 'За один раз можно загрузить только {0} изображений',
invalidImage: 'Недопустимое изображение',
},
shortcut: {
'drawing-view': 'Вид изображения',
'drawing-move-down': 'Переместить изображение вниз',
'drawing-move-up': 'Переместить изображение вверх',
'drawing-move-left': 'Переместить изображение влево',
'drawing-move-right': 'Переместить изображение вправо',
'drawing-delete': 'Удалить изображение',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Obrázok',
upload: {
float: 'Vložiť obrázok',
},
shape: {
insert: {
title: 'Vložiť tvar',
rectangle: 'Vložiť obdĺžnik',
ellipse: 'Vložiť elipsu',
},
},
panel: {
title: 'Upraviť obrázok',
},
'image-popup': {
delete: 'Odstrániť',
edit: 'Upraviť',
crop: 'Orezať',
reset: 'Obnoviť veľkosť',
},
'image-text-wrap': {
title: 'Obtekanie textu',
wrappingStyle: 'Štýl obtekania',
square: 'Obdĺžnikové',
topAndBottom: 'Hore a dole',
inline: 'V riadku s textom',
behindText: 'Za textom',
inFrontText: 'Pred textom',
wrapText: 'Obtekať text',
bothSide: 'Obe strany',
leftOnly: 'Len ľavá',
rightOnly: 'Len pravá',
distanceFromText: 'Vzdialenosť od textu',
top: 'Hore (px)',
left: 'Vľavo (px)',
bottom: 'Dole (px)',
right: 'Vpravo (px)',
},
'image-position': {
title: 'Pozícia',
horizontal: 'Vodorovne',
vertical: 'Zvisle',
absolutePosition: 'Absolútna pozícia (px)',
toTheRightOf: 'vpravo od',
bellow: 'pod',
options: 'Možnosti',
moveObjectWithText: 'Presunúť objekt s textom',
column: 'Stĺpec',
margin: 'Okraj',
page: 'Strana',
line: 'Riadok',
paragraph: 'Odsek',
},
'update-status': {
exceedMaxSize: 'Veľkosť obrázka prekračuje limit, limit je {0}M',
invalidImageType: 'Neplatný typ obrázka',
exceedMaxCount: 'Naraz možno nahrať iba {0} obrázkov',
invalidImage: 'Neplatný obrázok',
},
shortcut: {
'drawing-view': 'Zobrazenie kresby',
'drawing-move-down': 'Posunúť kresbu nadol',
'drawing-move-up': 'Posunúť kresbu nahor',
'drawing-move-left': 'Posunúť kresbu doľava',
'drawing-move-right': 'Posunúť kresbu doprava',
'drawing-delete': 'Odstrániť kresbu',
},
},
};
export default locale;

View File

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

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: 'Hình ảnh',
upload: {
float: 'Chèn hình ảnh',
},
shape: {
insert: {
title: 'Chèn hình',
rectangle: 'Chèn hình chữ nhật',
ellipse: 'Chèn hình elip',
},
},
panel: {
title: 'Chỉnh sửa hình ảnh',
},
'image-popup': {
delete: 'Xóa',
edit: 'Chỉnh sửa',
crop: 'Cắt',
reset: 'Đặt lại kích thước',
},
'image-text-wrap': {
title: 'Bọc văn bản',
wrappingStyle: 'Kiểu bọc văn bản',
square: 'Hình vuông',
topAndBottom: 'Trên và dưới',
inline: 'Cùng dòng với văn bản',
behindText: 'Phía sau văn bản',
inFrontText: 'Phía trước văn bản',
wrapText: 'Bọc văn bản',
bothSide: 'Cả hai bên',
leftOnly: 'Chỉ bên trái',
rightOnly: 'Chỉ bên phải',
distanceFromText: 'Khoảng cách với văn bản',
top: 'Trên (px)',
left: 'Trái (px)',
bottom: 'Dưới (px)',
right: 'Phải (px)',
},
'image-position': {
title: 'Vị trí',
horizontal: 'Ngang',
vertical: 'Dọc',
absolutePosition: 'Vị trí tuyệt đối (px)',
toTheRightOf: 'ở bên phải',
bellow: 'bên dưới',
options: 'Tùy chọn',
moveObjectWithText: 'Di chuyển đối tượng cùng văn bản',
column: 'Cột',
margin: 'Lề',
page: 'Trang',
line: 'Dòng',
paragraph: 'Đoạn văn',
},
'update-status': {
exceedMaxSize: 'Kích thước hình ảnh vượt quá giới hạn, giới hạn là {0}M',
invalidImageType: 'Loại hình ảnh không hợp lệ',
exceedMaxCount: 'Chỉ có thể tải lên tối đa {0} hình ảnh một lần',
invalidImage: 'Hình ảnh không hợp lệ',
},
shortcut: {
'drawing-view': 'Chế độ xem bản vẽ',
'drawing-move-down': 'Di chuyển bản vẽ xuống',
'drawing-move-up': 'Di chuyển bản vẽ lên',
'drawing-move-left': 'Di chuyển bản vẽ sang trái',
'drawing-move-right': 'Di chuyển bản vẽ sang phải',
'drawing-delete': 'Xóa bản vẽ',
},
},
};
export default locale;

View File

@@ -0,0 +1,91 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: '图片',
upload: {
float: '插入图片',
},
shape: {
insert: {
title: '插入图形',
rectangle: '插入矩形',
ellipse: '插入椭圆',
},
},
panel: {
title: '编辑图片',
},
'image-popup': {
delete: '删除',
edit: '编辑',
crop: '裁剪',
reset: '重置大小',
},
'image-text-wrap': {
title: '文字环绕',
wrappingStyle: '环绕方式',
square: '四周型',
topAndBottom: '上下型',
inline: '嵌入型',
behindText: '衬于文字下方',
inFrontText: '浮于文字上方',
wrapText: '自动换行',
bothSide: '两侧',
leftOnly: '左侧',
rightOnly: '右侧',
distanceFromText: '距正文',
top: '上(px)',
left: '左(px)',
bottom: '下(px)',
right: '右(px)',
},
'image-position': {
title: '位置',
horizontal: '水平',
vertical: '垂直',
absolutePosition: '绝对位置(px)',
toTheRightOf: '右侧',
bellow: '下方',
options: '选项',
moveObjectWithText: '对象随文字移动',
column: '栏',
margin: '页边距',
page: '页面',
line: '行',
paragraph: '段落',
},
'update-status': {
exceedMaxSize: '图片大小超过限制, 限制为{0}M',
invalidImageType: '图片类型错误',
exceedMaxCount: '图片只能一次上传{0}张',
invalidImage: '无效图片',
},
shortcut: {
'drawing-view': '绘图视图',
'drawing-move-down': '下移绘图',
'drawing-move-up': '上移绘图',
'drawing-move-left': '左移绘图',
'drawing-move-right': '右移绘图',
'drawing-delete': '删除绘图',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: '圖片',
upload: {
float: '插入圖片',
},
shape: {
insert: {
title: '插入圖形',
rectangle: '插入矩形',
ellipse: '插入橢圓形',
},
},
panel: {
title: '編圖',
},
'image-popup': {
delete: '刪除',
edit: '編輯',
crop: '裁切',
reset: '重置大小',
},
'image-text-wrap': {
title: '文繞圖',
wrappingStyle: '文繞圖方式',
square: '矩形',
topAndBottom: '上及下',
inline: '與文字排列',
behindText: '文字在前',
inFrontText: '文字在後',
wrapText: '自動換行',
bothSide: '兩側',
leftOnly: '僅左側',
rightOnly: '僅右側',
distanceFromText: '與文字距離',
top: '上(px)',
left: '左(px)',
bottom: '下(px)',
right: '右(px)',
},
'image-position': {
title: '位置',
horizontal: '水平',
vertical: '垂直',
absolutePosition: '絕對位置(px)',
toTheRightOf: '右側',
bellow: '下方',
options: '選項',
moveObjectWithText: '物件隨文字移動',
column: '欄',
margin: '邊界',
page: '頁面',
line: '行',
paragraph: '段落',
},
'update-status': {
exceedMaxSize: '圖片大小超過限制, 限制為{0}M',
invalidImageType: '圖片類型錯誤',
exceedMaxCount: '圖片只能一次上傳{0}張',
invalidImage: '無效圖片',
},
shortcut: {
'drawing-view': '繪圖檢視',
'drawing-move-down': '下移繪圖',
'drawing-move-up': '上移繪圖',
'drawing-move-left': '左移繪圖',
'drawing-move-right': '右移繪圖',
'drawing-delete': '刪除繪圖',
},
},
};
export default locale;

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'docs-drawing-ui': {
title: '圖片',
upload: {
float: '插入圖片',
},
shape: {
insert: {
title: '插入圖形',
rectangle: '插入矩形',
ellipse: '插入橢圓形',
},
},
panel: {
title: '編圖',
},
'image-popup': {
delete: '刪除',
edit: '編輯',
crop: '裁切',
reset: '重置大小',
},
'image-text-wrap': {
title: '文繞圖',
wrappingStyle: '文繞圖方式',
square: '矩形',
topAndBottom: '上及下',
inline: '與文字排列',
behindText: '文字在前',
inFrontText: '文字在後',
wrapText: '自動換行',
bothSide: '兩側',
leftOnly: '僅左側',
rightOnly: '僅右側',
distanceFromText: '與文字距離',
top: '上(px)',
left: '左(px)',
bottom: '下(px)',
right: '右(px)',
},
'image-position': {
title: '位置',
horizontal: '水平',
vertical: '垂直',
absolutePosition: '絕對位置(px)',
toTheRightOf: '右側',
bellow: '下方',
options: '選項',
moveObjectWithText: '物件隨文字移動',
column: '欄',
margin: '邊界',
page: '頁面',
line: '行',
paragraph: '段落',
},
'update-status': {
exceedMaxSize: '圖片大小超過限制, 限制為{0}M',
invalidImageType: '圖片類型錯誤',
exceedMaxCount: '圖片只能一次上傳{0}張',
invalidImage: '無效圖片',
},
shortcut: {
'drawing-view': '繪圖檢視',
'drawing-move-down': '下移繪圖',
'drawing-move-up': '上移繪圖',
'drawing-move-left': '左移繪圖',
'drawing-move-right': '右移繪圖',
'drawing-delete': '刪除繪圖',
},
},
};
export default locale;

View File

@@ -0,0 +1,337 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingSearch } from '@univerjs/core';
import {
ContextService,
DocumentDataModel,
DrawingTypeEnum,
ICommandService,
IContextService,
Injector,
IPermissionService,
IUniverInstanceService,
PermissionService,
toDisposable,
} from '@univerjs/core';
import { setDocumentPermissionValue } from '@univerjs/docs';
import { DocDrawingService, IDocDrawingAdapterService, IDocDrawingService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing';
import { DocCanvasPopManagerService, MOBILE_DOC_ELEMENT_MENU } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { COMPONENT_MOBILE_IMAGE_POPUP_MENU, OpenImageCropOperation } from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { IMenuManagerService } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { EditDocDrawingOperation } from '../../commands/operations/edit-doc-drawing.operation';
import { MobileDocDrawingPopupMenuController } from '../../controllers/mobile/drawing-popup-menu.controller';
import { DocDrawingFloatingToolbarAdapterService } from '../../services/doc-drawing-floating-toolbar-adapter.service';
import { DocDrawingPopupMenuController } from '../drawing-popup-menu.controller';
function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mobile = false) {
const unitId = 'doc-1';
const drawingId = 'drawing-1';
const injector = new Injector();
const createControl$ = new Subject<void>();
const clearControl$ = new Subject<boolean>();
const changing$ = new Subject<void>();
const focus$ = new Subject<never[]>();
const remove$ = new Subject<IDrawingSearch[]>();
const currentDocument$ = new Subject<never>();
const disposedDocument$ = new Subject<never>();
const renderCreated$ = new Subject<never>();
const renderDisposed$ = new Subject<never>();
const drawingObject = { oKey: drawingId };
const selectedObjects = new Map([[drawingId, drawingObject]]);
const popupDisposable = {
dispose: vi.fn(),
canDispose: () => false,
};
const drawing = {
unitId,
subUnitId: unitId,
drawingId,
drawingType,
};
const documentDataModel = new DocumentDataModel({
id: unitId,
body: {
dataStream: '\b\r\n',
customBlocks: [{ startIndex: 0, blockId: drawingId }],
},
});
const transformer = {
createControl$,
clearControl$,
changing$,
getSelectedObjectMap: () => selectedObjects,
clearSelectedObjects: vi.fn(),
};
const scene = {
getAllObjects: () => [],
getTransformerByCreate: () => transformer,
};
const executeCommand = vi.fn();
injector.add([ICommandService, {
useValue: {
executeCommand,
onCommandExecuted: () => toDisposable(() => undefined),
} as never,
}]);
const contextService = new ContextService();
injector.add([IContextService, { useValue: contextService }]);
injector.add([IPermissionService, { useClass: PermissionService }]);
injector.add([IUniverInstanceService, {
useValue: {
getAllUnitsForType: () => [documentDataModel],
getCurrentTypeOfUnit$: () => currentDocument$,
getTypeOfUnitDisposed$: () => disposedDocument$,
getUnit: () => documentDataModel,
getUnitType: () => undefined,
} as never,
}]);
injector.add([IRenderManagerService, {
useValue: {
created$: renderCreated$,
disposed$: renderDisposed$,
getRenderUnitById: () => ({ scene }),
has: () => true,
} as never,
}]);
injector.add([IDrawingManagerService, {
useValue: {
focus$,
remove$,
focusDrawing: vi.fn(),
getDrawingByParam: () => drawing,
getDrawingOKey: () => drawing,
getFocusDrawings: () => [],
} as never,
}]);
injector.add([IDocDrawingAdapterService, {
useValue: {
getEditDrawingCommandInfo: () => null,
} as never,
}]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
const attachPopupToObject = vi.fn<DocCanvasPopManagerService['attachPopupToObject']>(() => popupDisposable);
injector.add([DocCanvasPopManagerService, {
useValue: {
attachPopupToObject,
} as never,
}]);
injector.add([IMenuManagerService, {
useValue: {
getFlatMenuByPositionKey: () => [],
} as never,
}]);
injector.add([DocDrawingFloatingToolbarAdapterService]);
if (mobile) {
injector.add([MobileDocDrawingPopupMenuController]);
} else {
injector.add([DocDrawingPopupMenuController]);
}
const controller = mobile
? injector.get(MobileDocDrawingPopupMenuController)
: injector.get(DocDrawingPopupMenuController);
return {
clearControl$,
createControl$,
contextService,
controller,
executeCommand,
injector,
attachPopupToObject,
popupDisposable,
selectedObjects,
remove$,
transformer,
};
}
describe('DocDrawingPopupMenuController', () => {
it('keeps the mobile popup controller independent from the desktop controller', () => {
expect(MobileDocDrawingPopupMenuController.prototype).not.toBeInstanceOf(DocDrawingPopupMenuController);
});
it('does not expose mobile callbacks through the desktop popup', () => {
const harness = createControllerHarness();
const { controller } = harness;
try {
harness.createControl$.next();
const extraProps = harness.attachPopupToObject.mock.calls[0][1].extraProps;
expect(extraProps).not.toHaveProperty('onClose');
expect(extraProps).not.toHaveProperty('onEdit');
expect(extraProps).not.toHaveProperty('onDelete');
} finally {
controller.dispose();
harness.injector.dispose();
}
});
it('closes a mobile popup without clearing the object being edited', () => {
const harness = createControllerHarness(DrawingTypeEnum.DRAWING_CHART, true);
const { controller } = harness;
try {
harness.createControl$.next();
const onClose = harness.attachPopupToObject.mock.calls[0][1].extraProps?.onClose;
expect(onClose).toBeTypeOf('function');
if (typeof onClose === 'function') {
onClose();
}
expect(harness.popupDisposable.dispose).toHaveBeenCalledOnce();
expect(harness.selectedObjects.size).toBe(1);
expect(harness.transformer.clearSelectedObjects).not.toHaveBeenCalled();
} finally {
controller.dispose();
harness.injector.dispose();
}
});
it('keeps generic drawing edit and delete callbacks in the mobile controller', async () => {
const harness = createControllerHarness(DrawingTypeEnum.DRAWING_TABLE, true);
const { controller } = harness;
try {
harness.createControl$.next();
const popupOptions = harness.attachPopupToObject.mock.calls[0][1];
const extraProps = popupOptions.extraProps as typeof popupOptions.extraProps & {
onEdit?: () => unknown;
onDelete?: () => unknown;
};
expect(popupOptions.componentKey).toBe(MOBILE_DOC_ELEMENT_MENU);
await extraProps?.onEdit?.();
expect(harness.executeCommand).toHaveBeenCalledWith(EditDocDrawingOperation.id, {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
});
await extraProps?.onDelete?.();
expect(harness.executeCommand).toHaveBeenCalledWith(RemoveDocDrawingCommand.id, {
unitId: 'doc-1',
drawings: [{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' }],
});
} finally {
controller.dispose();
harness.injector.dispose();
}
});
it.each([false, true])('removes the floating menu and selection handles when its drawing is deleted (mobile: %s)', (mobile) => {
const { controller, createControl$, injector, popupDisposable, remove$, transformer } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile);
try {
createControl$.next();
remove$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'another-drawing' }]);
expect(popupDisposable.dispose).not.toHaveBeenCalled();
remove$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' }]);
expect(popupDisposable.dispose).toHaveBeenCalledOnce();
expect(transformer.clearSelectedObjects).toHaveBeenCalledOnce();
} finally {
controller.dispose();
injector.dispose();
}
});
it.each([false, true])('keeps an active popup during refresh and removes it after the selected drawing is deleted (mobile: %s)', (mobile) => {
const { controller, clearControl$, createControl$, injector, popupDisposable, selectedObjects } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile);
try {
createControl$.next();
clearControl$.next(false);
expect(popupDisposable.dispose).not.toHaveBeenCalled();
selectedObjects.clear();
clearControl$.next(true);
expect(popupDisposable.dispose).toHaveBeenCalledOnce();
} finally {
controller.dispose();
injector.dispose();
}
});
it.each([false, true])('removes an active popup when document editing is revoked even if drawing focus is empty (mobile: %s)', (mobile) => {
const { controller, createControl$, injector, popupDisposable } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile);
try {
createControl$.next();
expect(popupDisposable.dispose).not.toHaveBeenCalled();
setDocumentPermissionValue(injector.get(IPermissionService), 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(popupDisposable.dispose).toHaveBeenCalledOnce();
} finally {
controller.dispose();
injector.dispose();
}
});
it('uses the mobile drawing menu for images and charts', () => {
const imageHarness = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, true);
const imageController = imageHarness.controller;
try {
imageHarness.createControl$.next();
expect(imageHarness.attachPopupToObject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
componentKey: COMPONENT_MOBILE_IMAGE_POPUP_MENU,
extraProps: expect.objectContaining({
variant: 'doc-floating-toolbar',
menuItems: [
expect.objectContaining({ commandId: EditDocDrawingOperation.id }),
expect.objectContaining({ commandId: OpenImageCropOperation.id }),
expect.objectContaining({ commandId: RemoveDocDrawingCommand.id }),
],
}),
}),
'doc-1'
);
} finally {
imageController.dispose();
imageHarness.injector.dispose();
}
const chartHarness = createControllerHarness(DrawingTypeEnum.DRAWING_CHART, true);
const chartController = chartHarness.controller;
try {
chartHarness.createControl$.next();
expect(chartHarness.attachPopupToObject).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
componentKey: COMPONENT_MOBILE_IMAGE_POPUP_MENU,
extraProps: expect.objectContaining({
variant: 'doc-chart-floating-toolbar',
menuItems: [
expect.objectContaining({ commandId: EditDocDrawingOperation.id, label: 'docs-drawing-ui.image-popup.edit' }),
expect.objectContaining({ commandId: RemoveDocDrawingCommand.id }),
],
}),
}),
'doc-1'
);
} finally {
chartController.dispose();
chartHarness.injector.dispose();
}
});
});

View File

@@ -0,0 +1,454 @@
/**
* 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, IDisposable, INeedCheckDisposable, Nullable } from '@univerjs/core';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import {
DrawingTypeEnum,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
Inject,
IPermissionService,
isInternalEditorID,
IUniverInstanceService,
RxDisposable,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import {
canEditDocumentTargets,
getDocumentDrawingSegmentId,
getDocumentEntityParentPermissionObjectIds,
getDocumentEntityPermissionObjectId,
} from '@univerjs/docs';
import { IDocDrawingAdapterService, IDocDrawingService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import {
COMPONENT_IMAGE_POPUP_MENU,
ImageCropperObject,
ImageResetSizeOperation,
OpenImageCropOperation,
} from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { FloatingObjectToolbarPosition, IMenuManagerService, MenuItemType } from '@univerjs/ui';
import { takeUntil } from 'rxjs';
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
import { DocDrawingFloatingToolbarAdapterService } from '../services/doc-drawing-floating-toolbar-adapter.service';
interface IDrawingPopupTarget {
unitId: string;
subUnitId: string;
drawingId: string;
}
export class DocDrawingPopupMenuController extends RxDisposable {
private _initImagePopupMenu = new Set<string>();
private _embeddedRenderUnits = new Set<string>();
private _popupMenuListeners = new Map<string, IDisposable>();
private _disposePopupsByUnit = new Map<string, INeedCheckDisposable[]>();
private _popupTargetsByUnit = new Map<string, IDrawingPopupTarget>();
private _isDrawingPanelOpen = false;
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(DocCanvasPopManagerService) private readonly _canvasPopManagerService: DocCanvasPopManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IContextService private readonly _contextService: IContextService,
@IDocDrawingAdapterService private readonly _drawingAdapterService: IDocDrawingAdapterService,
@IDocDrawingService private readonly _docDrawingService: IDocDrawingService,
@Inject(DocDrawingFloatingToolbarAdapterService) private readonly _floatingToolbarAdapterService: DocDrawingFloatingToolbarAdapterService,
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IPermissionService private readonly _permissionService: IPermissionService
) {
super();
this._init();
}
// eslint-disable-next-line max-lines-per-function
private _init(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command) => {
const opensDrawingEditor = [...this._popupTargetsByUnit.values()].some((target) => {
const drawing = this._docDrawingService.getDrawingByParam(target);
const edit = drawing && this._drawingAdapterService.getEditDrawingCommandInfo({ ...target, drawing });
return edit?.commandId === command.id;
});
if (command.id === EditDocDrawingOperation.id || opensDrawingEditor) {
this._isDrawingPanelOpen = true;
this._clearPopups(undefined, true);
}
if (command.id === SidebarDocDrawingOperation.id) {
const params = command.params as { value?: string } | undefined;
this._isDrawingPanelOpen = params?.value === 'open';
if (this._isDrawingPanelOpen) {
this._clearPopups(undefined, true);
}
}
})
);
this.disposeWithMe(
this._drawingManagerService.focus$.subscribe((params) => {
if (params.length === 0) {
this._isDrawingPanelOpen = false;
}
})
);
this.disposeWithMe(this._drawingManagerService.remove$.subscribe((drawings) => {
for (const drawing of drawings) {
for (const [unitId, target] of this._popupTargetsByUnit) {
if (
target.unitId === drawing.unitId &&
target.subUnitId === drawing.subUnitId &&
target.drawingId === drawing.drawingId
) {
this._clearPopups(unitId, true);
this._renderManagerService.getRenderUnitById(unitId)?.scene.getTransformerByCreate().clearSelectedObjects();
}
}
}
}));
this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => {
for (const [popupUnitId, drawing] of this._popupTargetsByUnit) {
if (!this._canEditDrawing(drawing.unitId, drawing.drawingId)) {
this._clearPopups(popupUnitId, true);
}
}
}));
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._create(documentDataModel))
);
this.disposeWithMe(
this._univerInstanceService.getTypeOfUnitDisposed$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._dispose(documentDataModel))
);
this.disposeWithMe(
this._renderManagerService.created$.pipe(takeUntil(this.dispose$)).subscribe((render) => {
if (render.type !== UniverInstanceType.UNIVER_DOC || render.isMainScene) {
return;
}
this._embeddedRenderUnits.add(render.unitId);
this._create(this._univerInstanceService.getUnit<DocumentDataModel>(render.unitId, UniverInstanceType.UNIVER_DOC));
})
);
this.disposeWithMe(
this._renderManagerService.disposed$.pipe(takeUntil(this.dispose$)).subscribe((unitId) => {
if (!this._embeddedRenderUnits.delete(unitId)) {
return;
}
this._disposePopupMenuListener(unitId);
})
);
this._univerInstanceService.getAllUnitsForType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).forEach((documentDataModel) => this._create(documentDataModel));
}
private _dispose(documentDataModel: DocumentDataModel) {
const unitId = documentDataModel.getUnitId();
this._clearPopups(unitId, true);
this._disposePopupMenuListener(unitId);
this._renderManagerService.removeRender(unitId);
}
private _clearPopups(unitId?: string, force = false) {
if (unitId == null) {
[...this._disposePopupsByUnit.keys()].forEach((popupUnitId) => this._clearPopups(popupUnitId, force));
return;
}
const popups = this._disposePopupsByUnit.get(unitId);
if (!popups) {
return;
}
for (let index = popups.length - 1; index >= 0; index -= 1) {
const popup = popups[index];
if (force || popup.canDispose()) {
popup.dispose();
popups.splice(index, 1);
}
}
if (popups.length === 0) {
this._disposePopupsByUnit.delete(unitId);
this._popupTargetsByUnit.delete(unitId);
}
}
private _getDisposePopups(unitId: string): INeedCheckDisposable[] {
const popups = this._disposePopupsByUnit.get(unitId);
if (popups) {
return popups;
}
const nextPopups: INeedCheckDisposable[] = [];
this._disposePopupsByUnit.set(unitId, nextPopups);
return nextPopups;
}
private _create(documentDataModel: Nullable<DocumentDataModel>) {
if (!documentDataModel) {
return;
}
const unitId = documentDataModel.getUnitId();
if (isInternalEditorID(unitId)) {
return;
}
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
const listener = this._popupMenuListener(unitId);
if (listener) {
this._popupMenuListeners.set(unitId, listener);
this._initImagePopupMenu.add(unitId);
}
}
}
private _disposePopupMenuListener(unitId: string) {
this._popupMenuListeners.get(unitId)?.dispose();
this._popupMenuListeners.delete(unitId);
this._initImagePopupMenu.delete(unitId);
this._clearPopups(unitId, true);
}
private _hasCropObject(scene: Scene) {
const objects = scene.getAllObjects();
for (const object of objects) {
if (object instanceof ImageCropperObject) {
return true;
}
}
return false;
}
private _handleClearControl(
unitId: string,
transformer: ReturnType<Scene['getTransformerByCreate']>,
changeSelf: boolean
): void {
this._clearPopups(unitId, changeSelf);
queueMicrotask(() => {
if (transformer.getSelectedObjectMap().size > 0) {
return;
}
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
this._drawingManagerService.focusDrawing(null);
});
}
private _registerDrawingPopup(
unitId: string,
popup: INeedCheckDisposable,
drawing: IDrawingPopupTarget
): void {
this.disposeWithMe(popup);
this._getDisposePopups(unitId).push(popup);
this._popupTargetsByUnit.set(unitId, drawing);
const focusDrawings = this._drawingManagerService.getFocusDrawings();
const alreadyFocused = focusDrawings.find((focusedDrawing) =>
focusedDrawing.unitId === drawing.unitId &&
focusedDrawing.subUnitId === drawing.subUnitId &&
focusedDrawing.drawingId === drawing.drawingId
);
if (!alreadyFocused) {
this._drawingManagerService.focusDrawing([drawing]);
}
}
private _isSamePopupTarget(previous: IDrawingPopupTarget | undefined, next: IDrawingPopupTarget): boolean {
return previous?.unitId === next.unitId &&
previous.subUnitId === next.subUnitId &&
previous.drawingId === next.drawingId;
}
// eslint-disable-next-line max-lines-per-function
private _popupMenuListener(unitId: string): IDisposable | undefined {
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (!scene) {
return;
}
const transformer = scene.getTransformerByCreate();
const subscriptions = [
transformer.createControl$.subscribe(() => {
if (this._hasCropObject(scene)) {
this._clearPopups(unitId, true);
return;
}
const selectedObjects = transformer.getSelectedObjectMap();
if (this._isDrawingPanelOpen || selectedObjects.size > 1) {
this._clearPopups(unitId, this._isDrawingPanelOpen);
return;
}
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
this._clearPopups(unitId);
return;
}
const drawingParam = this._drawingManagerService.getDrawingOKey(object.oKey);
if (
!drawingParam ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_DOM ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_SHAPE
) {
this._clearPopups(unitId);
return;
}
const { unitId: drawingUnitId, subUnitId, drawingId, drawingType } = drawingParam;
if (!this._canEditDrawing(drawingUnitId, drawingId)) {
this._clearPopups(unitId, true);
return;
}
const disposePopups = this._disposePopupsByUnit.get(unitId);
const previousPopupTarget = this._popupTargetsByUnit.get(unitId);
const popupTarget = { unitId: drawingUnitId, subUnitId, drawingId };
if (this._isSamePopupTarget(previousPopupTarget, popupTarget) && disposePopups && disposePopups.length > 0) {
return;
}
this._clearPopups(unitId, previousPopupTarget != null);
const isImage = drawingType === DrawingTypeEnum.DRAWING_IMAGE;
// Charts use the document toolbar placement, while retaining chart-specific actions and controls.
const isChart = drawingType === DrawingTypeEnum.DRAWING_CHART;
const popup = this._canvasPopManagerService.attachPopupToObject(
object,
{
componentKey: COMPONENT_IMAGE_POPUP_MENU,
requiresStableLayout: false,
direction: isImage || isChart ? 'top-center' : 'horizontal',
offset: isImage || isChart ? [0, 8] : [2, 0],
extraProps: {
menuItems: this._getDrawingPopupMenuItems(drawingUnitId, subUnitId, drawingId, drawingType),
variant: isImage ? 'doc-floating-toolbar' : isChart ? 'doc-chart-floating-toolbar' : undefined,
unitId: drawingUnitId,
subUnitId,
drawingId,
},
},
drawingUnitId
);
this._registerDrawingPopup(unitId, popup, { unitId: drawingUnitId, subUnitId, drawingId });
}),
transformer.clearControl$.subscribe((changeSelf) => this._handleClearControl(unitId, transformer, changeSelf)),
transformer.changing$.subscribe(() => {
this._clearPopups(unitId, true);
}),
];
const disposable = toDisposable(() => subscriptions.forEach((subscription) => subscription.unsubscribe()));
this.disposeWithMe(disposable);
return disposable;
}
private _canEditDrawing(unitId: string, drawingId: string): boolean {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
unitId,
UniverInstanceType.UNIVER_DOC
);
if (!documentDataModel) {
return false;
}
const segmentId = getDocumentDrawingSegmentId(documentDataModel, drawingId);
return canEditDocumentTargets(this._permissionService, unitId, [
...getDocumentEntityParentPermissionObjectIds(documentDataModel, segmentId, 'drawing', drawingId),
getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawingId),
]);
}
private _getDrawingPopupMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) {
const drawing = this._docDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId });
const floatingToolbarMenuItems = drawing
? this._floatingToolbarAdapterService.getItems({ unitId, subUnitId, drawing })
: null;
const editCommandInfo = drawing
? this._drawingAdapterService.getEditDrawingCommandInfo({ unitId, subUnitId, drawing })
: null;
const defaultItems = [
{
label: editCommandInfo?.label ?? 'docs-drawing-ui.image-popup.edit',
index: 0,
commandId: editCommandInfo?.commandId ?? EditDocDrawingOperation.id,
commandParams: editCommandInfo?.commandParams ?? { unitId, subUnitId, drawingId },
disable: editCommandInfo?.disable ?? drawingType === DrawingTypeEnum.DRAWING_DOM,
hideOnClick: true,
icon: 'DrawingEditIcon',
},
{
label: 'docs-drawing-ui.image-popup.crop',
index: 1,
commandId: OpenImageCropOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
icon: 'DrawingCropIcon',
},
{
label: 'docs-drawing-ui.image-popup.delete',
index: 2,
commandId: RemoveDocDrawingCommand.id,
commandParams: { unitId, drawings: [{ unitId, subUnitId, drawingId }] },
disable: false,
icon: 'DrawingDeleteIcon',
},
{
label: 'docs-drawing-ui.image-popup.reset',
index: 3,
commandId: ImageResetSizeOperation.id,
commandParams: [{ unitId, subUnitId, drawingId }],
disable: true, // TODO: @JOCS, feature is not ready.
},
];
return [...(floatingToolbarMenuItems ?? defaultItems), ...this._getFloatingObjectMenuItems()];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.DOC)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title || typeof item.icon !== 'string') {
return [];
}
return [{
type: 'button' as const,
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
icon: item.icon,
}];
});
}
}

View File

@@ -0,0 +1,98 @@
/**
* 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 } from '@univerjs/core';
import type { IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { Observable } from 'rxjs';
import { InsertDocImageCommand } from '../commands/commands/insert-image.command';
export const DOCS_IMAGE_MENU_ID = 'doc.menu.image';
export const IMAGE_MENU_UPLOAD_FLOAT_ID = InsertDocImageCommand.id;
// TODO: @Jocs, remove this when cell support drawing.
const getDisableWhenSelectionInTableObservable = (accessor: IAccessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
return new Observable<boolean>((subscriber) => {
const observable = docSelectionManagerService.textSelection$.subscribe(() => {
const activeRange = docSelectionManagerService.getActiveTextRange();
if (activeRange) {
const { segmentId, startOffset, endOffset } = activeRange;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const tables = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.tables;
if (tables && tables.length) {
if (tables.some((table) => {
const { startIndex, endIndex } = table;
return (startOffset >= startIndex && startOffset < endIndex) || (endOffset >= startIndex && endOffset < endIndex);
})) {
subscriber.next(true);
return;
}
}
} else {
subscriber.next(true);
return;
}
subscriber.next(false);
});
return () => observable.unsubscribe();
});
};
export function ImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: DOCS_IMAGE_MENU_ID,
commandId: IMAGE_MENU_UPLOAD_FLOAT_ID,
type: MenuItemType.BUTTON,
icon: 'AddImageIcon',
tooltip: 'docs-drawing-ui.title',
disabled$: getDisableWhenSelectionInTableObservable(accessor),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function UploadFloatImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: IMAGE_MENU_UPLOAD_FLOAT_ID,
title: 'docs-drawing-ui.upload.float',
type: MenuItemType.BUTTON,
icon: 'AddImageIcon',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function UploadFloatImageBelowMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: `${IMAGE_MENU_UPLOAD_FLOAT_ID}.below`,
commandId: IMAGE_MENU_UPLOAD_FLOAT_ID,
title: 'docs-drawing-ui.upload.float',
type: MenuItemType.BUTTON,
icon: 'AddImageIcon',
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}

View File

@@ -0,0 +1,122 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { DOC_CONTENT_INSERT_MENU_ID, DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID, DOC_PARAGRAPH_T_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
import {
DOCS_IMAGE_MENU_ID,
IMAGE_MENU_UPLOAD_FLOAT_ID,
ImageMenuFactory,
UploadFloatImageBelowMenuFactory,
UploadFloatImageMenuFactory,
} from './image.menu';
import {
DOCS_SHAPE_BELOW_MENU_ID,
DOCS_SHAPE_MENU_ID,
InsertEllipseShapeBelowMenuFactory,
InsertEllipseShapeMenuFactory,
InsertRectangleShapeBelowMenuFactory,
InsertRectangleShapeMenuFactory,
ShapeBelowMenuFactory,
ShapeMenuFactory,
} from './shape.menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[DOCS_IMAGE_MENU_ID]: {
order: 0,
menuItemFactory: ImageMenuFactory,
},
},
[ContextMenuPosition.PARAGRAPH]: {
[ContextMenuGroup.LAYOUT]: {
[INSERT_BELLOW_MENU_ID]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
[EMPTY_PARAGRAPH_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
[DOC_CONTENT_INSERT_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
[DOC_PARAGRAPH_T_INSERT_MENU_ID]: {
insert: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 1,
menuItemFactory: UploadFloatImageMenuFactory,
},
[DOCS_SHAPE_MENU_ID]: {
order: 2,
menuItemFactory: ShapeMenuFactory,
},
},
},
[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID]: {
insert: {
[`${IMAGE_MENU_UPLOAD_FLOAT_ID}.below`]: {
order: 1,
menuItemFactory: UploadFloatImageBelowMenuFactory,
},
[DOCS_SHAPE_BELOW_MENU_ID]: {
order: 2,
menuItemFactory: ShapeBelowMenuFactory,
},
},
},
[DOCS_SHAPE_MENU_ID]: {
shapes: {
order: 0,
[InsertDocRectangleShapeCommand.id]: {
order: 0,
menuItemFactory: InsertRectangleShapeMenuFactory,
},
[InsertDocEllipseShapeCommand.id]: {
order: 1,
menuItemFactory: InsertEllipseShapeMenuFactory,
},
},
},
[DOCS_SHAPE_BELOW_MENU_ID]: {
shapes: {
order: 0,
[`${InsertDocRectangleShapeCommand.id}.below`]: {
order: 0,
menuItemFactory: InsertRectangleShapeBelowMenuFactory,
},
[`${InsertDocEllipseShapeCommand.id}.below`]: {
order: 1,
menuItemFactory: InsertEllipseShapeBelowMenuFactory,
},
},
},
},
};

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import {
InsertDocEllipseShapeCommand,
InsertDocRectangleShapeCommand,
} from '../commands/commands/insert-shape.command';
export const DOCS_SHAPE_MENU_ID = 'doc.command.menu-insert-shape';
export const DOCS_SHAPE_BELOW_MENU_ID = 'doc.command.menu-insert-shape.below';
export function ShapeMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: DOCS_SHAPE_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'ShapeIcon',
title: 'docs-drawing-ui.shape.insert.title',
tooltip: 'docs-drawing-ui.shape.insert.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function ShapeBelowMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: DOCS_SHAPE_BELOW_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'ShapeIcon',
title: 'docs-drawing-ui.shape.insert.title',
tooltip: 'docs-drawing-ui.shape.insert.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertRectangleShapeMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: InsertDocRectangleShapeCommand.id,
title: 'docs-drawing-ui.shape.insert.rectangle',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertEllipseShapeMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: InsertDocEllipseShapeCommand.id,
title: 'docs-drawing-ui.shape.insert.ellipse',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertRectangleShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: `${InsertDocRectangleShapeCommand.id}.below`,
commandId: InsertDocRectangleShapeCommand.id,
title: 'docs-drawing-ui.shape.insert.rectangle',
type: MenuItemType.BUTTON,
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertEllipseShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: `${InsertDocEllipseShapeCommand.id}.below`,
commandId: InsertDocEllipseShapeCommand.id,
title: 'docs-drawing-ui.shape.insert.ellipse',
type: MenuItemType.BUTTON,
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsDrawingUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDocsMobileUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingMobileUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY, DOCS_DRAWING_UI_PLUGIN_NAME } from './config/config';
import { DocDrawingAddRemoveController } from './controllers/doc-drawing-notification.controller';
import { DocDrawingPrintingController } from './controllers/doc-drawing-printing.controller';
import { DocDrawingTransformerController } from './controllers/doc-drawing-transformer-update.controller';
import { DocFloatDomController } from './controllers/doc-float-dom.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { MobileDocDrawingPopupMenuController } from './controllers/mobile/drawing-popup-menu.controller';
import { MobileDocDrawingUIController } from './controllers/mobile/ui.controller';
import { DocDrawingTransformUpdateController } from './controllers/render-controllers/doc-drawing-transform-update.controller';
import { DocDrawingUpdateRenderController } from './controllers/render-controllers/doc-drawing-update.render-controller';
import { MobileDocDrawingUpdateRenderController } from './controllers/render-controllers/mobile/doc-drawing-update.render-controller';
import { DocDrawingFloatingToolbarAdapterService } from './services/doc-drawing-floating-toolbar-adapter.service';
import { DocRefreshDrawingsService } from './services/doc-refresh-drawings.service';
@DependentOn(
UniverDocsPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsDrawingPlugin,
UniverDocsMobileUIPlugin,
UniverDrawingMobileUIPlugin
)
export class UniverDocsDrawingMobileUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_DOC;
static override pluginName = DOCS_DRAWING_UI_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDocsDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[MobileComponentsController],
[MobileDocDrawingUIController],
[MobileDocDrawingPopupMenuController],
[DocDrawingTransformerController],
[DocDrawingAddRemoveController],
[DocRefreshDrawingsService],
[DocDrawingFloatingToolbarAdapterService],
[DocFloatDomController],
[DocDrawingPrintingController],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
this._injector.get(MobileComponentsController);
}
override onReady(): void {
([
[DocDrawingUpdateRenderController, { useClass: MobileDocDrawingUpdateRenderController }],
[DocDrawingTransformUpdateController],
] as Dependency[]).forEach((m) => this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, m));
this._injector.get(DocDrawingAddRemoveController);
this._injector.get(MobileDocDrawingUIController);
this._injector.get(DocDrawingTransformerController);
this._injector.get(DocDrawingPrintingController);
}
override onRendered(): void {
this._injector.get(MobileDocDrawingPopupMenuController);
this._injector.get(DocFloatDomController);
}
}

View File

@@ -0,0 +1,108 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsDrawingUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY, DOCS_DRAWING_UI_PLUGIN_NAME } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DocDrawingAddRemoveController } from './controllers/doc-drawing-notification.controller';
import { DocDrawingPrintingController } from './controllers/doc-drawing-printing.controller';
import { DocDrawingTransformerController } from './controllers/doc-drawing-transformer-update.controller';
import { DocFloatDomController } from './controllers/doc-float-dom.controller';
import {
DocDrawingTransformUpdateController,
} from './controllers/render-controllers/doc-drawing-transform-update.controller';
import {
DocDrawingUpdateRenderController,
} from './controllers/render-controllers/doc-drawing-update.render-controller';
import { DocDrawingUIController } from './controllers/ui.controller';
import { DocDrawingPopupMenuController } from './menu/drawing-popup-menu.controller';
import { DocDrawingFloatingToolbarAdapterService } from './services/doc-drawing-floating-toolbar-adapter.service';
import { DocRefreshDrawingsService } from './services/doc-refresh-drawings.service';
@DependentOn(
UniverDocsPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsDrawingPlugin,
UniverDocsUIPlugin,
UniverDrawingUIPlugin
)
export class UniverDocsDrawingUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_DOC;
static override pluginName = DOCS_DRAWING_UI_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDocsDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(DOCS_DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[ComponentsController],
[DocDrawingUIController],
[DocDrawingPopupMenuController],
[DocDrawingTransformerController],
[DocDrawingAddRemoveController],
[DocRefreshDrawingsService],
[DocDrawingFloatingToolbarAdapterService],
[DocFloatDomController],
[DocDrawingPrintingController],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
this._injector.get(ComponentsController);
}
override onReady(): void {
([
[DocDrawingUpdateRenderController],
[DocDrawingTransformUpdateController],
] as Dependency[]).forEach((m) => this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, m));
this._injector.get(DocDrawingAddRemoveController);
this._injector.get(DocDrawingUIController);
this._injector.get(DocDrawingTransformerController);
this._injector.get(DocDrawingPrintingController);
}
override onRendered(): void {
this._injector.get(DocDrawingPopupMenuController);
this._injector.get(DocFloatDomController);
}
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { DocDrawingFloatingToolbarAdapterService } from '../doc-drawing-floating-toolbar-adapter.service';
describe('DocDrawingFloatingToolbarAdapterService', () => {
it('returns the first matching adapter items sorted by index', () => {
const service = new DocDrawingFloatingToolbarAdapterService();
service.registerAdapter({
getItems: () => null,
});
service.registerAdapter({
getItems: () => [
{
label: 'delete',
index: 2,
commandId: 'delete-command',
disable: false,
},
{
label: 'edit',
index: 1,
commandId: 'edit-command',
disable: false,
},
],
});
expect(service.getItems({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawing: { drawingId: 'drawing-1' } as never,
})?.map((item) => item.commandId)).toEqual(['edit-command', 'delete-command']);
});
it('unregisters disposed adapters', () => {
const service = new DocDrawingFloatingToolbarAdapterService();
const disposable = service.registerAdapter({
getItems: () => [{
label: 'edit',
index: 0,
commandId: 'edit-command',
disable: false,
}],
});
disposable.dispose();
expect(service.getItems({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawing: { drawingId: 'drawing-1' } as never,
})).toBeNull();
});
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { DocRefreshDrawingsService } from '../doc-refresh-drawings.service';
function createService(): DocRefreshDrawingsService {
const injector = new Injector();
injector.add([DocRefreshDrawingsService]);
return injector.get(DocRefreshDrawingsService);
}
describe('DocRefreshDrawingsService', () => {
it('emits the latest document skeleton refresh request', () => {
const service = createService();
const values: unknown[] = [];
const sub = service.refreshDrawings$.subscribe((value) => values.push(value));
const skeleton = { id: 'skeleton-1' };
service.refreshDrawings(skeleton as never);
service.refreshDrawings(null);
expect(values).toEqual([null, skeleton, null]);
sub.unsubscribe();
});
});

View File

@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable } from '@univerjs/core';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { IValueOption } from '@univerjs/ui';
import { toDisposable } from '@univerjs/core';
export interface IDocDrawingFloatingToolbarParams {
unitId: string;
subUnitId: string;
drawing: IDocDrawing;
}
export interface IDocDrawingFloatingToolbarOption {
icon?: string;
label: IValueOption['label'];
value: string;
}
interface IDocDrawingFloatingToolbarBaseItem {
label: string;
index: number;
commandId: string;
commandParams?: object;
disable: boolean;
hideOnClick?: boolean;
icon?: string;
}
export interface IDocDrawingFloatingToolbarButtonItem extends IDocDrawingFloatingToolbarBaseItem {
type?: 'button';
}
export interface IDocDrawingFloatingToolbarSelectItem extends IDocDrawingFloatingToolbarBaseItem {
type: 'select';
value: string;
options: IDocDrawingFloatingToolbarOption[];
commandParamsFactory: (value: string) => object;
}
export type IDocDrawingFloatingToolbarItem =
| IDocDrawingFloatingToolbarButtonItem
| IDocDrawingFloatingToolbarSelectItem;
export interface IDocDrawingFloatingToolbarAdapter {
getItems: (
params: IDocDrawingFloatingToolbarParams
) => readonly IDocDrawingFloatingToolbarItem[] | null | undefined;
}
export class DocDrawingFloatingToolbarAdapterService {
private readonly _adapters: IDocDrawingFloatingToolbarAdapter[] = [];
registerAdapter(adapter: IDocDrawingFloatingToolbarAdapter): IDisposable {
this._adapters.push(adapter);
return toDisposable(() => {
const index = this._adapters.indexOf(adapter);
if (index >= 0) {
this._adapters.splice(index, 1);
}
});
}
getItems(params: IDocDrawingFloatingToolbarParams): IDocDrawingFloatingToolbarItem[] | null {
for (const adapter of this._adapters) {
const items = adapter.getItems(params);
if (items) {
return [...items].sort((a, b) => a.index - b.index);
}
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { DocumentSkeleton } from '@univerjs/engine-render';
import { BehaviorSubject } from 'rxjs';
export class DocRefreshDrawingsService {
private readonly _refreshDrawings$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly refreshDrawings$ = this._refreshDrawings$.asObservable();
refreshDrawings(skeleton: Nullable<DocumentSkeleton>) {
this._refreshDrawings$.next(skeleton);
}
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IObjectPositionH, IObjectPositionV } from '@univerjs/core';
import type { IDocumentSkeletonDrawing, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage } from '@univerjs/engine-render';
import { ObjectRelativeFromH, ObjectRelativeFromV } from '@univerjs/core';
export interface IDrawingAnchorInPage {
skeDrawing: IDocumentSkeletonDrawing;
pageMarginTop: number;
pageMarginLeft: number;
}
export function findDrawingAnchorInPage(
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter,
drawingId: string,
pageMarginTop: number,
pageMarginLeft: number
): IDrawingAnchorInPage | null {
const skeDrawing = page.skeDrawings.get(drawingId);
if (skeDrawing) {
return { skeDrawing, pageMarginTop, pageMarginLeft };
}
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
for (const cell of row.cells) {
const cellAnchor = findDrawingAnchorInPage(cell, drawingId, cell.marginTop, cell.marginLeft);
if (cellAnchor) {
return cellAnchor;
}
}
}
}
return null;
}
export function resolveDrawingAnchorOffsets(
anchor: IDrawingAnchorInPage,
positionH: IObjectPositionH,
positionV: IObjectPositionV
): { horizontal: number; vertical: number } {
const { skeDrawing, pageMarginTop, pageMarginLeft } = anchor;
let horizontal = skeDrawing.aLeft;
let vertical = skeDrawing.aTop;
if (positionH.relativeFrom === ObjectRelativeFromH.MARGIN) {
horizontal -= pageMarginLeft;
} else if (positionH.relativeFrom === ObjectRelativeFromH.COLUMN) {
horizontal -= skeDrawing.columnLeft;
}
if (positionV.relativeFrom === ObjectRelativeFromV.PAGE) {
vertical += pageMarginTop;
} else if (positionV.relativeFrom === ObjectRelativeFromV.LINE) {
vertical -= skeDrawing.lineTop;
} else if (positionV.relativeFrom === ObjectRelativeFromV.PARAGRAPH) {
vertical -= skeDrawing.blockAnchorTop;
}
return { horizontal, vertical };
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { IDocFloatDom } from '@univerjs/docs-drawing';
import type { DocumentSkeleton, IBoundRectNoAngle, Scene } from '@univerjs/engine-render';
import type { IFloatDom, IFloatDomLayout } from '@univerjs/ui';
import type { ComponentType } from 'react';
import { render, unmount } from '@univerjs/design';
import { connectInjector, PrintFloatDomSingle } from '@univerjs/ui';
import { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import { calcDocFloatDomPositionByRect } from '../controllers/doc-float-dom.controller';
export interface IPrintingFloatDomProps {
floatDomInfos: IDocFloatDom[];
scene: Scene;
skeleton: DocumentSkeleton;
unitId: string;
offset: { x: number; y: number };
bound: IBoundRectNoAngle;
};
export const DocPrintingFloatDom = (props: IPrintingFloatDomProps) => {
const { floatDomInfos, scene, offset, bound } = props;
const width = bound.right - bound.left;
const height = bound.bottom - bound.top;
const floatDomParams = useMemo(() =>
floatDomInfos.map((info) => {
const { width = 0, height = 0, left = 0, top = 0 } = info.transform!;
const offsetBound = calcDocFloatDomPositionByRect(
{
left,
right: left + width,
top,
bottom: top + height,
},
scene
);
const domPos: IFloatDomLayout = offsetBound;
const floatDom: IFloatDom & { position: IFloatDomLayout } = {
position$: new BehaviorSubject<IFloatDomLayout>(domPos),
position: domPos,
id: info.drawingId,
componentKey: info.componentKey,
onPointerMove: () => { },
onPointerDown: () => { },
onPointerUp: () => { },
onWheel: () => { },
unitId: info.unitId,
data: info.data,
};
return [info.drawingId, floatDom] as const;
})
.filter(([_, floatDom]) => !(floatDom.position.endX < 0 || floatDom.position.endY < 0 || floatDom.position.startX > width || floatDom.position.startY > height)), [floatDomInfos, scene, offset, width, height]);
return (
<div className="univer-absolute univer-left-0 univer-top-0">
{floatDomParams.map(([id, floatDom]) => (
<PrintFloatDomSingle key={id} layer={floatDom} id={id} position={floatDom.position} />
))}
</div>
);
};
export function mountDocPrintingFloatDom(props: IPrintingFloatDomProps, root: HTMLElement, injector: Injector): () => void {
const PrintingFloatDomInjector = connectInjector(DocPrintingFloatDom, injector) as ComponentType<IPrintingFloatDomProps>;
render(<PrintingFloatDomInjector {...props} />, root);
return () => unmount(root);
}

View File

@@ -0,0 +1,198 @@
/**
* 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 } from '@univerjs/core';
import type { IDocFloatDom } from '@univerjs/docs-drawing';
import type { IBoundRectNoAngle, Scene } from '@univerjs/engine-render';
import { ComponentManager } from '@univerjs/ui';
import { act } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { createDocUiTestBed } from '../../__tests__/create-doc-ui-test-bed';
import { mountDocPrintingFloatDom } from '../DocPrintingFloatDom';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'doc-printing-float-dom-doc';
const PRINTING_COMPONENT_KEY = 'printing-visible-content';
class TestScene {
constructor(
private readonly _scroll = { x: 0, y: 0 },
private readonly _scale = { x: 1, y: 1 }
) {
}
getViewport() {
return {
viewportScrollX: this._scroll.x,
viewportScrollY: this._scroll.y,
};
}
getAncestorScale() {
return {
scaleX: this._scale.x,
scaleY: this._scale.y,
};
}
}
function PrintingVisibleContent(props: { data?: { label?: string } }) {
return <span>{props.data?.label}</span>;
}
function createDocData(): IDocumentData {
return {
id: UNIT_ID,
body: {
dataStream: '\r\n',
},
documentStyle: {
pageSize: {
width: 200,
height: 100,
},
marginTop: 0,
marginBottom: 0,
marginRight: 0,
marginLeft: 0,
},
};
}
function createFloatDom(drawingId: string, label: string, bound: IBoundRectNoAngle): IDocFloatDom {
return {
drawingId,
unitId: UNIT_ID,
componentKey: PRINTING_COMPONENT_KEY,
transform: {
left: bound.left,
top: bound.top,
width: bound.right - bound.left,
height: bound.bottom - bound.top,
},
data: { label },
} as unknown as IDocFloatDom;
}
describe('DocPrintingFloatDom', () => {
let root: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createDocUiTestBed> | undefined;
let disposePrinting: (() => void) | undefined;
afterEach(() => {
act(() => {
disposePrinting?.();
});
root?.remove();
currentTestBed?.univer.dispose();
root = undefined;
currentTestBed = undefined;
disposePrinting = undefined;
});
it('prints only floating DOM content whose transformed bounds intersect the page bound', () => {
currentTestBed = createDocUiTestBed(createDocData(), [
[ComponentManager],
]);
currentTestBed.injector.get(ComponentManager).register(PRINTING_COMPONENT_KEY, PrintingVisibleContent);
root = document.createElement('div');
document.body.appendChild(root);
act(() => {
disposePrinting = mountDocPrintingFloatDom({
floatDomInfos: [
createFloatDom('inside-image', 'Inside printable page', { left: 20, top: 10, right: 70, bottom: 40 }),
createFloatDom('outside-image', 'Outside printable page', { left: 240, top: 10, right: 300, bottom: 40 }),
],
scene: new TestScene() as unknown as Scene,
skeleton: {} as never,
unitId: UNIT_ID,
offset: { x: 0, y: 0 },
bound: { left: 0, top: 0, right: 200, bottom: 100 },
}, root!, currentTestBed!.injector);
});
expect(root.textContent).toContain('Inside printable page');
expect(root.textContent).not.toContain('Outside printable page');
act(() => {
disposePrinting?.();
disposePrinting = undefined;
});
expect(root.textContent).toBe('');
});
it('prints floating DOM content based on viewport scroll and ancestor scale', () => {
currentTestBed = createDocUiTestBed(createDocData(), [
[ComponentManager],
]);
currentTestBed.injector.get(ComponentManager).register(PRINTING_COMPONENT_KEY, PrintingVisibleContent);
root = document.createElement('div');
document.body.appendChild(root);
act(() => {
disposePrinting = mountDocPrintingFloatDom({
floatDomInfos: [
createFloatDom('visible-after-scroll', 'Visible after scroll and zoom', { left: 110, top: 60, right: 130, bottom: 80 }),
createFloatDom('before-scroll', 'Before scrolled viewport', { left: 0, top: 60, right: 20, bottom: 80 }),
createFloatDom('below-scaled-page', 'Below scaled page', { left: 120, top: 140, right: 150, bottom: 170 }),
],
scene: new TestScene({ x: 100, y: 50 }, { x: 2, y: 1.5 }) as unknown as Scene,
skeleton: {} as never,
unitId: UNIT_ID,
offset: { x: 0, y: 0 },
bound: { left: 0, top: 0, right: 200, bottom: 100 },
}, root!, currentTestBed!.injector);
});
expect(root.textContent).toContain('Visible after scroll and zoom');
expect(root.textContent).not.toContain('Before scrolled viewport');
expect(root.textContent).not.toContain('Below scaled page');
});
it('prints floating DOM content that partially intersects the page edge', () => {
currentTestBed = createDocUiTestBed(createDocData(), [
[ComponentManager],
]);
currentTestBed.injector.get(ComponentManager).register(PRINTING_COMPONENT_KEY, PrintingVisibleContent);
root = document.createElement('div');
document.body.appendChild(root);
act(() => {
disposePrinting = mountDocPrintingFloatDom({
floatDomInfos: [
createFloatDom('partially-visible-left', 'Partially visible on left edge', { left: -20, top: 10, right: 20, bottom: 40 }),
createFloatDom('partially-visible-bottom', 'Partially visible on bottom edge', { left: 40, top: 80, right: 80, bottom: 120 }),
createFloatDom('fully-before-page', 'Fully before printable page', { left: -60, top: 10, right: -10, bottom: 40 }),
],
scene: new TestScene() as unknown as Scene,
skeleton: {} as never,
unitId: UNIT_ID,
offset: { x: 0, y: 0 },
bound: { left: 0, top: 0, right: 200, bottom: 100 },
}, root!, currentTestBed!.injector);
});
expect(root.textContent).toContain('Partially visible on left edge');
expect(root.textContent).toContain('Partially visible on bottom edge');
expect(root.textContent).not.toContain('Fully before printable page');
});
});

View File

@@ -0,0 +1,39 @@
/**
* 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 { IDrawingManagerService } from '@univerjs/drawing';
import { DrawingCommonPanel } from '@univerjs/drawing-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { DocDrawingPosition } from './DocDrawingPosition';
import { DocDrawingTextWrap } from './DocDrawingTextWrap';
export const DocDrawingPanel = () => {
const drawingManagerService = useDependency(IDrawingManagerService);
const drawings = useObservable(
() => drawingManagerService.focus$,
drawingManagerService.getFocusDrawings(),
false,
[drawingManagerService]
);
return !!drawings?.length && (
<div className="univer-text-sm">
<DrawingCommonPanel drawings={drawings} hasAlign={false} hasCropper hasGroup={false} hasTransform={false} />
<DocDrawingTextWrap drawings={drawings} />
<DocDrawingPosition drawings={drawings} />
</div>
);
};

View File

@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { Checkbox, clsx, InputNumber, Select } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { useDocDrawingPosition } from './use-doc-drawing-position';
export interface IDocDrawingPositionProps {
drawings: IDrawingParam[];
}
export const DocDrawingPosition = (props: IDocDrawingPositionProps) => {
const renderManagerService = useDependency(IRenderManagerService);
const drawingParam = props.drawings[0];
const scene = drawingParam ? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene : undefined;
return drawingParam && scene ? <DocDrawingPositionContent {...props} /> : null;
};
function DocDrawingPositionContent(props: IDocDrawingPositionProps) {
const { localeService, showPanel, disabled, hPosition, vPosition, followTextMove, HORIZONTAL_RELATIVE_FROM, VERTICAL_RELATIVE_FROM, handlePositionChange, handleHorizontalRelativeFromChange, handleVerticalRelativeFromChange, handleFollowTextMoveCheck, MIN_OFFSET, MAX_OFFSET } = useDocDrawingPosition(props);
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !showPanel,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.title')}</div>
</header>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.horizontal')}</div>
</div>
<div
className={`
univer-grid univer-grid-cols-2 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.absolutePosition')}</span>
<InputNumber
min={MIN_OFFSET}
max={MAX_OFFSET}
precision={1}
disabled={disabled}
value={hPosition.posOffset}
onChange={(val) => {
if (val == null) {
return;
}
handlePositionChange('positionH', {
relativeFrom: hPosition.relativeFrom,
posOffset: val,
});
}}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.toTheRightOf')}</span>
<Select
value={String(hPosition.relativeFrom)}
disabled={disabled}
options={HORIZONTAL_RELATIVE_FROM}
onChange={handleHorizontalRelativeFromChange}
/>
</div>
</div>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.vertical')}</div>
</div>
<div
className={`
univer-grid univer-grid-cols-2 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.absolutePosition')}</span>
<InputNumber
min={MIN_OFFSET}
max={MAX_OFFSET}
precision={1}
disabled={disabled}
value={vPosition.posOffset}
onChange={(val) => {
if (val == null) {
return;
}
handlePositionChange('positionV', {
relativeFrom: vPosition.relativeFrom,
posOffset: val,
});
}}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.bellow')}</span>
<Select
disabled={disabled}
value={String(vPosition.relativeFrom)}
options={VERTICAL_RELATIVE_FROM}
onChange={handleVerticalRelativeFromChange}
/>
</div>
</div>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-position.options')}</div>
</div>
<div>
<Checkbox
disabled={disabled}
checked={followTextMove}
onChange={handleFollowTextMoveCheck}
>
{localeService.t<LocaleKey>('docs-drawing-ui.image-position.moveObjectWithText')}
</Checkbox>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import {
WrapTextType,
} from '@univerjs/core';
import { clsx, InputNumber, Radio, RadioGroup } from '@univerjs/design';
import { TextWrappingStyle } from '@univerjs/docs-drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { useDocDrawingTextWrap } from './use-doc-drawing-text-wrap';
export interface IDocDrawingTextWrapProps {
drawings: IDrawingParam[];
}
export const DocDrawingTextWrap = (props: IDocDrawingTextWrapProps) => {
const renderManagerService = useDependency(IRenderManagerService);
const drawingParam = props.drawings[0];
const scene = drawingParam ? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene : undefined;
return drawingParam && scene ? <DocDrawingTextWrapContent {...props} /> : null;
};
function DocDrawingTextWrapContent(props: IDocDrawingTextWrapProps) {
const { localeService, showPanel, wrappingStyle, wrapText, distToText, disableWrapText, disableDistTB, disableDistLR, handleWrappingStyleChange, handleWrapTextChange, handleDistToTextChange, MIN_MARGIN, MAX_MARGIN } = useDocDrawingTextWrap(props);
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !showPanel,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.title')}</div>
</header>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.wrappingStyle')}</div>
</div>
<div>
<RadioGroup value={wrappingStyle} onChange={handleWrappingStyleChange} direction="vertical">
<Radio value={TextWrappingStyle.INLINE}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.inline')}</Radio>
<Radio value={TextWrappingStyle.WRAP_SQUARE}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.square')}</Radio>
<Radio value={TextWrappingStyle.WRAP_TOP_AND_BOTTOM}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.topAndBottom')}</Radio>
<Radio value={TextWrappingStyle.BEHIND_TEXT}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.behindText')}</Radio>
<Radio value={TextWrappingStyle.IN_FRONT_OF_TEXT}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.inFrontText')}</Radio>
</RadioGroup>
</div>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.wrapText')}</div>
</div>
<div>
<RadioGroup disabled={disableWrapText} value={String(wrapText)} onChange={handleWrapTextChange} direction="horizontal">
<Radio value={String(WrapTextType.BOTH_SIDES)}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.bothSide')}</Radio>
<Radio value={String(WrapTextType.LEFT)}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.leftOnly')}</Radio>
<Radio value={String(WrapTextType.RIGHT)}>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.rightOnly')}</Radio>
</RadioGroup>
</div>
<div
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.distanceFromText')}</div>
</div>
<div
className={`
univer-grid univer-grid-cols-2 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.top')}</span>
<InputNumber
min={MIN_MARGIN}
max={MAX_MARGIN}
disabled={disableDistTB}
precision={1}
value={distToText.distT}
onChange={(val) => { handleDistToTextChange(val, 'distT'); }}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.left')}</span>
<InputNumber
min={MIN_MARGIN}
max={MAX_MARGIN}
disabled={disableDistLR}
precision={1}
value={distToText.distL}
onChange={(val) => { handleDistToTextChange(val, 'distL'); }}
/>
</div>
</div>
<div
className={`
univer-grid univer-grid-cols-2 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.bottom')}</span>
<InputNumber
min={MIN_MARGIN}
max={MAX_MARGIN}
disabled={disableDistTB}
precision={1}
value={distToText.distB}
onChange={(val) => { handleDistToTextChange(val, 'distB'); }}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.right')}</span>
<InputNumber
min={MIN_MARGIN}
max={MAX_MARGIN}
disabled={disableDistLR}
precision={1}
value={distToText.distR}
onChange={(val) => { handleDistToTextChange(val, 'distR'); }}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { MobileActionRow } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DrawingCommonPanel } from '@univerjs/drawing-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { useState } from 'react';
import { SidebarDocDrawingOperation } from '../../commands/operations/open-drawing-panel.operation';
import { MobileDocDrawingPosition } from './MobileDocDrawingPosition';
import { MobileDocDrawingTextWrap } from './MobileDocDrawingTextWrap';
const tabs: { value: 'image' | 'wrap' | 'position'; label: LocaleKey }[] = [
{ value: 'image', label: 'docs-drawing-ui.title' },
{ value: 'wrap', label: 'docs-drawing-ui.image-text-wrap.title' },
{ value: 'position', label: 'docs-drawing-ui.image-position.title' },
];
export function MobileDocDrawingPanel() {
const drawingManagerService = useDependency(IDrawingManagerService);
const drawings = useObservable(drawingManagerService.focus$, drawingManagerService.getFocusDrawings()) ?? [];
const localeService = useDependency(LocaleService);
const commandService = useDependency(ICommandService);
const [tab, setTab] = useState<'image' | 'wrap' | 'position'>('image');
return (
<div className="univer-flex univer-min-w-0 univer-flex-col univer-gap-4 univer-pb-2">
<div className="univer-grid univer-grid-cols-3 univer-gap-2" role="group">
{tabs.map((item) => (
<MobileActionRow
key={item.value}
aria-pressed={tab === item.value}
title={localeService.t<LocaleKey>(item.label)}
variant="subtle"
className="
univer-min-w-0 univer-px-2 univer-text-center univer-text-sm
aria-pressed:!univer-bg-primary-50 aria-pressed:!univer-text-primary-600
"
onClick={() => setTab(item.value)}
/>
))}
</div>
<div>
{tab === 'image' && (
<DrawingCommonPanel
drawings={drawings}
hasAlign={false}
hasCropper
hasGroup={false}
hasTransform={false}
onCropStart={() => commandService.executeCommand(SidebarDocDrawingOperation.id, { value: 'close' })}
/>
)}
{tab === 'wrap' && <MobileDocDrawingTextWrap drawings={drawings} />}
{tab === 'position' && <MobileDocDrawingPosition drawings={drawings} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
/**
* 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 } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import type { IDocDrawingPositionProps } from './DocDrawingPosition';
import { DocumentFlavor, LocaleService } from '@univerjs/core';
import { InputNumber, MobileActionRow } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { CheckMarkIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { useDocDrawingPosition } from './use-doc-drawing-position';
interface IMobileDocDrawingPositionProps {
disabled: boolean;
followTextMove: boolean;
disableFollowTextMove: boolean;
hPosition: IObjectPositionH;
vPosition: IObjectPositionV;
horizontalOptions: { label: string; value: string }[];
verticalOptions: { label: string; value: string; disabled?: boolean }[];
minOffset: number;
maxOffset: number;
onHorizontalOffsetChange: (value: number) => void;
onVerticalOffsetChange: (value: number) => void;
onHorizontalReferenceChange: (value: string) => void;
onVerticalReferenceChange: (value: string) => void;
onFollowTextMoveChange: (value: boolean) => void;
}
function MobileDocDrawingPositionControls(props: IMobileDocDrawingPositionProps) {
const localeService = useDependency(LocaleService);
const axes: {
title: LocaleKey;
reference: LocaleKey;
position: IObjectPositionH | IObjectPositionV;
options: { label: string; value: string; disabled?: boolean }[];
onOffsetChange: (value: number) => void;
onReferenceChange: (value: string) => void;
}[] = [
{
title: 'docs-drawing-ui.image-position.horizontal',
reference: 'docs-drawing-ui.image-position.toTheRightOf',
position: props.hPosition,
options: props.horizontalOptions,
onOffsetChange: props.onHorizontalOffsetChange,
onReferenceChange: props.onHorizontalReferenceChange,
},
{
title: 'docs-drawing-ui.image-position.vertical',
reference: 'docs-drawing-ui.image-position.bellow',
position: props.vPosition,
options: props.verticalOptions,
onOffsetChange: props.onVerticalOffsetChange,
onReferenceChange: props.onVerticalReferenceChange,
},
];
return (
<div className="univer-grid univer-gap-5">
{axes.map((axis) => (
<section key={axis.title} className="univer-grid univer-gap-3">
<h3 className="univer-m-0 univer-text-sm univer-font-medium">{localeService.t<LocaleKey>(axis.title)}</h3>
<label className="univer-grid univer-gap-2 univer-text-sm">
{localeService.t<LocaleKey>('docs-drawing-ui.image-position.absolutePosition')}
<InputNumber
disabled={props.disabled}
className="univer-h-12 univer-w-full"
inputClassName="[&_input]:!univer-h-12 [&_input]:!univer-text-base"
controls={false}
min={props.minOffset}
max={props.maxOffset}
precision={1}
value={axis.position.posOffset ?? 0}
onChange={(value) => {
if (value != null) {
axis.onOffsetChange(value);
}
}}
/>
</label>
<div className="univer-grid univer-grid-cols-2 univer-gap-2" role="group" aria-label={localeService.t<LocaleKey>(axis.reference)}>
{axis.options.map((option) => (
<MobileActionRow
key={option.value}
title={option.label}
variant="subtle"
disabled={props.disabled || option.disabled}
aria-pressed={String(axis.position.relativeFrom) === option.value}
className="
univer-px-3 univer-text-center univer-text-sm
aria-pressed:!univer-bg-primary-50 aria-pressed:!univer-text-primary-600
"
onClick={() => axis.onReferenceChange(option.value)}
/>
))}
</div>
</section>
))}
<MobileActionRow
role="switch"
title={localeService.t<LocaleKey>('docs-drawing-ui.image-position.moveObjectWithText')}
variant="subtle"
aria-checked={props.followTextMove}
disabled={props.disabled || props.disableFollowTextMove}
trailing={props.followTextMove ? <CheckMarkIcon className="univer-text-primary-600" /> : undefined}
onClick={() => props.onFollowTextMoveChange(!props.followTextMove)}
/>
</div>
);
}
export function MobileDocDrawingPosition(props: IDocDrawingPositionProps) {
const renderManagerService = useDependency(IRenderManagerService);
const drawing = props.drawings[0];
return drawing && renderManagerService.getRenderUnitById(drawing.unitId)?.scene
? <MobileDocDrawingPositionContent {...props} />
: null;
}
function MobileDocDrawingPositionContent(props: IDocDrawingPositionProps) {
const { showPanel, disabled, hPosition, vPosition, followTextMove, documentFlavor, HORIZONTAL_RELATIVE_FROM, VERTICAL_RELATIVE_FROM, handlePositionChange, handleHorizontalRelativeFromChange, handleVerticalRelativeFromChange, handleFollowTextMoveCheck, MIN_OFFSET, MAX_OFFSET } = useDocDrawingPosition(props);
return showPanel
? (
<MobileDocDrawingPositionControls
disabled={disabled}
followTextMove={followTextMove}
disableFollowTextMove={documentFlavor === DocumentFlavor.MODERN}
hPosition={hPosition}
vPosition={vPosition}
horizontalOptions={HORIZONTAL_RELATIVE_FROM}
verticalOptions={VERTICAL_RELATIVE_FROM}
minOffset={MIN_OFFSET}
maxOffset={MAX_OFFSET}
onHorizontalOffsetChange={(posOffset) => handlePositionChange('positionH', { ...hPosition, posOffset })}
onVerticalOffsetChange={(posOffset) => handlePositionChange('positionV', { ...vPosition, posOffset })}
onHorizontalReferenceChange={handleHorizontalRelativeFromChange}
onVerticalReferenceChange={handleVerticalRelativeFromChange}
onFollowTextMoveChange={handleFollowTextMoveCheck}
/>
)
: null;
}

View File

@@ -0,0 +1,155 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import type { IDocDrawingTextWrapProps } from './DocDrawingTextWrap';
import { LocaleService, WrapTextType } from '@univerjs/core';
import { InputNumber, MobileActionRow } from '@univerjs/design';
import { TextWrappingStyle } from '@univerjs/docs-drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { CheckMarkIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { useDocDrawingTextWrap } from './use-doc-drawing-text-wrap';
const styles: { value: TextWrappingStyle; label: LocaleKey }[] = [
{ value: TextWrappingStyle.INLINE, label: 'docs-drawing-ui.image-text-wrap.inline' },
{ value: TextWrappingStyle.WRAP_SQUARE, label: 'docs-drawing-ui.image-text-wrap.square' },
{ value: TextWrappingStyle.WRAP_TOP_AND_BOTTOM, label: 'docs-drawing-ui.image-text-wrap.topAndBottom' },
{ value: TextWrappingStyle.BEHIND_TEXT, label: 'docs-drawing-ui.image-text-wrap.behindText' },
{ value: TextWrappingStyle.IN_FRONT_OF_TEXT, label: 'docs-drawing-ui.image-text-wrap.inFrontText' },
];
const sides: { value: WrapTextType; label: LocaleKey }[] = [
{ value: WrapTextType.BOTH_SIDES, label: 'docs-drawing-ui.image-text-wrap.bothSide' },
{ value: WrapTextType.LEFT, label: 'docs-drawing-ui.image-text-wrap.leftOnly' },
{ value: WrapTextType.RIGHT, label: 'docs-drawing-ui.image-text-wrap.rightOnly' },
];
type DistanceKey = 'distT' | 'distB' | 'distL' | 'distR';
const distances: { key: DistanceKey; label: LocaleKey }[] = [
{ key: 'distT', label: 'docs-drawing-ui.image-text-wrap.top' },
{ key: 'distB', label: 'docs-drawing-ui.image-text-wrap.bottom' },
{ key: 'distL', label: 'docs-drawing-ui.image-text-wrap.left' },
{ key: 'distR', label: 'docs-drawing-ui.image-text-wrap.right' },
];
interface IMobileDocDrawingTextWrapProps {
wrappingStyle: TextWrappingStyle;
wrapText: WrapTextType;
distToText: Pick<IDocDrawingBase, DistanceKey>;
minMargin: number;
maxMargin: number;
disableWrapText: boolean;
disableDistTB: boolean;
disableDistLR: boolean;
onStyleChange: (value: TextWrappingStyle) => void;
onWrapTextChange: (value: WrapTextType) => void;
onDistanceChange: (value: number | null, direction: DistanceKey) => void;
}
function MobileDocDrawingTextWrapControls(props: IMobileDocDrawingTextWrapProps) {
const localeService = useDependency(LocaleService);
return (
<div className="univer-flex univer-flex-col univer-gap-5">
<div
role="group"
aria-label={localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.wrappingStyle')}
className="univer-grid univer-gap-2"
>
{styles.map((option) => (
<MobileActionRow
key={option.value}
title={localeService.t<LocaleKey>(option.label)}
variant="subtle"
aria-pressed={props.wrappingStyle === option.value}
className="aria-pressed:!univer-bg-primary-50 aria-pressed:!univer-text-primary-600"
trailing={props.wrappingStyle === option.value ? <CheckMarkIcon /> : undefined}
onClick={() => props.onStyleChange(option.value)}
/>
))}
</div>
{!props.disableWrapText && (
<section className="univer-grid univer-gap-2">
<h3 className="univer-m-0 univer-text-sm univer-font-medium">{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.wrapText')}</h3>
<div className="univer-grid univer-grid-cols-3 univer-gap-2">
{sides.map((option) => (
<MobileActionRow
key={option.value}
title={localeService.t<LocaleKey>(option.label)}
variant="subtle"
aria-pressed={props.wrapText === option.value}
className="
univer-px-2 univer-text-center univer-text-sm
aria-pressed:!univer-bg-primary-50 aria-pressed:!univer-text-primary-600
"
onClick={() => props.onWrapTextChange(option.value)}
/>
))}
</div>
</section>
)}
{(!props.disableDistTB || !props.disableDistLR) && (
<section className="univer-grid univer-gap-3">
<h3 className="univer-m-0 univer-text-sm univer-font-medium">{localeService.t<LocaleKey>('docs-drawing-ui.image-text-wrap.distanceFromText')}</h3>
<div className="univer-grid univer-grid-cols-2 univer-gap-3">
{distances.filter(({ key }) => (key === 'distT' || key === 'distB') ? !props.disableDistTB : !props.disableDistLR).map(({ key, label }) => (
<label key={key} className="univer-grid univer-gap-2 univer-text-sm">
{localeService.t<LocaleKey>(label)}
<InputNumber
className="univer-h-12 univer-w-full"
inputClassName="[&_input]:!univer-h-12 [&_input]:!univer-text-base"
controls={false}
min={props.minMargin}
max={props.maxMargin}
precision={1}
value={props.distToText[key] ?? 0}
onChange={(value) => props.onDistanceChange(value, key)}
/>
</label>
))}
</div>
</section>
)}
</div>
);
}
export function MobileDocDrawingTextWrap(props: IDocDrawingTextWrapProps) {
const renderManagerService = useDependency(IRenderManagerService);
const drawing = props.drawings[0];
return drawing && renderManagerService.getRenderUnitById(drawing.unitId)?.scene
? <MobileDocDrawingTextWrapContent {...props} />
: null;
}
function MobileDocDrawingTextWrapContent(props: IDocDrawingTextWrapProps) {
const { showPanel, wrappingStyle, wrapText, distToText, disableWrapText, disableDistTB, disableDistLR, handleWrappingStyleChange, handleWrapTextChange, handleDistToTextChange, MIN_MARGIN, MAX_MARGIN } = useDocDrawingTextWrap(props);
return showPanel
? (
<MobileDocDrawingTextWrapControls
wrappingStyle={wrappingStyle}
wrapText={wrapText}
distToText={distToText}
minMargin={MIN_MARGIN}
maxMargin={MAX_MARGIN}
disableWrapText={disableWrapText}
disableDistTB={disableDistTB}
disableDistLR={disableDistLR}
onStyleChange={handleWrappingStyleChange}
onWrapTextChange={handleWrapTextChange}
onDistanceChange={handleDistToTextChange}
/>
)
: null;
}

View File

@@ -0,0 +1,467 @@
/**
* 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 { DependencyIdentifier, DocumentDataModel, ICommand, IDocumentData } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import { within } from '@testing-library/react';
import {
DocumentFlavor,
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocDrawingController, DocDrawingService, IDocDrawingService } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { createDocUiTestBed } from '../../../__tests__/create-doc-ui-test-bed';
import locale from '../../../locale/en-US';
import { DocDrawingPosition } from '../DocDrawingPosition';
import { MobileDocDrawingPosition } from '../MobileDocDrawingPosition';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'doc-drawing-position-doc';
const DRAWING_ID = 'image-1';
class TestDocSkeletonManagerService {
private readonly _page = { marginTop: 72 };
private readonly _column = {
lines: [] as Array<{
top: number;
paragraphIndex: number;
paragraphStart?: boolean;
}>,
parent: {
parent: this._page,
},
};
private readonly _paragraphStartLine = {
top: 20,
paragraphIndex: 1,
paragraphStart: true,
};
private readonly _line = {
top: 44,
paragraphIndex: 1,
parent: this._column,
};
private readonly _glyph = {
parent: {
parent: this._line,
},
};
constructor() {
this._column.lines.push(this._paragraphStartLine, this._line);
}
getSkeleton() {
return {
findNodeByCharIndex: () => this._glyph,
getSkeletonData: () => ({
pages: [{
marginTop: 72,
marginLeft: 90,
marginBottom: 72,
pageWidth: 594,
pageHeight: 840,
headerId: '',
footerId: '',
skeDrawings: new Map([
[DRAWING_ID, {
drawingId: DRAWING_ID,
aLeft: 24,
aTop: 42,
columnLeft: 8,
lineTop: 10,
blockAnchorTop: 12,
}],
]),
skeTables: new Map(),
}],
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
}
getViewModel() {
return {
getEditArea: () => DocumentEditArea.BODY,
reset: () => undefined,
};
}
}
class TestDocSelectionRenderService {
getSegment() {
return '';
}
getSegmentPage() {
return 0;
}
blur() {
return undefined;
}
}
class TestScene {
getTransformerByCreate() {
return {
refreshControls: () => undefined,
};
}
}
class TestRenderManagerService {
private readonly _scene = new TestScene();
private readonly _skeletonManager = new TestDocSkeletonManagerService();
private readonly _selectionRenderService = new TestDocSelectionRenderService();
private _render: unknown;
addRender(_unitId: string, render: unknown) {
this._render = render;
}
private _getRender() {
if (this._render == null) {
return null;
}
return {
...(this._render as object),
scene: this._scene,
with: <T,>(token: DependencyIdentifier<T>) => {
if (token === DocSkeletonManagerService) {
return this._skeletonManager as T;
}
if (token === DocSelectionRenderService) {
return this._selectionRenderService as T;
}
throw new Error(`Unexpected render dependency: ${String(token)}`);
},
};
}
getRenderUnitById(_unitId: string) {
return this._getRender();
}
}
function createDocData(): IDocumentData {
return {
id: UNIT_ID,
body: {
dataStream: '\b\r\n',
customBlocks: [{
startIndex: 0,
blockId: DRAWING_ID,
}],
},
drawings: {
[DRAWING_ID]: {
drawingId: DRAWING_ID,
unitId: UNIT_ID,
subUnitId: UNIT_ID,
drawingType: 'image',
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
wrapText: WrapTextType.BOTH_SIDES,
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 12,
},
},
} as never,
},
drawingsOrder: [DRAWING_ID],
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createPositionTestBed() {
const testBed = createDocUiTestBed(createDocData(), [
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const { injector } = testBed;
injector.add([DocDrawingService]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([DocDrawingController]);
const commandService = injector.get(ICommandService);
[
RichTextEditingMutation as unknown as ICommand,
].forEach((command) => commandService.registerCommand(command));
injector.get(LocaleService).load({
[LocaleType.EN_US]: locale,
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
injector.get(DocDrawingController).loadDrawingDataForUnit(UNIT_ID);
injector.get(IDrawingManagerService).focusDrawing([{
unitId: UNIT_ID,
subUnitId: UNIT_ID,
drawingId: DRAWING_ID,
}]);
return testBed;
}
function renderPanel(root: Root, testBed: ReturnType<typeof createPositionTestBed>, mobile = false) {
const Panel = mobile ? MobileDocDrawingPosition : DocDrawingPosition;
const drawing = testBed.doc.getSnapshot().drawings![DRAWING_ID];
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<ConfigProvider mountContainer={document.body}>
<Panel drawings={[drawing as never]} />
</ConfigProvider>
</RediContext.Provider>
);
});
}
function currentDrawing(testBed: ReturnType<typeof createPositionTestBed>) {
return testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(UNIT_ID, UniverInstanceType.UNIVER_DOC)!
.getSnapshot()
.drawings![DRAWING_ID];
}
function getNumberInput(container: HTMLElement, index: number): HTMLInputElement {
return container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input')[index];
}
function getMoveWithTextInput(container: HTMLElement): HTMLInputElement {
return container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!;
}
function setInputValue(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
async function selectPositionRelativeFrom(container: HTMLDivElement, selectIndex: number, optionIndex: number) {
const select = Array.from(container.querySelectorAll('[data-u-comp="select"]'))[selectIndex] as HTMLElement | undefined;
expect(select).toBeDefined();
await act(async () => {
select!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
await Promise.resolve();
});
const option = document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"]')[optionIndex];
expect(option).toBeDefined();
await act(async () => {
option!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
describe('DocDrawingPosition', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPositionTestBed> | undefined;
it('applies mobile position options without a dropdown and keeps selected state in sync', async () => {
const testBed = createPositionTestBed();
currentTestBed = testBed;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, testBed, true);
const view = within(container);
const marginButton = view.getAllByRole('button', { name: 'Margin' })[0];
await act(async () => marginButton.click());
expect(currentDrawing(testBed).docTransform.positionH).toEqual({
relativeFrom: ObjectRelativeFromH.MARGIN,
posOffset: -90,
});
expect(marginButton.getAttribute('aria-pressed')).toBe('true');
await act(async () => setInputValue(getNumberInput(container!, 0), '36.5'));
expect(currentDrawing(testBed).docTransform.positionH.posOffset).toBe(36.5);
const follow = view.getByRole('switch');
await act(async () => follow.click());
expect(follow.getAttribute('aria-checked')).toBe('false');
expect(currentDrawing(testBed).docTransform.positionV.relativeFrom).toBe(ObjectRelativeFromV.PAGE);
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('anchors the focused drawing to the page when move-with-text is turned off', async () => {
currentTestBed = createPositionTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
const moveWithText = getMoveWithTextInput(container);
expect(moveWithText.checked).toBe(true);
await act(async () => {
moveWithText.click();
await Promise.resolve();
});
expect(moveWithText.checked).toBe(false);
expect(currentDrawing(currentTestBed)).toMatchObject({
docTransform: {
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 104,
},
},
});
});
it('persists horizontal absolute position on the focused drawing', async () => {
currentTestBed = createPositionTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
const horizontalPositionInput = getNumberInput(container, 0);
await act(async () => {
setInputValue(horizontalPositionInput, '36.5');
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 36.5,
},
},
});
});
it('keeps the image visually fixed when horizontal positioning changes from page to margin', async () => {
currentTestBed = createPositionTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await selectPositionRelativeFrom(container, 0, 2);
expect(currentDrawing(currentTestBed)).toMatchObject({
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.MARGIN,
posOffset: -90,
},
},
});
});
it('persists vertical absolute position on the focused drawing', async () => {
currentTestBed = createPositionTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
const verticalPositionInput = getNumberInput(container, 1);
await act(async () => {
setInputValue(verticalPositionInput, '18.5');
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
docTransform: {
positionV: {
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 18.5,
},
},
});
});
it('hides the position controls when doc drawing focus is cleared', async () => {
currentTestBed = createPositionTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
expect(container.firstElementChild?.classList.contains('univer-hidden')).toBe(false);
await act(async () => {
currentTestBed!.get(IDrawingManagerService).focusDrawing([]);
await Promise.resolve();
});
expect(container.firstElementChild?.classList.contains('univer-hidden')).toBe(true);
});
});

View File

@@ -0,0 +1,488 @@
/**
* 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 { DependencyIdentifier, DocumentDataModel, ICommand, IDocumentData } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import { within } from '@testing-library/react';
import {
BooleanNumber,
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocDrawingController, DocDrawingService, IDocDrawingService } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { createDocUiTestBed } from '../../../__tests__/create-doc-ui-test-bed';
import {
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
} from '../../../commands/commands/update-doc-drawing.command';
import { DocDrawingAddRemoveController } from '../../../controllers/doc-drawing-notification.controller';
import locale from '../../../locale/en-US';
import { DocRefreshDrawingsService } from '../../../services/doc-refresh-drawings.service';
import { DocDrawingTextWrap } from '../DocDrawingTextWrap';
import { MobileDocDrawingTextWrap } from '../MobileDocDrawingTextWrap';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'doc-drawing-text-wrap-doc';
const DRAWING_ID = 'image-1';
class TestDocSkeletonManagerService {
getSkeleton() {
return {
getSkeletonData: () => ({
pages: [{
marginTop: 72,
marginLeft: 90,
marginBottom: 72,
pageWidth: 594,
pageHeight: 840,
headerId: '',
footerId: '',
skeDrawings: new Map([
[DRAWING_ID, {
drawingId: DRAWING_ID,
aLeft: 24,
aTop: 42,
columnLeft: 8,
lineTop: 10,
blockAnchorTop: 12,
}],
]),
skeTables: new Map(),
}],
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
}
getViewModel() {
return {
getEditArea: () => DocumentEditArea.BODY,
reset: () => undefined,
};
}
}
class TestDocSelectionRenderService {
private _segment = '';
private _segmentPage = 0;
getSegment() {
return this._segment;
}
setSegment(segment: string) {
this._segment = segment;
}
getSegmentPage() {
return this._segmentPage;
}
setSegmentPage(page: number) {
this._segmentPage = page;
}
blur() {
return undefined;
}
}
class TestScene {
controlRefreshCount = 0;
getTransformerByCreate() {
return {
refreshControls: () => {
this.controlRefreshCount += 1;
},
};
}
}
class TestRenderManagerService {
private readonly _scene = new TestScene();
private readonly _skeletonManager = new TestDocSkeletonManagerService();
private readonly _selectionRenderService = new TestDocSelectionRenderService();
private _render: unknown;
addRender(_unitId: string, render: unknown) {
this._render = render;
}
getRenderUnitById(_unitId: string) {
return {
...(this._render as object),
scene: this._scene,
with: <T,>(token: DependencyIdentifier<T>) => {
if (token === DocSkeletonManagerService) {
return this._skeletonManager as T;
}
if (token === DocSelectionRenderService) {
return this._selectionRenderService as T;
}
throw new Error(`Unexpected render dependency: ${String(token)}`);
},
};
}
}
function createDocData(): IDocumentData {
return {
id: UNIT_ID,
body: {
dataStream: '\b\r\n',
customBlocks: [{
startIndex: 0,
blockId: DRAWING_ID,
}],
},
drawings: {
[DRAWING_ID]: {
drawingId: DRAWING_ID,
unitId: UNIT_ID,
subUnitId: UNIT_ID,
drawingType: 'image',
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
wrapText: WrapTextType.BOTH_SIDES,
distT: 1,
distL: 2,
distB: 3,
distR: 4,
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 0,
},
},
} as never,
},
drawingsOrder: [DRAWING_ID],
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createPanelTestBed() {
const testBed = createDocUiTestBed(createDocData(), [
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
]);
const { injector } = testBed;
injector.add([DocDrawingService]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([DocRefreshDrawingsService]);
injector.add([DocDrawingController]);
injector.add([DocDrawingAddRemoveController]);
const commandService = injector.get(ICommandService);
[
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
RichTextEditingMutation as unknown as ICommand,
].forEach((command) => commandService.registerCommand(command));
injector.get(LocaleService).load({
[LocaleType.EN_US]: locale,
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
injector.get(DocDrawingController).loadDrawingDataForUnit(UNIT_ID);
injector.get(DocDrawingAddRemoveController);
injector.get(IDrawingManagerService).focusDrawing([{
unitId: UNIT_ID,
subUnitId: UNIT_ID,
drawingId: DRAWING_ID,
}]);
return testBed;
}
function renderPanel(root: Root, testBed: ReturnType<typeof createPanelTestBed>, mobile = false) {
const Panel = mobile ? MobileDocDrawingTextWrap : DocDrawingTextWrap;
const drawing = testBed.doc.getSnapshot().drawings![DRAWING_ID];
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<ConfigProvider mountContainer={document.body}>
<Panel drawings={[drawing as never]} />
</ConfigProvider>
</RediContext.Provider>
);
});
}
function currentDrawing(testBed: ReturnType<typeof createPanelTestBed>) {
return testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(UNIT_ID, UniverInstanceType.UNIVER_DOC)!
.getSnapshot()
.drawings![DRAWING_ID];
}
function getRadioInput(container: HTMLElement, index: number): HTMLInputElement {
return container.querySelectorAll<HTMLInputElement>('[data-u-comp="radio"] input')[index];
}
function getNumberInput(container: HTMLElement, index: number): HTMLInputElement {
return container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input')[index];
}
function setInputValue(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('DocDrawingTextWrap', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPanelTestBed> | undefined;
it('applies mobile wrapping and distance controls and reflects the current model', async () => {
const testBed = createPanelTestBed();
currentTestBed = testBed;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, testBed, true);
const view = within(container);
await act(async () => {
view.getByRole('button', { name: 'Right only' }).click();
});
expect(currentDrawing(testBed).wrapText).toBe(WrapTextType.RIGHT);
expect(view.getByRole('button', { name: 'Right only' }).getAttribute('aria-pressed')).toBe('true');
const topInput = view.getByLabelText<HTMLInputElement>('Top(px)');
await act(async () => setInputValue(topInput, '15'));
expect(currentDrawing(testBed).distT).toBe(15);
await act(async () => {
view.getByRole('button', { name: 'Top and Bottom' }).click();
});
expect(currentDrawing(testBed).layoutType).toBe(PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM);
expect(view.queryByRole('button', { name: 'Right only' })).toBeNull();
expect(view.queryByLabelText('Left(px)')).toBeNull();
expect(view.getByLabelText<HTMLInputElement>('Top(px)').value).toBe('15');
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('persists behind-text wrapping on the focused drawing', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await act(async () => {
getRadioInput(container!, 3).click();
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 24,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 114,
},
},
});
});
it('persists in-front-of-text wrapping on the focused drawing', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await act(async () => {
getRadioInput(container!, 4).click();
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 24,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 114,
},
},
});
});
it('preserves wrap metadata when switching to top-and-bottom text flow', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await act(async () => {
getRadioInput(container!, 2).click();
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
wrapText: WrapTextType.BOTH_SIDES,
distT: 1,
distL: 2,
distB: 3,
distR: 4,
docTransform: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 24,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 114,
},
},
});
});
it('persists left distance from text on the focused drawing', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
const leftDistanceInput = getNumberInput(container, 1);
await act(async () => {
setInputValue(leftDistanceInput, '12.5');
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
distT: 1,
distL: 12.5,
distB: 3,
distR: 4,
});
});
it('disables side distance controls when text only wraps above and below the drawing', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await act(async () => {
getRadioInput(container!, 2).click();
await Promise.resolve();
});
expect(getNumberInput(container, 0).disabled).toBe(false);
expect(getNumberInput(container, 2).disabled).toBe(false);
expect(getNumberInput(container, 1).disabled).toBe(true);
expect(getNumberInput(container, 3).disabled).toBe(true);
});
it('persists right-only text wrapping on the focused drawing', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
await act(async () => {
getRadioInput(container!, 7).click();
await Promise.resolve();
});
expect(currentDrawing(currentTestBed)).toMatchObject({
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
wrapText: WrapTextType.RIGHT,
});
});
it('hides the text wrapping controls when doc drawing focus is cleared', async () => {
currentTestBed = createPanelTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
expect(container.firstElementChild?.classList.contains('univer-hidden')).toBe(false);
await act(async () => {
currentTestBed!.get(IDrawingManagerService).focusDrawing([]);
await Promise.resolve();
});
expect(container.firstElementChild?.classList.contains('univer-hidden')).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
/**
* 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 COMPONENT_DOC_DRAWING_PANEL = 'COMPONENT_DOC_DRAWING_PANEL';
export const MOBILE_DOC_DRAWING_PANEL_DIALOG_ID = 'docs-drawing-ui.mobile-panel';

View File

@@ -0,0 +1,377 @@
/**
* 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, ICommandInfo, IDrawingParam, IObjectPositionH, IObjectPositionV, Nullable } from '@univerjs/core';
import type { IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import type { IDocumentSkeletonDrawing } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import type { IDocDrawingPositionProps } from './DocDrawingPosition';
import {
DocumentFlavor,
ICommandService,
IUniverInstanceService,
LocaleService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
const MIN_OFFSET = -1000;
const MAX_OFFSET = 1000;
export function useDocDrawingPosition(props: IDocDrawingPositionProps) {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const renderManagerService = useDependency(IRenderManagerService);
const univerInstanceService = useDependency(IUniverInstanceService);
const { drawings } = props;
const drawingParam = drawings[0];
const { unitId } = drawingParam;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const documentFlavor = documentDataModel?.getSnapshot().documentStyle.documentFlavor;
const renderObject = renderManagerService.getRenderUnitById(unitId);
const scene = renderObject!.scene!;
const transformer = scene.getTransformerByCreate();
const HORIZONTAL_RELATIVE_FROM = [{
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.column'),
value: String(ObjectRelativeFromH.COLUMN),
}, {
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.page'),
value: String(ObjectRelativeFromH.PAGE),
}, {
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.margin'),
value: String(ObjectRelativeFromH.MARGIN),
}];
const VERTICAL_RELATIVE_FROM = [{
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.line'),
value: String(ObjectRelativeFromV.LINE),
disabled: documentFlavor === DocumentFlavor.MODERN,
}, {
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.page'),
value: String(ObjectRelativeFromV.PAGE),
disabled: documentFlavor === DocumentFlavor.MODERN,
}, {
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.margin'),
value: String(ObjectRelativeFromV.MARGIN),
disabled: documentFlavor === DocumentFlavor.MODERN,
}, {
label: localeService.t<LocaleKey>('docs-drawing-ui.image-position.paragraph'),
value: String(ObjectRelativeFromV.PARAGRAPH),
}];
const [disabled, setDisabled] = useState(true);
const [hPosition, setHPosition] = useState<IObjectPositionH>({
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
});
const [vPosition, setVPosition] = useState<IObjectPositionV>({
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 0,
});
const [followTextMove, setFollowTextMove] = useState(true);
const [showPanel, setShowPanel] = useState(true);
function handlePositionChange(
direction: 'positionH' | 'positionV',
value: IObjectPositionH | IObjectPositionV
) {
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const drawings = focusDrawings.map((drawing) => {
return {
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
};
});
commandService.executeCommand<IUpdateDrawingDocTransformCommandParams>(UpdateDrawingDocTransformCommand.id, {
unitId: focusDrawings[0].unitId,
subUnitId: focusDrawings[0].subUnitId,
drawings: drawings.map((drawing) => ({
drawingId: drawing.drawingId,
key: direction,
value,
})),
});
const docSelectionRenderService = renderManagerService.getRenderUnitById(unitId)?.with(DocSelectionRenderService);
if (docSelectionRenderService) {
docSelectionRenderService.blur();
}
transformer.refreshControls();
}
function handleHorizontalRelativeFromChange(value: string) {
const prevRelativeFrom = hPosition.relativeFrom;
const prevPosOffset = hPosition.posOffset;
const relativeFrom = [ObjectRelativeFromH.COLUMN, ObjectRelativeFromH.PAGE, ObjectRelativeFromH.MARGIN]
.find((reference) => String(reference) === value);
if (relativeFrom == null) {
return;
}
if (prevRelativeFrom === relativeFrom) {
return;
}
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const drawingId = focusDrawings[0].drawingId;
const unitId = focusDrawings[0].unitId;
let drawing: Nullable<IDocumentSkeletonDrawing> = null;
let pageMarginLeft = 0;
const skeleton = renderManagerService.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getSkeleton();
const skeletonData = skeleton?.getSkeletonData();
if (skeletonData == null) {
return;
}
const { pages, skeHeaders, skeFooters } = skeletonData;
for (const page of pages) {
const { marginLeft, skeDrawings, headerId, footerId, pageWidth } = page;
if (skeDrawings.has(drawingId)) {
drawing = skeDrawings.get(drawingId);
pageMarginLeft = marginLeft;
break;
}
const headerPage = skeHeaders.get(headerId)?.get(pageWidth);
if (headerPage?.skeDrawings.has(drawingId)) {
drawing = headerPage?.skeDrawings.get(drawingId);
pageMarginLeft = marginLeft;
break;
}
const footerPage = skeFooters.get(footerId)?.get(pageWidth);
if (footerPage?.skeDrawings.has(drawingId)) {
drawing = footerPage?.skeDrawings.get(drawingId);
pageMarginLeft = marginLeft;
break;
}
}
if (drawing == null) {
return;
}
let delta = 0;
if (prevRelativeFrom === ObjectRelativeFromH.COLUMN) {
delta -= drawing.columnLeft;
} else if (prevRelativeFrom === ObjectRelativeFromH.MARGIN) {
delta -= pageMarginLeft;
}
if (relativeFrom === ObjectRelativeFromH.COLUMN) {
delta += drawing.columnLeft;
} else if (relativeFrom === ObjectRelativeFromH.MARGIN) {
delta += pageMarginLeft;
} else if (relativeFrom === ObjectRelativeFromH.PAGE) {
// Do nothing.
}
const newPositionH = {
relativeFrom,
posOffset: (prevPosOffset ?? 0) - delta,
};
handlePositionChange('positionH', newPositionH);
}
function handleVerticalRelativeFromChange(value: string) {
const prevRelativeFrom = vPosition.relativeFrom;
const prevPosOffset = vPosition.posOffset;
const relativeFrom = [ObjectRelativeFromV.LINE, ObjectRelativeFromV.PAGE, ObjectRelativeFromV.MARGIN, ObjectRelativeFromV.PARAGRAPH]
.find((reference) => String(reference) === value);
if (relativeFrom == null) {
return;
}
if (prevRelativeFrom === relativeFrom) {
return;
}
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const { drawingId, unitId } = focusDrawings[0];
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const skeleton = renderManagerService.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getSkeleton();
const docSelectionRenderService = renderManagerService.getRenderUnitById(unitId)?.with(DocSelectionRenderService);
const segmentId = docSelectionRenderService?.getSegment();
const segmentPage = docSelectionRenderService?.getSegmentPage();
const drawing = documentDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customBlocks?.find((c) => c.blockId === drawingId);
if (drawing == null || skeleton == null || docSelectionRenderService == null) {
return;
}
const { startIndex } = drawing;
const glyph = skeleton.findNodeByCharIndex(startIndex, segmentId, segmentPage);
const line = glyph?.parent?.parent;
const column = line?.parent;
const paragraphStartLine = column?.lines.find((l) => l.paragraphIndex === line?.paragraphIndex && l.paragraphStart);
const page = column?.parent?.parent;
if (glyph == null || line == null || paragraphStartLine == null || column == null || page == null) {
return;
}
let delta = 0;
if (prevRelativeFrom === ObjectRelativeFromV.PARAGRAPH) {
delta -= paragraphStartLine.top;
} else if (prevRelativeFrom === ObjectRelativeFromV.LINE) {
delta -= line.top;
} else if (prevRelativeFrom === ObjectRelativeFromV.PAGE) {
delta += page.marginTop;
}
if (relativeFrom === ObjectRelativeFromV.PARAGRAPH) {
delta += paragraphStartLine.top;
} else if (relativeFrom === ObjectRelativeFromV.LINE) {
delta += line.top;
} else if (relativeFrom === ObjectRelativeFromV.PAGE) {
delta -= page.marginTop;
}
const newPositionV = {
relativeFrom,
posOffset: (prevPosOffset ?? 0) - delta,
};
handlePositionChange('positionV', newPositionV);
}
function handleFollowTextMoveCheck(val: string | number | boolean) {
handleVerticalRelativeFromChange(val ? String(ObjectRelativeFromV.PARAGRAPH) : String(ObjectRelativeFromV.PAGE));
}
useEffect(() => {
function updateState(drawingParam: IDrawingParam) {
const snapshot = documentDataModel?.getSnapshot();
const drawing = snapshot?.drawings?.[drawingParam.drawingId];
if (drawing == null) {
return;
}
const { layoutType } = drawing;
const {
positionH,
positionV,
} = drawing.docTransform;
setHPosition(positionH);
setVPosition(positionV);
setDisabled(layoutType === PositionedObjectLayoutType.INLINE);
setFollowTextMove(positionV.relativeFrom === ObjectRelativeFromV.PARAGRAPH || positionV.relativeFrom === ObjectRelativeFromV.LINE);
}
function updateFocusDrawingState() {
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
updateState(focusDrawings[0]);
}
// Get the init focus drawing position.
updateFocusDrawingState();
// Need to update focus drawing position when focus drawing changes.
const subscription = drawingManagerService.focus$.subscribe((drawingParams) => {
if (drawingParams.length === 0) {
setShowPanel(false);
return;
}
setShowPanel(true);
updateState(drawingParams[0]);
});
// Need to update focus drawing position when focus drawing wrap style changed or other edit which will affect the position.
const mutationListener = commandService.onCommandExecuted(async (command: ICommandInfo) => {
if (command.id === RichTextEditingMutation.id) {
updateFocusDrawingState();
}
});
return () => {
subscription.unsubscribe();
mutationListener.dispose();
};
}, [commandService, documentDataModel, drawingManagerService]);
return {
localeService,
showPanel,
disabled,
hPosition,
vPosition,
followTextMove,
documentFlavor,
HORIZONTAL_RELATIVE_FROM,
VERTICAL_RELATIVE_FROM,
handlePositionChange,
handleHorizontalRelativeFromChange,
handleVerticalRelativeFromChange,
handleFollowTextMoveCheck,
MIN_OFFSET,
MAX_OFFSET,
};
}

View File

@@ -0,0 +1,277 @@
/**
* 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, ICommandInfo, IDrawingParam, Nullable } from '@univerjs/core';
import type { IDocDrawingTextWrapProps } from './DocDrawingTextWrap';
import {
BooleanNumber,
ICommandService,
IUniverInstanceService,
LocaleService,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { TextWrappingStyle, UpdateDocDrawingWrappingStyleCommand } from '@univerjs/docs-drawing';
import { IDrawingManagerService } from '@univerjs/drawing';
import { useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import {
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrapTextCommand,
} from '../../commands/commands/update-doc-drawing.command';
const MIN_MARGIN = 0;
const MAX_MARGIN = 100;
interface IDistToText {
distT: number;
distL: number;
distB: number;
distR: number;
}
export function useDocDrawingTextWrap(props: IDocDrawingTextWrapProps) {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const univerInstanceService = useDependency(IUniverInstanceService);
const { drawings } = props;
const drawingParam = drawings[0];
const { unitId } = drawingParam;
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const [disableWrapText, setDisableWrapText] = useState(true);
const [disableDistTB, setDisableDistTB] = useState(true);
const [disableDistLR, setDisableDistLR] = useState(true);
const [wrappingStyle, setWrappingStyle] = useState(TextWrappingStyle.INLINE);
const [wrapText, setWrapText] = useState(WrapTextType.BOTH_SIDES);
const [distToText, setDistToText] = useState<IDistToText>({
distT: 0,
distL: 0,
distB: 0,
distR: 0,
});
const [showPanel, setShowPanel] = useState(true);
function handleWrappingStyleChange(value: number | string | boolean) {
const style = Object.values(TextWrappingStyle).find((style) => style === value);
if (style == null) {
return;
}
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const { unitId, subUnitId } = focusDrawings[0];
const drawings = focusDrawings.map(({ unitId, subUnitId, drawingId }) => ({
unitId,
subUnitId,
drawingId,
}));
commandService.executeCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId,
subUnitId,
drawings,
wrappingStyle: style,
});
}
function handleWrapTextChange(value: number | string | boolean) {
const wrapText = [WrapTextType.BOTH_SIDES, WrapTextType.LEFT, WrapTextType.RIGHT].find((side) => String(side) === String(value));
if (wrapText == null) {
return;
}
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const drawings = focusDrawings.map((drawing) => {
return {
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
};
});
commandService.executeCommand(UpdateDocDrawingWrapTextCommand.id, {
unitId: focusDrawings[0].unitId,
subUnitId: focusDrawings[0].subUnitId,
drawings,
wrapText,
});
}
function handleDistToTextChange(value: Nullable<number>, direction: keyof IDistToText) {
if (value == null) {
return;
}
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
const drawings = focusDrawings.map((drawing) => {
return {
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
};
});
commandService.executeCommand(UpdateDocDrawingDistanceCommand.id, {
unitId: focusDrawings[0].unitId,
subUnitId: focusDrawings[0].subUnitId,
drawings,
dist: {
[direction]: value,
},
});
}
useEffect(() => {
function updateFocusDrawingState() {
const focusDrawings = drawingManagerService.getFocusDrawings();
if (focusDrawings.length === 0) {
return;
}
updateState(focusDrawings[0]);
}
function updateState(drawingParam: IDrawingParam) {
const drawing = documentDataModel?.getSnapshot()?.drawings?.[drawingParam.drawingId];
if (drawing == null) {
return;
}
const {
distT = 0,
distL = 0,
distB = 0,
distR = 0,
layoutType = PositionedObjectLayoutType.INLINE,
behindDoc = BooleanNumber.FALSE,
wrapText = WrapTextType.BOTH_SIDES,
} = drawing;
const distToText = {
distT,
distL,
distB,
distR,
};
setDistToText(distToText);
setWrapText(wrapText);
setDisableWrapText(layoutType !== PositionedObjectLayoutType.WRAP_SQUARE);
if (
layoutType === PositionedObjectLayoutType.WRAP_NONE ||
layoutType === PositionedObjectLayoutType.INLINE
) {
setDisableDistTB(true);
} else {
setDisableDistTB(false);
}
if (
layoutType === PositionedObjectLayoutType.WRAP_NONE ||
layoutType === PositionedObjectLayoutType.INLINE ||
layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM
) {
setDisableDistLR(true);
} else {
setDisableDistLR(false);
}
if (layoutType === PositionedObjectLayoutType.WRAP_NONE) {
if (behindDoc === BooleanNumber.TRUE) {
setWrappingStyle(TextWrappingStyle.BEHIND_TEXT);
} else {
setWrappingStyle(TextWrappingStyle.IN_FRONT_OF_TEXT);
}
} else {
switch (layoutType) {
case PositionedObjectLayoutType.INLINE:
setWrappingStyle(TextWrappingStyle.INLINE);
break;
case PositionedObjectLayoutType.WRAP_SQUARE:
setWrappingStyle(TextWrappingStyle.WRAP_SQUARE);
break;
case PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM:
setWrappingStyle(TextWrappingStyle.WRAP_TOP_AND_BOTTOM);
break;
default:
throw new Error(`Unsupported layout type: ${layoutType}`);
}
}
}
updateFocusDrawingState();
const subscription = drawingManagerService.focus$.subscribe((drawingParams) => {
if (drawingParams.length === 0) {
setShowPanel(false);
return;
}
setShowPanel(true);
updateState(drawingParams[0]);
});
// Need to update focus drawing position when focus drawing wrap style changed or other edit which will affect the position.
const mutationListener = commandService.onCommandExecuted(async (command: ICommandInfo) => {
if (command.id === RichTextEditingMutation.id) {
updateFocusDrawingState();
}
});
return () => {
subscription.unsubscribe();
mutationListener.dispose();
};
}, [commandService, documentDataModel, drawingManagerService]);
return {
localeService,
showPanel,
wrappingStyle,
wrapText,
distToText,
disableWrapText,
disableDistTB,
disableDistLR,
handleWrappingStyleChange,
handleWrapTextChange,
handleDistToTextChange,
MIN_MARGIN,
MAX_MARGIN,
};
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

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

View File

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

View File

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