snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
99
packages/docs-drawing-ui/src/controllers/ui.controller.ts
Normal file
99
packages/docs-drawing-ui/src/controllers/ui.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user