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