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

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

View File

@@ -0,0 +1,503 @@
/**
* 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 { ICommandInfo } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { DrawingTypeEnum, UniverInstanceType } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { DRAWING_OBJECT_LAYER_INDEX, Rect } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { AlignType, SetDrawingAlignOperation } from '../../commands/operations/drawing-align.operation';
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX } from '../../services/drawing-render.service';
import { DrawingUpdateController } from '../drawing-update.controller';
interface IDrawingNotification {
unitId: string;
subUnitId: string;
drawingId: string;
behindText?: boolean;
hidden?: boolean;
selectable?: boolean;
}
interface IDrawingTransformObject {
oKey: string;
left: number;
top: number;
width: number;
height: number;
angle: number;
isInGroup?: boolean;
}
function createHarness() {
const add$ = new Subject<IDrawingNotification[]>();
const remove$ = new Subject<IDrawingNotification[]>();
const update$ = new Subject<IDrawingNotification[]>();
const refreshTransform$ = new Subject<IDrawingNotification[]>();
const visible$ = new Subject<Array<IDrawingNotification & { visible: boolean }>>();
const order$ = new Subject<{ unitId: string; subUnitId: string; drawingIds: string[] }>();
const group$ = new Subject<IDrawingGroupUpdateParam[]>();
const ungroup$ = new Subject<IDrawingGroupUpdateParam[]>();
const changeStart$ = new Subject<{ objects: Map<string, IDrawingTransformObject> }>();
const changeEnd$ = new Subject<{ objects: Map<string, IDrawingTransformObject> }>();
const transformer = {
changeStart$,
changeEnd$,
clearSelectedObjects: vi.fn(),
setSelectedControl: vi.fn(),
refreshControls: vi.fn(() => ({ changeNotification: vi.fn() })),
};
const debounceRefreshControls = vi.fn();
const sceneTransformer = {
debounceRefreshControls,
clearSelectedObjects: vi.fn(),
clearControlByIds: vi.fn(),
};
const zIndexShape = { setProps: vi.fn(), makeDirty: vi.fn() };
const disposeShape = { dispose: vi.fn() };
const showHideShape = { show: vi.fn(), hide: vi.fn() };
const transformShape = { layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const hiddenTransformShape = { layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const selectableShape = { oKey: 'unit-1#-#sheet-1#-#drawing-selectable', evented: true, layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const eventlessShape = { oKey: 'unit-1#-#sheet-1#-#drawing-eventless', evented: false, layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const getSceneObject = vi.fn((key: string): unknown => {
if (key === 'unit-1#-#sheet-1#-#drawing-visible') return showHideShape;
if (key === 'unit-1#-#sheet-1#-#drawing-transform') return transformShape;
if (key === 'unit-1#-#sheet-1#-#drawing-transform-refresh-hidden') return transformShape;
if (key === selectableShape.oKey) return selectableShape;
if (key === eventlessShape.oKey) return eventlessShape;
if (key === 'unit-1#-#sheet-1#-#drawing-remove') return disposeShape;
return null;
});
const scene = {
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => sceneTransformer),
getObject: getSceneObject,
getObjectIncludeInGroup: vi.fn((key: string) => {
if (key === 'unit-1#-#sheet-1#-#drawing-hidden-to-visible') return hiddenTransformShape;
return getSceneObject(key);
}),
fuzzyMathObjects: vi.fn((key: string) => {
if (key.includes('drawing-z')) return [zIndexShape];
if (key.includes('drawing-remove')) return [disposeShape];
return [];
}),
addObject: vi.fn((_object: any, _layerIndex?: number) => ({ attachTransformerTo: vi.fn() })),
addObjects: vi.fn(),
removeObject: vi.fn(),
makeDirty: vi.fn(),
};
const renderManagerService = {
getRenderUnitById: vi.fn(() => ({ scene })),
};
const drawingParams = new Map<string, { unitId: string; subUnitId: string; drawingId: string; drawingType: DrawingTypeEnum; behindText?: boolean; hidden?: boolean; selectable?: boolean; transform: { left: number; top: number; width: number; height: number; angle: number; clipBounds?: { left: number; top: number; width: number; height: number } } }>([
['drawing-a', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 } }],
['drawing-b', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 20, top: 0, width: 10, height: 10, angle: 0 } }],
['chart-1', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'chart-1', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 40, top: 0, width: 20, height: 10, angle: 0 } }],
['drawing-transform', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform', drawingType: DrawingTypeEnum.DRAWING_IMAGE, behindText: true, hidden: true, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0, clipBounds: { left: 0, top: 0, width: 80, height: 120 } } }],
['drawing-transform-in-group', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform-in-group', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 8, top: 9, width: 10, height: 11, angle: 0 } }],
['drawing-transform-refresh-hidden', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform-refresh-hidden', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0, clipBounds: { left: 0, top: 0, width: 80, height: 120 } } }],
['drawing-hidden-to-visible', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-hidden-to-visible', drawingType: DrawingTypeEnum.DRAWING_IMAGE, hidden: false, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0 } }],
['drawing-selectable', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-selectable', drawingType: DrawingTypeEnum.DRAWING_IMAGE, selectable: false, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0 } }],
['drawing-eventless', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-eventless', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 4, top: 5, width: 6, height: 7, angle: 0 } }],
['drawing-refresh-missing', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 4, top: 5, width: 6, height: 7, angle: 0 } }],
]);
const drawingManagerService = {
drawingManagerData: {},
add$,
remove$,
update$,
refreshTransform$,
visible$,
order$,
group$,
ungroup$,
getDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => drawingParams.get(drawingId) ?? null),
getOldDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => {
const drawing = drawingParams.get(drawingId);
return drawingId === 'drawing-selectable' && drawing
? { ...drawing, selectable: false }
: drawing ?? null;
}),
getDrawingOKey: vi.fn((oKey: string) => ({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: oKey, drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 } })),
getDrawingOrder: vi.fn(() => ['drawing-z']),
getFocusDrawings: vi.fn(() => [
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
]),
focusDrawing: vi.fn(),
addNotification: vi.fn(),
featurePluginUpdateNotification: vi.fn(),
};
let commandExecuted: ((cmd: ICommandInfo) => void) | undefined;
const commandService = {
onCommandExecuted: vi.fn((handler: (cmd: ICommandInfo) => void) => {
commandExecuted = handler;
return { dispose: vi.fn() };
}),
syncExecuteCommand: vi.fn(),
};
const controller = new DrawingUpdateController(
{
getFocusedUnit: vi.fn(() => ({
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => 'unit-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
})),
} as never,
commandService as never,
renderManagerService as never,
drawingManagerService as never
);
return {
controller,
add$,
changeStart$,
changeEnd$,
commandExecuted: () => commandExecuted,
drawingManagerService,
drawingParams,
group$,
order$,
refreshTransform$,
remove$,
scene,
showHideShape,
transformShape,
hiddenTransformShape,
selectableShape,
eventlessShape,
update$,
visible$,
zIndexShape,
disposeShape,
debounceRefreshControls,
transformer,
commandService,
};
}
describe('DrawingUpdateController', () => {
it('syncs selection on transform start and persists transform changes on end', () => {
const harness = createHarness();
harness.add$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' }]);
const oKey = 'drawing-a';
const startObject = { oKey, left: 0, top: 0, width: 10, height: 10, angle: 0, isInGroup: false };
harness.changeStart$.next({ objects: new Map([[oKey, startObject]]) });
expect(harness.commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, [
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' },
]);
const endObject = { ...startObject, left: 5 };
harness.changeEnd$.next({ objects: new Map([[oKey, endObject]]) });
expect(harness.drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-a', transform: expect.objectContaining({ left: 5 }) }),
]);
harness.controller.dispose();
});
it('applies drawing alignment changes for focused drawings', () => {
const harness = createHarness();
harness.add$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' }]);
harness.commandExecuted()?.({ id: SetDrawingAlignOperation.id, params: { alignType: AlignType.left } });
expect(harness.drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-b', transform: { left: 0, top: 0 } }),
]);
harness.controller.dispose();
});
it('updates zIndex, transforms, visibility, and disposes removed shapes', () => {
const harness = createHarness();
harness.order$.next({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingIds: ['drawing-z'] });
expect(harness.zIndexShape.setProps).toHaveBeenCalledWith({ zIndex: 0 });
expect(harness.zIndexShape.makeDirty).toHaveBeenCalledTimes(1);
const drawingId = 'drawing-transform';
harness.update$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(harness.transformShape.transformByState).toHaveBeenCalled();
expect(harness.transformShape.hide).toHaveBeenCalledTimes(1);
expect(harness.debounceRefreshControls).toHaveBeenCalledTimes(1);
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(harness.transformShape.transformByState).toHaveBeenCalledTimes(2);
expect(harness.transformShape.setClipBounds).toHaveBeenCalledWith({ left: 0, top: 0, width: 80, height: 120 });
expect(harness.transformShape.hide).toHaveBeenCalledTimes(2);
expect(harness.scene.removeObject).toHaveBeenCalledWith(harness.transformShape);
expect(harness.scene.addObject).toHaveBeenCalledWith(harness.transformShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
harness.visible$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-visible', visible: false }]);
expect(harness.showHideShape.hide).toHaveBeenCalledTimes(1);
harness.visible$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-visible', visible: true }]);
expect(harness.showHideShape.show).toHaveBeenCalledTimes(1);
harness.remove$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-remove' }]);
expect(harness.disposeShape.dispose).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('restores a hidden render object when drawing metadata becomes visible', () => {
const harness = createHarness();
harness.update$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-hidden-to-visible',
}]);
expect(harness.hiddenTransformShape.show).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('restores a hidden render object from a visibility notification', () => {
const harness = createHarness();
harness.visible$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-hidden-to-visible',
visible: true,
}]);
expect(harness.hiddenTransformShape.show).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('syncs drawing selectability to canvas picking and the active selection', () => {
const harness = createHarness();
const drawing = harness.drawingParams.get('drawing-selectable')!;
harness.drawingManagerService.getFocusDrawings.mockReturnValue([
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-selectable', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
]);
harness.update$.next([{
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
}]);
expect(harness.selectableShape.evented).toBe(false);
expect(harness.scene.getTransformer().clearControlByIds).toHaveBeenCalledWith([harness.selectableShape.oKey]);
expect(harness.drawingManagerService.focusDrawing).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-b' }),
]);
delete drawing.selectable;
harness.update$.next([{
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
}]);
expect(harness.selectableShape.evented).toBe(true);
harness.controller.dispose();
});
it('preserves eventless render objects without selectable metadata', () => {
const harness = createHarness();
harness.update$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-eventless',
}]);
expect(harness.eventlessShape.evented).toBe(false);
harness.controller.dispose();
});
it('disposes grouped drawing children through grouped scene lookup on remove notifications', () => {
const harness = createHarness();
const groupedShape = { dispose: vi.fn() };
const drawingId = 'drawing-remove-in-group';
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId });
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === drawingShapeKey) {
return groupedShape;
}
return null;
});
harness.scene.fuzzyMathObjects.mockClear();
harness.remove$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(groupedShape.dispose).toHaveBeenCalledTimes(1);
expect(harness.scene.getTransformer().clearSelectedObjects).toHaveBeenCalledTimes(1);
expect(harness.scene.fuzzyMathObjects).not.toHaveBeenCalled();
harness.controller.dispose();
});
it('requests drawing insertion when a refreshed transform belongs to a missing scene object', () => {
const harness = createHarness();
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' }]);
expect(harness.drawingManagerService.addNotification).toHaveBeenCalledTimes(1);
expect(harness.drawingManagerService.addNotification).toHaveBeenCalledWith([
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' },
]);
harness.controller.dispose();
});
it('refreshes drawing transforms for objects inside groups without requesting insertion', () => {
const harness = createHarness();
const groupedShape = { transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn(), layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX } };
const drawingId = 'drawing-transform-in-group';
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId });
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === drawingShapeKey) {
return groupedShape;
}
return null;
});
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(groupedShape.transformByState).toHaveBeenCalledWith({
left: 8,
top: 9,
width: 10,
height: 11,
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
});
expect(harness.drawingManagerService.addNotification).not.toHaveBeenCalled();
harness.controller.dispose();
});
it('groups existing chart render objects from the scene', () => {
const harness = createHarness();
const unitId = 'unit-1';
const subUnitId = 'sheet-1';
const imageKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'drawing-a' });
const chartKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'chart-1' });
const groupKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'group-1' });
const imageRect = new Rect(imageKey, { left: 0, top: 0, width: 10, height: 10 });
const chartRect = new Rect(chartKey, { left: 40, top: 0, width: 20, height: 10 });
const imageTransformSpy = vi.spyOn(imageRect, 'transformByState');
const chartTransformSpy = vi.spyOn(chartRect, 'transformByState');
let createdGroup: { oKey: string; getObjects: () => unknown[]; transformerConfig?: { rotateEnabled?: boolean }; angle?: number } | undefined;
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === imageKey) {
return imageRect;
}
if (key === chartKey) {
return chartRect;
}
return null;
});
harness.scene.addObject.mockImplementation((object: { oKey: string; getObjects?: () => unknown[] }) => {
if (object.oKey === groupKey && object.getObjects) {
createdGroup = object as { oKey: string; getObjects: () => unknown[]; transformerConfig?: { rotateEnabled?: boolean }; angle?: number };
}
return { attachTransformerTo: vi.fn() };
});
harness.group$.next([{
parent: {
unitId,
subUnitId,
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0, width: 60, height: 10, angle: 30 },
groupBaseBound: { left: 0, top: 0, width: 60, height: 10 },
},
children: [
{ unitId, subUnitId, drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 }, groupId: 'group-1' },
{ unitId, subUnitId, drawingId: 'chart-1', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 40, top: 0, width: 20, height: 10, angle: 0 }, groupId: 'group-1' },
],
}]);
expect(createdGroup?.oKey).toBe(groupKey);
expect(createdGroup?.getObjects()).toEqual([imageRect, chartRect]);
expect(createdGroup?.transformerConfig?.rotateEnabled).toBe(false);
expect(createdGroup?.angle).toBe(30);
expect(imageRect.isInGroup).toBe(true);
expect(chartRect.isInGroup).toBe(true);
expect(imageRect.groupKey).toBe(groupKey);
expect(chartRect.groupKey).toBe(groupKey);
expect(imageTransformSpy).toHaveBeenCalledWith({ left: 0, top: 0, width: 10, height: 10, angle: 0 });
expect(chartTransformSpy).toHaveBeenCalledWith({ left: 40, top: 0, width: 20, height: 10, angle: 0 });
expect(harness.transformer.clearSelectedObjects).toHaveBeenCalledTimes(1);
expect(harness.transformer.setSelectedControl).toHaveBeenCalledWith(createdGroup);
harness.controller.dispose();
});
it('uses refresh metadata to hide stale drawings without re-adding missing hidden shapes', () => {
const harness = createHarness();
harness.refreshTransform$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-transform-refresh-hidden',
behindText: true,
hidden: true,
}]);
expect(harness.transformShape.hide).toHaveBeenCalledTimes(1);
expect(harness.scene.removeObject).toHaveBeenCalledWith(harness.transformShape);
expect(harness.scene.addObject).toHaveBeenCalledWith(harness.transformShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
harness.refreshTransform$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-refresh-missing',
hidden: true,
}]);
expect(harness.drawingManagerService.addNotification).not.toHaveBeenCalled();
harness.controller.dispose();
});
});

View File

@@ -0,0 +1,438 @@
/**
* 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 { ICommandInfo } from '@univerjs/core';
import { ContextService, DrawingTypeEnum, ICommandService, IContextService, Injector, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutoImageCropOperation, CloseImageCropOperation, CropType, OpenImageCropOperation } from '../../commands/operations/image-crop.operation';
import { ImageCropperObject } from '../../views/crop/image-cropper-object';
import { ImageCropperController } from '../image-cropper.controller';
import { MobileImageCropperController } from '../mobile/image-cropper.controller';
function createImage(id: string) {
const img = document.createElement('img');
Object.defineProperty(img, 'width', { value: 100, configurable: true });
Object.defineProperty(img, 'height', { value: 50, configurable: true });
return new Image(id, { image: img });
}
const injectors: Injector[] = [];
function createController(
command: ICommandService,
drawing: IDrawingManagerService,
render: IRenderManagerService,
instance: IUniverInstanceService,
message: IMessageService,
locale: LocaleService,
shortcut: IShortcutService,
layout: ILayoutService,
mobile = false
) {
const injector = new Injector([
[ICommandService, { useValue: command }],
[IDrawingManagerService, { useValue: drawing }],
[IRenderManagerService, { useValue: render }],
[IUniverInstanceService, { useValue: instance }],
[IMessageService, { useValue: message }],
[LocaleService, { useValue: locale }],
[IShortcutService, { useValue: shortcut }],
[ILayoutService, { useValue: layout }],
[IContextService, { useClass: ContextService }],
]);
injectors.push(injector);
if (mobile) {
injector.add([MobileImageCropperController]);
return injector.get(MobileImageCropperController);
}
injector.add([ImageCropperController]);
return injector.get(ImageCropperController);
}
afterEach(() => {
injectors.splice(0).forEach((injector) => injector.dispose());
});
describe('ImageCropperController', () => {
it('auto crops focused image and opens crop panel', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 0, top: 0, width: 100, height: 50, angle: 0 });
const existingCropper = new ImageCropperObject(`${imageKey}-crop`, { applyTransform: image.getState() });
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [focusDrawing]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
};
const transformer = {
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
};
const scene = {
getAllObjectsByOrder: vi.fn(() => [existingCropper]),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
// handlers: [OpenImageCrop, CloseImageCrop, AutoImageCrop]
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(CloseImageCropOperation.id, { isAuto: true });
expect(image.srcRect).toEqual({ left: 25, top: 0, right: 25, bottom: 0 });
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(OpenImageCropOperation.id, focusDrawing);
controller.dispose();
});
it('shows error message when auto crop target is not an Image', () => {
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const messageService = { show: vi.fn() };
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' }]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
};
const scene = {
getAllObjectsByOrder: vi.fn(() => []),
getObject: vi.fn(() => ({ not: 'image' })),
getTransformerByCreate: vi.fn(() => ({ changeStart$: new Subject(), changeEnd$: new Subject(), clearControl$: new Subject() })),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
messageService as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(messageService.show).toHaveBeenCalledWith(expect.objectContaining({
type: MessageType.Error,
}));
controller.dispose();
});
it.each([false, true])('opens crop mode with mobile hit targets only when mobile=%s', (mobile) => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 0, top: 0, width: 100, height: 50, angle: 0 });
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE, srcRect: null, prstGeom: null })),
};
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((obj: ImageCropperObject) => {
createdCropper = obj;
return { attachTransformerTo: vi.fn() };
}),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const layoutService = { focus: vi.fn() };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
layoutService as never,
mobile
);
expect(controller instanceof ImageCropperController).toBe(!mobile);
// OpenImageCrop handler
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(createdCropper).not.toBeNull();
expect(createdCropper!.transformerConfig?.cropAnchorHitSize).toBe(mobile ? 44 : undefined);
const cropper = createdCropper!;
const originalImageState = image.getState();
const originalCropState = cropper.getState();
const objects = new Map([[cropper.oKey, cropper]]);
for (let attempt = 0; attempt < 5; attempt++) {
transformer.createControlForCopper.mockClear();
transformer.changeStart$.next({ objects });
transformer.changeEnd$.next({ objects });
expect(transformer.createControlForCopper).toHaveBeenCalledExactlyOnceWith(cropper);
expect(cropper.getState()).toEqual(originalCropState);
expect(image.getState()).toEqual(originalImageState);
}
createdCropper!.onPointerEnter$.emitEvent({} as never);
expect(createdCropper!.cursor).toBe(CURSOR_TYPE.MOVE);
createdCropper!.onPointerLeave$.emitEvent({} as never);
expect(createdCropper!.cursor).toBe(CURSOR_TYPE.DEFAULT);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, [focusDrawing]);
expect(layoutService.focus).toHaveBeenCalledOnce();
controller.dispose();
});
it('registers confirm and cancel shortcuts only while crop mode is open', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn((id, params) => {
if (id === CloseImageCropOperation.id) {
commandHandlers[1]({ id, params } as never);
}
return true;
}),
};
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((object: ImageCropperObject) => {
createdCropper = object;
return { attachTransformerTo: vi.fn() };
}),
};
const firstEnterShortcutDisposable = { dispose: vi.fn() };
const firstCancelShortcutDisposable = { dispose: vi.fn() };
const secondEnterShortcutDisposable = { dispose: vi.fn() };
const secondCancelShortcutDisposable = { dispose: vi.fn() };
const shortcutDisposables = [
firstEnterShortcutDisposable,
firstCancelShortcutDisposable,
secondEnterShortcutDisposable,
secondCancelShortcutDisposable,
];
const shortcutService = {
registerShortcut: vi.fn(() => shortcutDisposables.shift()!),
};
const controller = createController(
commandService as never,
{
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
getDrawingOKey: vi.fn(() => ({ ...focusDrawing, transform: image.getState() })),
featurePluginUpdateNotification: vi.fn(),
} as never,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn(() => null) } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
shortcutService as never,
{ focus: vi.fn() } as never
);
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
}));
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
staticParameters: { isCancel: true },
}));
const firstCropperDispose = vi.spyOn(createdCropper!, 'dispose');
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(firstCropperDispose).toHaveBeenCalledOnce();
const secondCropperDispose = vi.spyOn(createdCropper!, 'dispose');
transformer.clearCopperControl.mockImplementationOnce(() => {
commandService.syncExecuteCommand(CloseImageCropOperation.id, undefined);
});
commandHandlers[1]({ id: CloseImageCropOperation.id, params: { isCancel: true } } as never);
expect(transformer.detachFrom).toHaveBeenCalledWith(createdCropper);
expect(secondCropperDispose).toHaveBeenCalledOnce();
expect(commandService.syncExecuteCommand).toHaveBeenLastCalledWith(CloseImageCropOperation.id, undefined);
expect(firstEnterShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(firstCancelShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(secondEnterShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(secondCancelShortcutDisposable.dispose).toHaveBeenCalledOnce();
controller.dispose();
});
it('restores the image state from before auto crop without publishing changes when cancelled', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 10, top: 20, width: 100, height: 50, angle: 0 });
const originalTransform = image.getState();
const originalSrcRect = image.srcRect;
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn((id, params) => {
if (id === OpenImageCropOperation.id) {
commandHandlers[0]({ id, params } as never);
}
return true;
}),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [focusDrawing]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
getDrawingOKey: vi.fn(() => ({ ...focusDrawing, drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
featurePluginUpdateNotification: vi.fn(),
};
const refreshedImageStates: ReturnType<Image['getState']>[] = [];
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(() => refreshedImageStates.push(image.getState())),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((object: ImageCropperObject) => {
createdCropper = object;
return { attachTransformerTo: vi.fn() };
}),
};
const controller = createController(
commandService as never,
drawingManagerService as never,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn(() => ({ getUnitId: () => focusDrawing.unitId })) } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(image.srcRect).not.toBeNull();
expect(createdCropper).not.toBeNull();
refreshedImageStates.length = 0;
commandHandlers[1]({ id: CloseImageCropOperation.id, params: { isCancel: true } } as never);
expect(drawingManagerService.featurePluginUpdateNotification).not.toHaveBeenCalled();
expect(image.getState()).toEqual(originalTransform);
expect(image.srcRect).toBe(originalSrcRect);
expect(refreshedImageStates).toEqual([originalTransform]);
controller.dispose();
});
});

View File

@@ -0,0 +1,306 @@
/**
* 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 { ContextService, DrawingTypeEnum, ICommandService, IContextService, IImageIoService, ImageSourceType, Injector, IUniverInstanceService, LocaleService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IDialogService, ILayoutService, IMessageService, IShortcutService } from '@univerjs/ui';
import { NEVER, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX, DrawingRenderService } from '../../services/drawing-render.service';
import { ImageCropperController } from '../image-cropper.controller';
import { ImageUpdateController } from '../image-update.controller';
const injectors: Injector[] = [];
function createController(
command: ICommandService,
render: IRenderManagerService,
drawing: IDrawingManagerService,
dialog: IDialogService,
imageIo: IImageIoService,
instance: IUniverInstanceService,
drawingRender: DrawingRenderService
) {
const injector = new Injector([
[ICommandService, { useValue: command }],
[IRenderManagerService, { useValue: render }],
[IDrawingManagerService, { useValue: drawing }],
[IDialogService, { useValue: dialog }],
[IImageIoService, { useValue: imageIo }],
[IUniverInstanceService, { useValue: { ...instance, getCurrentTypeOfUnit$: () => NEVER } }],
[DrawingRenderService, { useValue: drawingRender }],
[IContextService, { useClass: ContextService }],
[ThemeService],
[LocaleService],
[ILayoutService, { useValue: { focus: vi.fn() } }],
[IMessageService, { useValue: { show: vi.fn() } }],
[IShortcutService, { useValue: { registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } }],
[ImageCropperController],
[ImageUpdateController],
]);
injectors.push(injector);
return injector.get(ImageUpdateController);
}
afterEach(() => {
injectors.splice(0).forEach((injector) => injector.dispose());
vi.useRealTimers();
});
function createSheetUnit(unitId = 'book-1', subUnitId = 'sheet-1') {
return {
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => unitId,
getActiveSheet: () => ({ getSheetId: () => subUnitId }),
};
}
describe('ImageUpdateController', () => {
it('resets image size after command execution and pushes updated drawing state back', () => {
let commandHandler: ((command: { id: string; params?: unknown }) => void) | undefined;
const changeNotification = vi.fn();
const imageShape = {
resetSize: vi.fn(),
getNativeSize: vi.fn(() => ({ width: 320, height: 180 })),
};
const params = [{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-1' }];
const drawingManagerService = {
add$: new Subject(),
update$: new Subject(),
getDrawingByParam: vi.fn(() => ({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { width: 100, height: 50, angle: 30 },
})),
featurePluginUpdateNotification: vi.fn(),
};
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandler = handler;
return { dispose: vi.fn() };
}),
syncExecuteCommand: vi.fn(),
};
const controller = createController(
commandService as never,
{
getRenderUnitById: vi.fn(() => ({
scene: {
getObject: vi.fn(() => imageShape),
getTransformerByCreate: vi.fn(() => ({
refreshControls: vi.fn(() => ({ changeNotification })),
})),
},
})),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages: vi.fn() } as never
);
expect(controller).toBeTruthy();
commandHandler!({ id: ImageResetSizeOperation.id, params });
expect(imageShape.resetSize).toHaveBeenCalledTimes(1);
expect(drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({
drawingId: 'shape-1',
srcRect: null,
prstGeom: null,
transform: expect.objectContaining({ width: 320, height: 180, angle: 0 }),
}),
]);
expect(changeNotification).toHaveBeenCalledTimes(1);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, params);
});
it('deduplicates add notifications before rendering newly inserted images', async () => {
vi.useFakeTimers();
const add$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const update$ = new Subject();
const imageParam = {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-2',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { width: 100, height: 50, angle: 0 },
};
const drawingManagerService = {
add$,
update$,
getDrawingByParam: vi.fn(() => imageParam),
refreshTransform: vi.fn(),
};
const renderImages = vi.fn(async () => []);
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({
scene: { getTransformerByCreate: vi.fn(), getObject: vi.fn() },
})),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages } as never
);
expect(controller).toBeTruthy();
const duplicated = { unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-2' };
add$.next([duplicated]);
add$.next([duplicated]);
await vi.advanceTimersByTimeAsync(40);
expect(renderImages).toHaveBeenCalledTimes(1);
expect(drawingManagerService.refreshTransform).toHaveBeenCalledWith([imageParam]);
});
it('refreshes a loaded image from the current drawing state after updates during loading', async () => {
vi.useFakeTimers();
const add$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const update$ = new Subject();
const insertedImage = {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-2',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 0, top: 0, width: 100, height: 50, angle: 0 },
};
const updatedImage = {
...insertedImage,
transform: { ...insertedImage.transform, left: 200, top: 120 },
};
let currentImage = insertedImage;
let finishRendering!: (images: never[]) => void;
const renderImages = vi.fn(() => new Promise<never[]>((resolve) => {
finishRendering = resolve;
}));
const refreshTransform = vi.fn();
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({
scene: { getTransformerByCreate: vi.fn(), getObject: vi.fn() },
})),
} as never,
{
add$,
update$,
getDrawingByParam: vi.fn(() => currentImage),
refreshTransform,
} as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages } as never
);
add$.next([{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-2' }]);
await vi.advanceTimersByTimeAsync(40);
currentImage = updatedImage;
finishRendering([]);
await Promise.resolve();
expect(refreshTransform).toHaveBeenCalledWith([updatedImage]);
controller.dispose();
});
it('applies refreshed transform, clip bounds, and behind-text layer to existing images', () => {
const add$ = new Subject();
const update$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const transform = {
angle: 0,
clipBounds: { height: 120, left: 0, top: 0, width: 80 },
height: 60,
left: 10,
top: 20,
width: 100,
};
const imageParam = {
behindText: true,
drawingId: 'shape-3',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,Zm9v',
srcRect: { bottom: 0, left: 0, right: 0, top: 0 },
subUnitId: 'sheet-1',
transform,
unitId: 'book-1',
};
const imageShape = {
changeSource: vi.fn(),
layer: { zIndex: 4 },
setClipBounds: vi.fn(),
setPrstGeom: vi.fn(),
setSrcRect: vi.fn(),
transformByState: vi.fn(),
};
const scene = {
addObject: vi.fn(),
getObject: vi.fn(() => imageShape),
getTransformerByCreate: vi.fn(),
removeObject: vi.fn(),
};
const drawingManagerService = {
add$,
update$,
getDrawingByParam: vi.fn(() => imageParam),
};
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({ scene })),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages: vi.fn() } as never
);
expect(controller).toBeTruthy();
update$.next([{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-3' }]);
expect(imageShape.transformByState).toHaveBeenCalledWith({
angle: 0,
flipX: false,
flipY: false,
height: 60,
left: 10,
skewX: 0,
skewY: 0,
top: 20,
width: 100,
});
expect(imageShape.setClipBounds).toHaveBeenCalledWith(transform.clipBounds);
expect(scene.removeObject).toHaveBeenCalledWith(imageShape);
expect(scene.addObject).toHaveBeenCalledWith(imageShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
});
});

View File

@@ -0,0 +1,311 @@
/**
* 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 { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { DrawingGroupObject, Group } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { disposeDrawingRenderObject, getCurrentUnitInfo, getDrawingRenderObject, insertGroupObject, syncGroupRotateEnabled } from '../utils';
const { MockGroup } = vi.hoisted(() => {
class HoistedMockGroup {
oKey: string;
transformerConfig?: { rotateEnabled?: boolean };
private _objects: Array<{ oKey: string }> = [];
setBaseBound = vi.fn();
transformByState = vi.fn();
constructor(oKey: string) {
this.oKey = oKey;
}
getObjects() {
return this._objects;
}
addObject(object: { oKey: string }) {
this._objects.push(object);
}
getObjectIncludeInGroup(key: string) {
return this._objects.find((obj) => obj.oKey === key) ?? null;
}
}
return {
MockGroup: HoistedMockGroup,
};
});
vi.mock('@univerjs/drawing', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/drawing')>();
return {
...actual,
getDrawingShapeKeyByDrawingSearch: vi.fn(({ drawingId }) => `group-${drawingId}`),
};
});
vi.mock('@univerjs/engine-render', () => ({
DRAWING_OBJECT_LAYER_INDEX: 99,
Group: MockGroup,
DrawingGroupObject: MockGroup,
}));
describe('drawing controller utils', () => {
it('creates a new drawing group, attaches it to the scene, and avoids duplicate children', () => {
const object = { oKey: 'child-1' };
const attachTransformerTo = vi.fn();
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo,
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 30, height: 40, angle: 15 },
groupBaseBound: { left: 1, top: 2, width: 3, height: 4 },
})),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
expect(getDrawingShapeKeyByDrawingSearch).toHaveBeenCalledWith({ drawingId: 'group-1' });
expect(group).toBeInstanceOf(DrawingGroupObject as unknown as typeof MockGroup);
expect(group.getObjects()).toEqual([object]);
expect(group.setBaseBound).toHaveBeenCalledWith({ left: 1, top: 2, width: 3, height: 4 });
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 15 });
expect(attachTransformerTo).toHaveBeenCalledWith(group);
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
expect(group.getObjects()).toEqual([object]);
const nextObject = { oKey: 'child-2' };
insertGroupObject({ drawingId: 'group-1' } as never, nextObject as never, scene as never, drawingManagerService as never);
expect(group.getObjects()).toEqual([object, nextObject]);
});
it('inserts nested groups into their parent group', () => {
const object = { oKey: 'child-1' };
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo: vi.fn(),
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn((param: { drawingId: string }) => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: param.drawingId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
groupId: param.drawingId === 'child-group' ? 'parent-group' : undefined,
transform: { left: 0, top: 0, width: 10, height: 10 },
})),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-group' }, object as never, scene as never, drawingManagerService as never);
const childGroup = scene.getObject('group-child-group') as InstanceType<typeof MockGroup> & { isInGroup?: boolean };
const parentGroup = scene.getObject('group-parent-group') as InstanceType<typeof MockGroup>;
expect(childGroup.isInGroup).toBe(true);
expect(parentGroup.getObjects()).toEqual([childGroup]);
});
it('disables restored group rotation when descendants include an old chart drawing', () => {
const object = { oKey: 'chart-1' };
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo: vi.fn(),
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 30, height: 40, angle: 30 },
})),
getDrawingsByGroup: vi.fn(() => [{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: { left: 10, top: 20, width: 30, height: 40 },
groupId: 'group-1',
}]),
};
insertGroupObject({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'group-1' }, object as never, scene as never, drawingManagerService as never);
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
expect(group.transformerConfig?.rotateEnabled).toBe(false);
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 30 });
});
it('uses explicit children when synchronizing group rotation state', () => {
const group = new MockGroup('group-group-1');
const scene = {
getObjectIncludeInGroup: vi.fn(() => null),
};
const drawingManagerService = {
getDrawingsByGroup: vi.fn(() => []),
};
syncGroupRotateEnabled(group as never, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
}, scene as never, drawingManagerService as never, [{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
}]);
expect(group.transformerConfig?.rotateEnabled).toBe(false);
expect(drawingManagerService.getDrawingsByGroup).not.toHaveBeenCalled();
});
it('skips invalid group targets and resolves current unit info for sheet, doc, and slide', () => {
const scene = {
getObject: vi.fn(() => ({ oKey: 'not-a-group' })),
getObjectIncludeInGroup: vi.fn(() => ({ oKey: 'not-a-group' })),
addObject: vi.fn(() => ({ attachTransformerTo: vi.fn() })),
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({ transform: { left: 0, top: 0, width: 10, height: 10 } })),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ drawingId: 'group-2' } as never, { oKey: 'child-2' } as never, scene as never, drawingManagerService as never);
expect(scene.getObjectIncludeInGroup).toHaveBeenCalled();
expect(scene.addObject).not.toHaveBeenCalled();
insertGroupObject({ drawingId: 'missing-group' } as never, { oKey: 'child-3' } as never, scene as never, {
getDrawingByParam: vi.fn(() => null),
getDrawingsByGroup: vi.fn(() => []),
} as never);
const sheet = {
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => 'workbook-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
};
const doc = {
type: UniverInstanceType.UNIVER_DOC,
getUnitId: () => 'doc-1',
};
const slide = {
type: UniverInstanceType.UNIVER_SLIDE,
getUnitId: () => 'slide-1',
};
const currentUniverService = {
getFocusedUnit: vi.fn()
.mockReturnValueOnce(sheet)
.mockReturnValueOnce(doc)
.mockReturnValueOnce(slide)
.mockReturnValueOnce(null),
getUnit: vi.fn(() => sheet),
};
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'workbook-1',
subUnitId: 'sheet-1',
current: sheet,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'doc-1',
subUnitId: 'doc-1',
current: doc,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'slide-1',
subUnitId: 'slide-1',
current: slide,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toBeUndefined();
expect(getCurrentUnitInfo(currentUniverService as never, 'workbook-1')).toEqual({
unitId: 'workbook-1',
subUnitId: 'sheet-1',
current: sheet,
});
expect(Group).toBeDefined();
});
it('looks up and disposes grouped render objects through grouped scene lookup only', () => {
const renderObject = { oKey: 'group-child', dispose: vi.fn() };
const scene = {
getObject: vi.fn(() => ({ oKey: 'top-level-child' })),
getObjectIncludeInGroup: vi.fn(() => renderObject),
};
const drawingSearch = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' };
expect(getDrawingRenderObject(scene as never, drawingSearch)).toBe(renderObject);
expect(disposeDrawingRenderObject(scene as never, drawingSearch)).toBe(true);
expect(renderObject.dispose).toHaveBeenCalledTimes(1);
expect(scene.getObject).not.toHaveBeenCalled();
});
it('does not fall back to top-level scene lookup when grouped lookup misses', () => {
const scene = {
getObject: vi.fn(() => ({ oKey: 'top-level-child', dispose: vi.fn() })),
getObjectIncludeInGroup: vi.fn(() => null),
};
expect(getDrawingRenderObject(scene as never, { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' })).toBeNull();
expect(disposeDrawingRenderObject(scene as never, { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' })).toBe(false);
expect(scene.getObject).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
/**
* 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 {
BottomIcon,
CropIcon,
DeleteIcon,
DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
} from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import {
COMPONENT_IMAGE_POPUP_MENU,
} from '../views/image-popup-menu/component-name';
import { ImagePopupMenu } from '../views/image-popup-menu/ImagePopupMenu';
import { DRAWING_ALIGN_COMPONENT, DRAWING_ARRANGE_COMPONENT, DRAWING_GROUP_COMPONENT, DRAWING_TRANSFORM_COMPONENT, IMAGE_CROPPER_COMPONENT } from '../views/panel/component-name';
import { DrawingAlign } from '../views/panel/DrawingAlign';
import { DrawingArrange } from '../views/panel/DrawingArrange';
import { DrawingGroup } from '../views/panel/DrawingGroup';
import { DrawingTransform } from '../views/panel/DrawingTransform';
import { ImageCropper } from '../views/panel/ImageCropper';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[COMPONENT_IMAGE_POPUP_MENU, ImagePopupMenu],
[DRAWING_ARRANGE_COMPONENT, DrawingArrange],
[DRAWING_ALIGN_COMPONENT, DrawingAlign],
[DRAWING_GROUP_COMPONENT, DrawingGroup],
[DRAWING_TRANSFORM_COMPONENT, DrawingTransform],
[IMAGE_CROPPER_COMPONENT, ImageCropper],
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
BottomIcon,
DrawingCropIcon: CropIcon,
DrawingDeleteIcon: DeleteIcon,
DrawingEditIcon: DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
}));
}
}

View File

@@ -0,0 +1,934 @@
/**
* 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 {
DrawingTypeEnum,
ICommandInfo,
IDrawingParam,
IDrawingSearch,
ITransformState,
Nullable,
} from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingOrderMapParam } from '@univerjs/drawing';
import type { BaseObject, Image, IShapeProps, Scene, Shape } from '@univerjs/engine-render';
import type { ISetDrawingAlignOperationParams } from '../commands/operations/drawing-align.operation';
import {
checkIfMove,
Disposable,
ICommandService,
IUniverInstanceService,
toDisposable,
} from '@univerjs/core';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import {
DRAWING_OBJECT_LAYER_INDEX,
DrawingGroupObject,
Group,
IRenderManagerService,
RENDER_CLASS_TYPE,
} from '@univerjs/engine-render';
import { AlignType, SetDrawingAlignOperation } from '../commands/operations/drawing-align.operation';
import { CloseImageCropOperation } from '../commands/operations/image-crop.operation';
import { ensureDrawingRenderLayer } from '../services/drawing-render.service';
import { getUpdateParams } from '../utils/get-update-params';
import { disposeDrawingRenderObject, getCurrentUnitInfo, insertGroupObject, syncGroupRotateEnabled } from './utils';
interface IDrawingTransformCache {
unitId: string;
subUnitId: string;
drawingId: string;
drawingType: DrawingTypeEnum;
transform: ITransformState;
}
interface IDrawingTransformStateWithClipBounds extends ITransformState {
clipBounds?: Nullable<{ left: number; top: number; width: number; height: number }>;
}
interface IDrawingRefreshMetadata {
hidden?: boolean;
behindText?: boolean;
selectable?: boolean;
}
type IDrawingParamWithRefreshMetadata = IDrawingParam & IDrawingRefreshMetadata;
function hasRefreshMetadata(refreshParam: IDrawingSearch): refreshParam is IDrawingSearch & IDrawingRefreshMetadata {
return 'hidden' in refreshParam || 'behindText' in refreshParam || 'selectable' in refreshParam;
}
function syncDrawingHiddenState(shape: BaseObject, drawingParam: IDrawingParamWithRefreshMetadata): void {
if (!('hidden' in drawingParam)) {
return;
}
drawingParam.hidden === true ? shape.hide() : shape.show();
}
function syncDrawingSelectableState(
shape: BaseObject,
drawingParam: IDrawingParamWithRefreshMetadata,
scene: Scene,
drawingManagerService: IDrawingManagerService
): void {
const previousDrawing = drawingManagerService.getOldDrawingByParam(drawingParam);
// Some drawing types are eventless by design. Only override that behavior when selectable is explicitly
// managed; deleting a previously present field means restoring Shape's default selectable state.
if (!('selectable' in drawingParam) && (!previousDrawing || !('selectable' in previousDrawing))) {
return;
}
const selectable = drawingParam.selectable !== false;
shape.evented = selectable;
if (selectable) {
return;
}
// Disabling picking must also discard stale selection state, otherwise the transformer remains interactive.
scene.getTransformer()?.clearControlByIds([shape.oKey]);
const focusedDrawings = drawingManagerService.getFocusDrawings();
const remainingDrawings = focusedDrawings.filter(({ unitId, subUnitId, drawingId }) =>
unitId !== drawingParam.unitId || subUnitId !== drawingParam.subUnitId || drawingId !== drawingParam.drawingId
);
if (remainingDrawings.length !== focusedDrawings.length) {
drawingManagerService.focusDrawing(remainingDrawings);
}
}
function mergeRefreshMetadata(drawingParam: IDrawingParam, refreshParam: IDrawingSearch): IDrawingParamWithRefreshMetadata {
if (!hasRefreshMetadata(refreshParam)) {
return drawingParam;
}
return {
...drawingParam,
...refreshParam,
};
}
export class DrawingUpdateController extends Disposable {
constructor(
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService
) {
super();
this._initialize();
}
override dispose(): void {
super.dispose();
}
private _initialize() {
this._recoveryImages();
this._drawingAddListener();
this._drawingRemoveListener();
this._drawingUpdateListener();
this._commandExecutedListener();
this._drawingArrangeListener();
this._drawingGroupListener();
this._drawingRefreshListener();
this._drawingVisibleListener();
}
private _recoveryImages() {
const drawingList = this._drawingManagerService.drawingManagerData;
const info = getCurrentUnitInfo(this._currentUniverService);
if (info == null) {
return;
}
const { unitId: currentUnitId, subUnitId: currentSubUnitId } = info;
Object.keys(drawingList).forEach((unitId) => {
Object.keys(drawingList[unitId]).forEach((subUnitId) => {
const drawingMap = drawingList[unitId][subUnitId].data;
if (drawingMap == null || unitId !== currentUnitId || subUnitId !== currentSubUnitId) {
return;
}
Object.keys(drawingMap).forEach((drawingId) => {
const drawing = drawingMap[drawingId];
if (drawing) {
this._insertDrawing([{ unitId, subUnitId, drawingId }]);
}
});
});
});
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetDrawingAlignOperation.id) {
const params = command.params as ISetDrawingAlignOperationParams;
if (params == null) {
return;
}
this._drawingAlign(params);
}
})
);
}
private _drawingGroupListener() {
this.disposeWithMe(
this._drawingManagerService.group$.subscribe((params) => {
this._groupDrawings(params);
})
);
this.disposeWithMe(
this._drawingManagerService.ungroup$.subscribe((params) => {
this._ungroupDrawings(params);
})
);
}
// private _drawingGroup(params: ISetImageGroupOperationParams) {
// const { groupType } = params;
// const drawings = this._drawingManagerService.getFocusDrawings();
// if (drawings.length === 0) {
// return;
// }
// switch (groupType) {
// case GroupType.group:
// this._groupDrawings(drawings);
// break;
// case GroupType.regroup:
// this._regroupDrawings(drawings);
// break;
// case GroupType.ungroup:
// this._ungroupDrawings(drawings);
// break;
// default:
// break;
// }
// }
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
const transformer = scene.getTransformerByCreate();
return { scene, transformer };
}
private _groupDrawings(drawings: IDrawingGroupUpdateParam[]) {
drawings.forEach((drawing) => {
this._groupDrawing(drawing);
});
}
private _groupDrawing(params: IDrawingGroupUpdateParam) {
const { parent, children } = params;
const { unitId, subUnitId, drawingId } = parent;
const renderObject = this._getSceneAndTransformerByDrawingSearch(parent.unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
const objects: BaseObject[] = [];
children.forEach((drawing) => {
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch(drawing);
const object = scene.getObjectIncludeInGroup(drawingShapeKey);
if (object == null || objects.includes(object)) {
return;
}
objects.push(object);
const { transform } = drawing;
if (transform == null) {
return;
}
if (object.classType === RENDER_CLASS_TYPE.GROUP) {
object.transformByState({ left: transform.left, top: transform.top });
} else {
object.transformByState(transform);
}
// const group = object?.ancestorGroup;
// if (group != null && objects.includes(group) === false) {
// objects.push(group);
// } else if (object != null && objects.includes(object) === false) {
// objects.push(object);
// }
});
if (objects.length === 0) {
return;
}
const groupKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const group = new DrawingGroupObject(groupKey);
scene.addObject(group, DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
group.addObjects(...objects);
syncGroupRotateEnabled(group, parent, scene, this._drawingManagerService, children);
if (parent.groupBaseBound) {
group.setBaseBound(parent.groupBaseBound);
}
if (parent.groupId) {
group.isInGroup = true;
insertGroupObject(
{ drawingId: parent.groupId, unitId, subUnitId },
group,
scene,
this._drawingManagerService
);
}
// group.reCalculateObjects();
parent.transform && group.transformByState({ left: parent.transform.left, top: parent.transform.top, width: parent.transform.width, height: parent.transform.height, angle: parent.transform.angle });
transformer.clearSelectedObjects();
transformer.setSelectedControl(group);
}
// private _regroupDrawings(drawings: IDrawingSearch[]) {
// const renderObject = this._getSceneAndTransformerByDrawingSearch(drawings[0].unitId);
// if (renderObject == null) {
// return;
// }
// const { scene, transformer } = renderObject;
// const objects: BaseObject[] = [];
// let firstGroup: Nullable<Group> = null;
// drawings.forEach((drawing) => {
// const imageShapeKey = getDrawingShapeKeyByDrawingSearch(drawing);
// const o = scene.getObject(imageShapeKey);
// if (o == null) {
// return true;
// }
// const group = o.ancestorGroup as Nullable<Group>;
// if (group != null && firstGroup == null) {
// firstGroup = group;
// } else if (group != null && !objects.includes(group)) {
// objects.push(group);
// } else if (!objects.includes(o)) {
// objects.push(o);
// }
// });
// if (firstGroup == null) {
// return;
// }
// if (objects.length === 0) {
// return;
// }
// (firstGroup as Group).addObjects(...objects);
// (firstGroup as Group).reCalculateObjects();
// transformer.clearSelectedObjects();
// transformer.setSelectedControl(firstGroup);
// }
private _ungroupDrawings(drawings: IDrawingGroupUpdateParam[]) {
drawings.forEach((drawing) => {
this._ungroupDrawing(drawing);
});
}
private _ungroupDrawing(drawing: IDrawingGroupUpdateParam) {
const { parent, children } = drawing;
const renderObject = this._getSceneAndTransformerByDrawingSearch(parent.unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
// const objects: BaseObject[] = [];
children.forEach((drawing) => {
const drawingKey = getDrawingShapeKeyByDrawingSearch(drawing);
const object = scene.getObjectIncludeInGroup(drawingKey);
if (object == null) {
return true;
}
if (object == null) {
return;
}
// objects.push(object);
const { transform } = drawing;
if (transform == null) {
return;
}
if (object.classType === RENDER_CLASS_TYPE.GROUP) {
object.transformByState({ left: transform.left, top: transform.top });
} else {
object.transformByState(transform);
}
// transform && object.transformByState(transform);
// const group = o.ancestorGroup as Nullable<Group>;
// if (group != null && !objects.includes(group)) {
// objects.push(group);
// } else if (o instanceof Group && !objects.includes(o)) {
// objects.push(o);
// }
});
const groupKey = getDrawingShapeKeyByDrawingSearch(parent);
const group = scene.getObject(groupKey) as Group;
const { width, height } = group;
group.getObjects().forEach((object) => {
group.removeSelfObjectAndTransform(object.oKey, width, height);
});
group.dispose();
// if (objects.length === 0) {
// return;
// }
// objects.forEach((group) => {
// const { width, height } = group;
// group.getObjects().forEach((object) => {
// group.removeSelfObjectAndTransform(object.oKey, width, height);
// });
// group.dispose();
// });
transformer.clearSelectedObjects();
}
private _drawingAlign(params: ISetDrawingAlignOperationParams) {
const { alignType } = params;
const drawings = params.drawings || this._drawingManagerService.getFocusDrawings();
if (alignType === AlignType.default) {
return;
}
const drawingTransformCaches: IDrawingTransformCache[] = [];
let minLeft = Number.POSITIVE_INFINITY;
let minTop = Number.POSITIVE_INFINITY;
let maxRight = Number.NEGATIVE_INFINITY;
let maxBottom = Number.NEGATIVE_INFINITY;
let drawingCount = 0;
drawings.forEach((drawing) => {
const { unitId, subUnitId, drawingId, drawingType } = drawing;
const drawingParam = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (drawingParam == null || drawingParam.transform == null) {
return;
}
drawingTransformCaches.push({
unitId,
subUnitId,
drawingId,
drawingType,
transform: drawingParam.transform,
});
const { left = 0, top = 0, width = 0, height = 0 } = drawingParam.transform;
minLeft = Math.min(minLeft, left);
minTop = Math.min(minTop, top);
maxRight = Math.max(maxRight, left + width);
maxBottom = Math.max(maxBottom, top + height);
drawingCount++;
});
if (drawingCount === 0) {
return;
}
this._sortDrawingTransform(drawingTransformCaches, alignType);
this._applyAlignType(drawingTransformCaches, alignType, minLeft, minTop, maxRight, maxBottom, drawingCount);
}
private _applyAlignType(drawingTransformCaches: IDrawingTransformCache[], alignType: AlignType, minLeft: number, minTop: number, maxRight: number, maxBottom: number, drawingCount: number) {
const averageHorizon = Math.round((maxRight - minLeft) / drawingCount * 10) / 10;
const averageVertical = Math.round((maxBottom - minTop) / drawingCount * 10) / 10;
const updateParams: IDrawingParam[] = [];
const renderObject = this._getSceneAndTransformerByDrawingSearch(drawingTransformCaches[0].unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
drawingTransformCaches.forEach((drawingTransformCache, index) => {
const { unitId, subUnitId, drawingId, transform, drawingType } = drawingTransformCache;
const { left = 0, top = 0, width = 0, height = 0 } = transform;
let newLeft = left;
let newTop = top;
switch (alignType) {
case AlignType.left:
newLeft = minLeft;
break;
case AlignType.center:
newLeft = minLeft + (maxRight - minLeft) / 2 - width / 2;
break;
case AlignType.right:
newLeft = maxRight - width;
break;
case AlignType.top:
newTop = minTop;
break;
case AlignType.middle:
newTop = minTop + (maxBottom - minTop) / 2 - height / 2;
break;
case AlignType.bottom:
newTop = maxBottom - height;
break;
case AlignType.horizon:
newLeft = minLeft + averageHorizon * index;
break;
case AlignType.vertical:
newTop = minTop + averageVertical * index;
break;
default:
break;
}
if (newLeft !== left || newTop !== top) {
updateParams.push({
unitId,
subUnitId,
drawingId,
drawingType,
transform: {
left: newLeft,
top: newTop,
},
});
}
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
transformer.refreshControls().changeNotification();
}
private _sortDrawingTransform(drawingTransformCaches: IDrawingTransformCache[], alignType: AlignType) {
drawingTransformCaches.sort((a, b) => {
const aTransform = a.transform;
const bTransform = b.transform;
const {
left: aLeft = 0,
top: aTop = 0,
width: aWidth = 0,
height: aHeight = 0,
} = aTransform;
const {
left: bLeft = 0,
top: bTop = 0,
width: bWidth = 0,
height: bHeight = 0,
} = bTransform;
switch (alignType) {
case AlignType.left:
return aLeft - bLeft;
case AlignType.center:
return aLeft + aWidth / 2 - (bLeft + bWidth / 2);
case AlignType.right:
return aLeft + aWidth - (bLeft + bWidth);
case AlignType.top:
return aTop - bTop;
case AlignType.middle:
return aTop + aHeight / 2 - (bTop + bHeight / 2);
case AlignType.bottom:
return aTop + aHeight - (bTop + bHeight);
case AlignType.horizon:
return aLeft + aWidth / 2 - (bLeft + bWidth / 2);
case AlignType.vertical:
return aTop + aHeight / 2 - (bTop + bHeight / 2);
default:
return 0;
}
});
}
private _drawingArrangeListener() {
this.disposeWithMe(
this._drawingManagerService.order$.subscribe((params) => {
this._drawingArrange(params);
})
);
}
private _drawingArrange(params: IDrawingOrderMapParam) {
const { unitId, subUnitId, drawingIds } = params;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
drawingIds.forEach((drawingId) => {
const oKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const drawingShapes = scene.fuzzyMathObjects(oKey, true) as Shape<IShapeProps>[];
if (drawingShapes == null || drawingShapes.length === 0) {
return;
}
const index = this._drawingManagerService.getDrawingOrder(unitId, subUnitId).indexOf(drawingId);
for (const shape of drawingShapes) {
shape.setProps({ zIndex: index });
shape.makeDirty();
}
});
}
private _sceneListenerOnDrawingMap: WeakSet<Scene> = new WeakSet();
private _drawingAddListener() {
this.disposeWithMe(
this._drawingManagerService.add$.subscribe((params) => {
this._insertDrawing(params);
})
);
}
private _insertDrawing(params: IDrawingSearch[]) {
const sceneList: Scene[] = [];
(params).forEach((param) => {
const { unitId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (!sceneList.includes(scene)) {
sceneList.push(scene);
}
});
sceneList.forEach((scene) => {
if (this._sceneListenerOnDrawingMap.has(scene)) {
return;
}
this._addListenerOnDrawing(scene);
this._sceneListenerOnDrawingMap.add(scene);
});
}
private _drawingRemoveListener() {
this.disposeWithMe(
this._drawingManagerService.remove$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (disposeDrawingRenderObject(scene, { unitId, subUnitId, drawingId })) {
scene.getTransformer()?.clearSelectedObjects();
}
});
})
);
}
private _drawingUpdateListener() {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const { transform, drawingType } = drawingParam;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
if (transform == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
// getObject() excludes hidden and grouped children, but both still need model updates and may
// become visible again as part of this notification.
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey) as Image;
if (drawingShape == null) {
return true;
}
drawingShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(drawingShape as Image).setClipBounds?.((transform as IDrawingTransformStateWithClipBounds).clipBounds);
syncDrawingHiddenState(drawingShape, drawingParam);
syncDrawingSelectableState(drawingShape, drawingParam, scene, this._drawingManagerService);
ensureDrawingRenderLayer(scene, drawingShape, drawingParam);
scene.getTransformer()?.debounceRefreshControls();
});
})
);
}
private _drawingRefreshListener() {
this.disposeWithMe(
this._drawingManagerService.refreshTransform$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const { transform } = drawingParam;
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey);
const drawingParamWithRefreshMetadata = mergeRefreshMetadata(drawingParam, param);
if (drawingShape == null) {
if (drawingParamWithRefreshMetadata.hidden === true) {
return true;
}
this._drawingManagerService.addNotification([{ unitId, subUnitId, drawingId }]);
return true;
}
const {
left = 0,
top = 0,
width = 0,
height = 0,
angle = 0,
flipX = false,
flipY = false,
skewX = 0,
skewY = 0,
} = transform;
drawingShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(drawingShape as Image).setClipBounds?.((transform as IDrawingTransformStateWithClipBounds).clipBounds);
syncDrawingHiddenState(drawingShape as BaseObject, drawingParamWithRefreshMetadata);
syncDrawingSelectableState(drawingShape as BaseObject, drawingParamWithRefreshMetadata, scene, this._drawingManagerService);
ensureDrawingRenderLayer(scene, drawingShape as BaseObject, drawingParamWithRefreshMetadata);
});
})
);
}
private _drawingVisibleListener() {
this.disposeWithMe(
this._drawingManagerService.visible$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId, visible } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
// Hidden objects are absent from getObject(), so use the unfiltered lookup to show them again.
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey);
if (drawingShape == null) {
return true;
}
if (visible) {
drawingShape.show();
} else {
drawingShape.hide();
}
});
})
);
}
private _filterUpdateParams(params: Nullable<IDrawingParam>[], startTransforms: Nullable<ITransformState[]>) {
return params.filter((param, index) => {
if (param == null) {
return false;
}
const { transform } = param;
return checkIfMove(transform, startTransforms?.[index]);
}) as IDrawingParam[];
}
// group?.getObjects().forEach((o) => {
// const drawing = this._drawingManagerService.getDrawingOKey(o.oKey);
// if (drawing != null) {
// const { unitId, subUnitId, drawingId } = drawing;
// drawings.push({ unitId, subUnitId, drawingId });
// }
// });
private _addListenerOnDrawing(scene: Scene) {
const transformer = scene.getTransformerByCreate();
let startTransforms: Nullable<ITransformState[]> = null;
this.disposeWithMe(
toDisposable(
transformer.changeStart$.subscribe((state) => {
const { objects } = state;
const objectArray = Array.from(objects.values());
const drawings: IDrawingSearch[] = [];
startTransforms = objectArray.map((object) => {
const { left, top, height, width, angle, oKey, isInGroup } = object;
const drawing = this._drawingManagerService.getDrawingOKey(oKey);
if (isInGroup || object instanceof Group) {
let group = object.ancestorGroup as Group;
if (group == null && object instanceof Group) {
group = object;
}
if (group == null) {
return null;
}
const groupDrawing = this._drawingManagerService.getDrawingOKey(group.oKey);
if (groupDrawing) {
const { unitId, subUnitId, drawingId } = groupDrawing;
drawings.push({ unitId, subUnitId, drawingId });
const { left, top, height, width, angle } = group;
return { left, top, height, width, angle };
}
} else if (drawing != null) {
const { unitId, subUnitId, drawingId } = drawing;
drawings.push({ unitId, subUnitId, drawingId });
return { left, top, height, width, angle };
}
return null;
}).filter((transform) => transform != null) as ITransformState[];
if (drawings.length > 0) {
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, drawings);
} else {
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, []);
}
})
)
);
this.disposeWithMe(
toDisposable(
transformer.changeEnd$.subscribe((state) => {
const { objects } = state;
const params = this._filterUpdateParams(getUpdateParams(objects, this._drawingManagerService), startTransforms);
if (params.length > 0) {
// this._drawingManagerService.batchUpdate(params);
this._drawingManagerService.featurePluginUpdateNotification(params);
}
})
)
);
}
}

View File

@@ -0,0 +1,525 @@
/**
* 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 { ICommandInfo, IDrawingSearch, ISrcRect, ITransformState, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Scene } from '@univerjs/engine-render';
import type { ICloseImageCropOperationParams, IOpenImageCropOperationBySrcRectParams } from '../commands/operations/image-crop.operation';
import type { LocaleKey } from '../locale/types';
import {
checkIfMove,
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService, precisionTo } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, of, switchMap } from 'rxjs';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
OpenImageCropOperation,
} from '../commands/operations/image-crop.operation';
import { getImageCropRect } from '../utils/image-crop-transform';
import { ImageCropperObject } from '../views/crop/image-cropper-object';
interface IImageCropSnapshot {
transform: ITransformState;
srcRect: Nullable<ISrcRect>;
}
interface IImageCropSession {
scene: Scene;
imageShape: Image;
imageCropperObject: ImageCropperObject;
}
export class ImageCropperController extends Disposable {
private _sceneListenerOnImageMap: WeakSet<Scene> = new WeakSet();
private readonly _cropShortcutDisposables = new DisposableCollection();
private readonly _cropSnapshots = new WeakMap<ImageCropperObject, IImageCropSnapshot>();
private _pendingCropSnapshot: Nullable<IImageCropSnapshot> = null;
private _activeCropSession: Nullable<IImageCropSession> = null;
private readonly _cropping$ = new BehaviorSubject(false);
readonly cropping$ = this._cropping$.asObservable();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();
this.disposeWithMe(this._cropShortcutDisposables);
this.disposeWithMe({ dispose: () => this._cropping$.complete() });
this._init();
}
private _init(): void {
this._initOpenCrop();
this._initCloseCrop();
this._initAutoCrop();
}
private _initAutoCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== AutoImageCropOperation.id) {
return;
}
const params = command.params as IOpenImageCropOperationBySrcRectParams;
if (params == null) {
return;
}
const { cropType } = params;
const drawingParams = this._drawingManagerService.getFocusDrawings();
if (drawingParams.length !== 1) {
return;
}
const drawingParam = drawingParams[0];
const { unitId, subUnitId, drawingId } = drawingParam;
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return true;
}
const imageCropperObject = this._searchCropObject(scene);
if (imageCropperObject != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
if (imageShape == null) {
return;
}
this._pendingCropSnapshot = this._captureCropSnapshot(imageShape);
try {
this._updateCropperObject(cropType, imageShape);
this._commandService.syncExecuteCommand(OpenImageCropOperation.id, { unitId, subUnitId, drawingId });
} finally {
this._pendingCropSnapshot = null;
}
})
);
}
private _calculateSrcRectByRatio(left: number, top: number, width: number, height: number, numerator: number, denominator: number) {
const srcRatio = width / height;
const ratio = numerator / denominator;
let newWidth = width;
let newHeight = height;
if (srcRatio > ratio) {
newWidth = height * ratio;
} else {
newHeight = width / ratio;
}
const newLeft = (width - newWidth) / 2;
const newTop = (height - newHeight) / 2;
return {
left: precisionTo(newLeft, 1),
top: precisionTo(newTop, 1),
right: precisionTo(width - (newLeft + newWidth), 1),
bottom: precisionTo(height - (newTop + newHeight), 1),
};
}
private _updateCropperObject(cropType: CropType, imageShape: Image) {
const { left, top, width, height } = imageShape.calculateTransformWithSrcRect();
let newSrcRect: Nullable<ISrcRect>;
switch (cropType) {
case CropType.R1_1:
// 1:1
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 1, 1);
break;
case CropType.R16_9:
// 16:9
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 16, 9);
break;
case CropType.R9_16:
// 9:16
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 9, 16);
break;
case CropType.R5_4:
// 5:4
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 5, 4);
break;
case CropType.R4_5:
// 4:5
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 5);
break;
case CropType.R4_3:
// 4:3
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 3);
break;
case CropType.R3_4:
// 3:4
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 4);
break;
case CropType.R3_2:
// 3:2
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 2);
break;
case CropType.R2_3:
// 2:3
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 2, 3);
break;
case CropType.FREE:
default:
break;
}
if (newSrcRect == null) {
return;
}
imageShape.setSrcRect(newSrcRect);
const { left: newLeft = 0, top: newTop = 0, bottom: newBottom = 0, right: newRight = 0 } = newSrcRect;
imageShape.transformByStateCloseCropper({
left: left + newLeft,
top: top + newTop,
width: width - newRight - newLeft,
height: height - newBottom - newTop,
});
}
private _initOpenCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== OpenImageCropOperation.id) {
return;
}
const params = command.params as IDrawingSearch;
if (params == null) {
return;
}
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const { unitId, subUnitId, drawingId } = params;
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return true;
}
if (!this._sceneListenerOnImageMap.has(scene)) {
this._addListenerOnImage(scene);
this._sceneListenerOnImageMap.add(scene);
}
const imageData = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (imageData == null) {
return;
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
const transformer = scene.getTransformer();
transformer?.clearControls();
const imageCropperObject = new ImageCropperObject(`${imageShapeKey}-crop`, {
srcRect: imageShape.srcRect,
prstGeom: imageShape.prstGeom,
applyTransform: imageShape.calculateTransformWithSrcRect(),
});
this._cropSnapshots.set(imageCropperObject, this._pendingCropSnapshot ?? this._captureCropSnapshot(imageShape));
this._pendingCropSnapshot = null;
scene.addObject(imageCropperObject, imageShape.getLayerIndex() + 1).attachTransformerTo(imageCropperObject);
this._activeCropSession = { scene, imageShape, imageCropperObject };
this._cropping$.next(true);
transformer?.createControlForCopper(imageCropperObject);
this._addHoverForImageCopper(imageCropperObject);
imageShape.openRenderByCropper();
transformer?.refreshControls();
imageCropperObject.makeDirty(true);
this._registerCropShortcuts(imageCropperObject);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{ unitId, subUnitId, drawingId }]);
this._layoutService.focus();
})
);
}
private _searchCropObject(scene: Scene) {
const objects = scene.getAllObjectsByOrder();
for (const object of objects) {
if (object instanceof ImageCropperObject) {
return object as ImageCropperObject;
}
}
}
private _initCloseCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== CloseImageCropOperation.id) {
return;
}
const cropSession = this._activeCropSession;
if (cropSession == null) {
return;
}
this._activeCropSession = null;
const { scene, imageShape, imageCropperObject } = cropSession;
const transformer = scene.getTransformerByCreate();
transformer.detachFrom(imageCropperObject);
transformer.clearCopperControl();
const params = command.params as ICloseImageCropOperationParams | undefined;
if (params?.isCancel) {
this._restoreCropSnapshot(imageShape, imageCropperObject);
} else {
const srcRect = getImageCropRect(imageShape, imageCropperObject);
const drawingParam = this._drawingManagerService.getDrawingOKey(imageShape.oKey);
if (drawingParam != null) {
const { left, top, height, width } = imageCropperObject;
this._drawingManagerService.featurePluginUpdateNotification([{
...drawingParam,
transform: {
...drawingParam.transform,
left,
top,
height,
width,
},
srcRect: srcRect.srcRectAngle,
}] as IImageData[]);
}
imageShape.setSrcRect({ ...srcRect.srcRectAngle });
}
imageShape.closeRenderByCropper();
imageShape.makeDirty(true);
transformer.refreshControls();
this._cropSnapshots.delete(imageCropperObject);
imageCropperObject.dispose();
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
})
);
const sheetUnit$ = this._univerInstanceService
.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(
switchMap((workbook) => workbook ? workbook.activeSheet$ : of(null))
);
this.disposeWithMe(sheetUnit$.subscribe(() => {
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
}));
}
private _getApplyObjectByCropObject(cropObject: ImageCropperObject): Nullable<Image> {
const cropOKey = cropObject.oKey;
const applyOKey = cropOKey.slice(0, cropOKey.length - 5);
const scene = cropObject.getScene();
if (!scene) return null;
const applyObject = scene.getObject(applyOKey) as Image;
if (applyObject == null) {
return null;
}
return applyObject;
}
private _captureCropSnapshot(imageShape: Image): IImageCropSnapshot {
return {
transform: imageShape.getState(),
srcRect: imageShape.srcRect == null ? imageShape.srcRect : { ...imageShape.srcRect },
};
}
private _restoreCropSnapshot(imageShape: Image, imageCropperObject: ImageCropperObject): void {
const snapshot = this._cropSnapshots.get(imageCropperObject);
if (snapshot) {
imageShape.transformByStateCloseCropper(snapshot.transform);
imageShape.setSrcRect(snapshot.srcRect);
}
}
private _registerCropShortcuts(imageCropperObject: ImageCropperObject): void {
this._cropShortcutDisposables.dispose();
this._cropShortcutDisposables.add(imageCropperObject.onDispose$.subscribeEvent(() => {
this._activeCropSession = null;
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
priority: 1000,
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
priority: 1000,
staticParameters: { isCancel: true },
}));
}
private _addListenerOnImage(scene: Scene) {
const transformer = scene.getTransformerByCreate();
let startTransform: Nullable<ITransformState> = null;
this.disposeWithMe(
transformer.changeStart$.subscribe((state) => {
const { objects } = state;
const cropObject = objects.values().next().value as ImageCropperObject;
if (cropObject == null || !(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
startTransform = { left, top, height, width, angle };
transformer.clearCopperControl();
})
);
this.disposeWithMe(
transformer.changeEnd$.subscribe((state) => {
const { objects } = state;
const cropObject = objects.values().next().value as ImageCropperObject;
if (cropObject == null || !(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
if (!checkIfMove({ left, top, height, width, angle }, startTransform)) {
// Pointer-down removes the handles even when the gesture ends without a drag.
transformer.createControlForCopper(cropObject);
return;
}
const applyObject = this._getApplyObjectByCropObject(cropObject);
if (applyObject == null) {
return;
}
const srcRect = getImageCropRect(applyObject, cropObject);
cropObject.refreshSrcRect(srcRect.srcRect, applyObject.getState());
// cropObject.imageData = {
// ...cropObject.imageData,
// srcRect: srcRect.srcRect,
// } as IImageData;
transformer.createControlForCopper(cropObject);
})
);
this._endCropListener(scene);
}
private _addHoverForImageCopper(o: ImageCropperObject) {
this.disposeWithMe(
o.onPointerEnter$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.MOVE;
})
);
this.disposeWithMe(
o.onPointerLeave$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.DEFAULT;
})
);
}
private _endCropListener(scene: Scene) {
const transformer = scene.getTransformerByCreate();
this.disposeWithMe(
transformer.clearControl$.subscribe((changeSelf) => {
if (changeSelf === true) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
})
);
}
}

View File

@@ -0,0 +1,298 @@
/**
* 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 { ICommandInfo, IDrawingSearch, Nullable } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Image, Scene } from '@univerjs/engine-render';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
Inject,
IUniverInstanceService,
toDisposable,
} from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, IRenderManagerService } from '@univerjs/engine-render';
import { bufferTime, filter, map } from 'rxjs';
import { ImageResetSizeOperation } from '../commands/operations/image-reset-size.operation';
import { DrawingRenderService, ensureDrawingRenderLayer } from '../services/drawing-render.service';
import { getCurrentUnitInfo } from './utils';
export class ImageUpdateController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService
) {
super();
this._initialize();
}
override dispose(): void {
super.dispose();
}
private _initialize() {
this._drawingAddListener();
this._commandExecutedListener();
this._imageUpdateListener();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === ImageResetSizeOperation.id) {
const params = command.params as IDrawingSearch[];
if (params == null) {
return;
}
this._resetImageSize(params);
}
})
);
}
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
const transformer = scene.getTransformerByCreate();
return { scene, transformer };
}
private _resetImageSize(params: IDrawingSearch[]) {
const updateParams: IImageData[] = [];
const sceneList: Scene[] = [];
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (imageShape == null) {
return true;
}
const imageData = this._drawingManagerService.getDrawingByParam(param);
if (imageData == null) {
return true;
}
if (imageData.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
(imageShape as Image).resetSize();
const { width, height } = (imageShape as Image).getNativeSize();
if (sceneList.includes(scene) === false) {
sceneList.push(scene);
}
updateParams.push({
...imageData,
transform: {
...imageData.transform,
height,
width,
angle: 0,
},
srcRect: null,
prstGeom: null,
} as IImageData);
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
sceneList.forEach((scene) => {
const transformer = scene.getTransformerByCreate();
transformer.refreshControls().changeNotification();
});
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, params);
}
private _drawingAddListener() {
this.disposeWithMe(
this._drawingManagerService.add$
.pipe(
bufferTime(33),
filter((batches) => batches.length > 0),
map((batches) => batches.flat()),
map((items) => {
const map = new Map<string, IDrawingSearch>();
for (const it of items) {
map.set(`${it.unitId}|${it.subUnitId}|${it.drawingId}`, it);
}
return [...map.values()];
}),
filter((items) => items.length > 0)
)
.subscribe((uniqueParams) => {
this._insertImages(uniqueParams);
})
);
}
private _insertImages(params: IDrawingSearch[]) {
(params).forEach(async (param) => {
const { unitId, subUnitId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
const currentSubUnitId = getCurrentUnitInfo(this._currentUniverService, unitId)?.subUnitId;
if (renderObject == null || currentSubUnitId !== subUnitId) {
return;
}
const imageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (imageParam == null) {
return;
}
const images = await this._drawingRenderService.renderImages(imageParam, renderObject.scene);
// Image loading is asynchronous, so commands may have changed its transform or metadata while
// renderImages was pending. Refresh from the current model instead of the stale pre-load snapshot.
const currentImageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (currentImageParam) {
this._drawingManagerService.refreshTransform([currentImageParam]);
}
if (images == null || images.length === 0) {
return;
}
for (const image of images) {
this._addHoverForImage(image);
this._addDialogForImage(image);
}
});
}
private _imageUpdateListener() {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (drawingParam == null) {
return;
}
const { transform, drawingType, srcRect, prstGeom, source, imageSourceType } = drawingParam;
if (drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(drawingShapeKey) as Image;
if (imageShape == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(imageShape as Image & { setClipBounds?: (clipBounds?: unknown) => void }).setClipBounds?.((transform as { clipBounds?: unknown }).clipBounds);
ensureDrawingRenderLayer(scene, imageShape, drawingParam);
imageShape.setSrcRect(srcRect);
imageShape.setPrstGeom(prstGeom);
if (source != null && source.length > 0 && (imageSourceType === ImageSourceType.BASE64 || imageSourceType === ImageSourceType.URL)) {
imageShape.changeSource(source);
}
});
})
);
}
private _addHoverForImage(o: Image) {
this.disposeWithMe(
toDisposable(
o.onPointerEnter$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.GRAB;
})
)
);
this.disposeWithMe(
toDisposable(
o.onPointerLeave$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.DEFAULT;
})
)
);
}
private _addDialogForImage(o: Image) {
const preview = () => {
const native = o.getNative();
if (native) {
this._drawingRenderService.previewImage(`${o.oKey}-viewer-dialog`, native.src, o.getNativeSize().width, o.getNativeSize().height);
}
};
this.disposeWithMe(
toDisposable(
o.onDblclick$.subscribeEvent(preview)
)
);
}
}

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import {
BottomIcon,
CropIcon,
DeleteIcon,
DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
} from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { COMPONENT_MOBILE_IMAGE_POPUP_MENU } from '../../views/image-popup-menu/component-name';
import { MobileImagePopupMenu } from '../../views/image-popup-menu/MobileImagePopupMenu';
import { DRAWING_ALIGN_COMPONENT, DRAWING_ARRANGE_COMPONENT, DRAWING_GROUP_COMPONENT, DRAWING_TRANSFORM_COMPONENT, IMAGE_CROPPER_COMPONENT } from '../../views/panel/component-name';
import { MobileDrawingArrange } from '../../views/panel/MobileDrawingArrange';
import { MobileDrawingAlign, MobileDrawingGroup, MobileDrawingTransform } from '../../views/panel/MobileDrawingControls';
import { MobileImageCropper } from '../../views/panel/MobileImageCropper';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) componentManager: ComponentManager,
@Inject(IconManager) iconManager: IconManager
) {
super();
this.disposeWithMe(componentManager.register(DRAWING_ALIGN_COMPONENT, MobileDrawingAlign));
this.disposeWithMe(componentManager.register(DRAWING_GROUP_COMPONENT, MobileDrawingGroup));
this.disposeWithMe(componentManager.register(DRAWING_TRANSFORM_COMPONENT, MobileDrawingTransform));
this.disposeWithMe(componentManager.register(DRAWING_ARRANGE_COMPONENT, MobileDrawingArrange));
this.disposeWithMe(componentManager.register(IMAGE_CROPPER_COMPONENT, MobileImageCropper));
this.disposeWithMe(componentManager.register(COMPONENT_MOBILE_IMAGE_POPUP_MENU, MobileImagePopupMenu));
this.disposeWithMe(iconManager.register({
BottomIcon,
DrawingCropIcon: CropIcon,
DrawingDeleteIcon: DeleteIcon,
DrawingEditIcon: DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
}));
}
}

View File

@@ -0,0 +1,474 @@
/**
* 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 { ICommandInfo, IDrawingSearch, ISrcRect, ITransformState, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Scene } from '@univerjs/engine-render';
import type { ICloseImageCropOperationParams, IOpenImageCropOperationBySrcRectParams } from '../../commands/operations/image-crop.operation';
import type { LocaleKey } from '../../locale/types';
import {
checkIfMove,
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService, precisionTo } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, of, switchMap } from 'rxjs';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
OpenImageCropOperation,
} from '../../commands/operations/image-crop.operation';
import { getImageCropRect } from '../../utils/image-crop-transform';
import { ImageCropperObject } from '../../views/crop/image-cropper-object';
const MOBILE_CROP_ANCHOR_HIT_SIZE = 44;
interface IImageCropSnapshot {
transform: ITransformState;
srcRect: Nullable<ISrcRect>;
}
interface IImageCropSession {
scene: Scene;
imageShape: Image;
imageCropperObject: ImageCropperObject;
}
export class MobileImageCropperController extends Disposable {
private _sceneListenerOnImageMap: WeakSet<Scene> = new WeakSet();
private readonly _cropShortcutDisposables = new DisposableCollection();
private readonly _cropSnapshots = new WeakMap<ImageCropperObject, IImageCropSnapshot>();
private _pendingCropSnapshot: Nullable<IImageCropSnapshot> = null;
private _activeCropSession: Nullable<IImageCropSession> = null;
private readonly _cropping$ = new BehaviorSubject(false);
readonly cropping$ = this._cropping$.asObservable();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();
this.disposeWithMe(this._cropShortcutDisposables);
this.disposeWithMe({ dispose: () => this._cropping$.complete() });
this._init();
}
private _init(): void {
this._initOpenCrop();
this._initCloseCrop();
this._initAutoCrop();
}
private _initAutoCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== AutoImageCropOperation.id) {
return;
}
const params = command.params as IOpenImageCropOperationBySrcRectParams;
if (params == null) {
return;
}
const drawingParams = this._drawingManagerService.getFocusDrawings();
if (drawingParams.length !== 1) {
return;
}
const { cropType } = params;
const { unitId, subUnitId, drawingId } = drawingParams[0];
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (scene == null) {
return true;
}
if (this._searchCropObject(scene) != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
this._pendingCropSnapshot = this._captureCropSnapshot(imageShape);
try {
this._updateCropperObject(cropType, imageShape);
this._commandService.syncExecuteCommand(OpenImageCropOperation.id, { unitId, subUnitId, drawingId });
} finally {
this._pendingCropSnapshot = null;
}
})
);
}
private _calculateSrcRectByRatio(left: number, top: number, width: number, height: number, numerator: number, denominator: number) {
const srcRatio = width / height;
const ratio = numerator / denominator;
let newWidth = width;
let newHeight = height;
if (srcRatio > ratio) {
newWidth = height * ratio;
} else {
newHeight = width / ratio;
}
const newLeft = (width - newWidth) / 2;
const newTop = (height - newHeight) / 2;
return {
left: precisionTo(newLeft, 1),
top: precisionTo(newTop, 1),
right: precisionTo(width - (newLeft + newWidth), 1),
bottom: precisionTo(height - (newTop + newHeight), 1),
};
}
private _updateCropperObject(cropType: CropType, imageShape: Image): void {
const { left, top, width, height } = imageShape.calculateTransformWithSrcRect();
let newSrcRect: Nullable<ISrcRect>;
switch (cropType) {
case CropType.R1_1:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 1, 1);
break;
case CropType.R16_9:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 16, 9);
break;
case CropType.R9_16:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 9, 16);
break;
case CropType.R5_4:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 5, 4);
break;
case CropType.R4_5:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 5);
break;
case CropType.R4_3:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 3);
break;
case CropType.R3_4:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 4);
break;
case CropType.R3_2:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 2);
break;
case CropType.R2_3:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 2, 3);
break;
case CropType.FREE:
default:
break;
}
if (newSrcRect == null) {
return;
}
imageShape.setSrcRect(newSrcRect);
const { left: newLeft = 0, top: newTop = 0, bottom: newBottom = 0, right: newRight = 0 } = newSrcRect;
imageShape.transformByStateCloseCropper({
left: left + newLeft,
top: top + newTop,
width: width - newRight - newLeft,
height: height - newBottom - newTop,
});
}
private _initOpenCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== OpenImageCropOperation.id) {
return;
}
const params = command.params as IDrawingSearch;
if (params == null) {
return;
}
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const { unitId, subUnitId, drawingId } = params;
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (scene == null) {
return true;
}
if (!this._sceneListenerOnImageMap.has(scene)) {
this._addListenerOnImage(scene);
this._sceneListenerOnImageMap.add(scene);
}
const imageData = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (imageData == null) {
return;
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
const transformer = scene.getTransformer();
transformer?.clearControls();
const imageCropperObject = new ImageCropperObject(`${imageShapeKey}-crop`, {
srcRect: imageShape.srcRect,
prstGeom: imageShape.prstGeom,
applyTransform: imageShape.calculateTransformWithSrcRect(),
});
imageCropperObject.transformerConfig = {
...imageCropperObject.transformerConfig,
cropAnchorHitSize: MOBILE_CROP_ANCHOR_HIT_SIZE,
};
this._cropSnapshots.set(imageCropperObject, this._pendingCropSnapshot ?? this._captureCropSnapshot(imageShape));
this._pendingCropSnapshot = null;
scene.addObject(imageCropperObject, imageShape.getLayerIndex() + 1).attachTransformerTo(imageCropperObject);
this._activeCropSession = { scene, imageShape, imageCropperObject };
this._cropping$.next(true);
transformer?.createControlForCopper(imageCropperObject);
this._addHoverForImageCopper(imageCropperObject);
imageShape.openRenderByCropper();
transformer?.refreshControls();
imageCropperObject.makeDirty(true);
this._registerCropShortcuts(imageCropperObject);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{ unitId, subUnitId, drawingId }]);
this._layoutService.focus();
})
);
}
private _searchCropObject(scene: Scene): ImageCropperObject | undefined {
for (const object of scene.getAllObjectsByOrder()) {
if (object instanceof ImageCropperObject) {
return object;
}
}
}
private _initCloseCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== CloseImageCropOperation.id) {
return;
}
const cropSession = this._activeCropSession;
if (cropSession == null) {
return;
}
this._activeCropSession = null;
const { scene, imageShape, imageCropperObject } = cropSession;
const transformer = scene.getTransformerByCreate();
transformer.detachFrom(imageCropperObject);
transformer.clearCopperControl();
const params = command.params as ICloseImageCropOperationParams | undefined;
if (params?.isCancel) {
this._restoreCropSnapshot(imageShape, imageCropperObject);
} else {
const srcRect = getImageCropRect(imageShape, imageCropperObject);
const drawingParam = this._drawingManagerService.getDrawingOKey(imageShape.oKey);
if (drawingParam != null) {
const { left, top, height, width } = imageCropperObject;
this._drawingManagerService.featurePluginUpdateNotification([{
...drawingParam,
transform: {
...drawingParam.transform,
left,
top,
height,
width,
},
srcRect: srcRect.srcRectAngle,
}] as IImageData[]);
}
imageShape.setSrcRect({ ...srcRect.srcRectAngle });
}
imageShape.closeRenderByCropper();
imageShape.makeDirty(true);
transformer.refreshControls();
this._cropSnapshots.delete(imageCropperObject);
imageCropperObject.dispose();
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
})
);
const sheetUnit$ = this._univerInstanceService
.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(switchMap((workbook) => workbook ? workbook.activeSheet$ : of(null)));
this.disposeWithMe(sheetUnit$.subscribe(() => {
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
}));
}
private _getApplyObjectByCropObject(cropObject: ImageCropperObject): Nullable<Image> {
const applyOKey = cropObject.oKey.slice(0, cropObject.oKey.length - 5);
const scene = cropObject.getScene();
if (!scene) {
return null;
}
return scene.getObject(applyOKey) as Image ?? null;
}
private _captureCropSnapshot(imageShape: Image): IImageCropSnapshot {
return {
transform: imageShape.getState(),
srcRect: imageShape.srcRect == null ? imageShape.srcRect : { ...imageShape.srcRect },
};
}
private _restoreCropSnapshot(imageShape: Image, imageCropperObject: ImageCropperObject): void {
const snapshot = this._cropSnapshots.get(imageCropperObject);
if (snapshot) {
imageShape.transformByStateCloseCropper(snapshot.transform);
imageShape.setSrcRect(snapshot.srcRect);
}
}
private _registerCropShortcuts(imageCropperObject: ImageCropperObject): void {
this._cropShortcutDisposables.dispose();
this._cropShortcutDisposables.add(imageCropperObject.onDispose$.subscribeEvent(() => {
this._activeCropSession = null;
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
priority: 1000,
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
priority: 1000,
staticParameters: { isCancel: true },
}));
}
private _addListenerOnImage(scene: Scene): void {
const transformer = scene.getTransformerByCreate();
let startTransform: Nullable<ITransformState> = null;
this.disposeWithMe(
transformer.changeStart$.subscribe((state) => {
const cropObject = state.objects.values().next().value as ImageCropperObject;
if (!(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
startTransform = { left, top, height, width, angle };
transformer.clearCopperControl();
})
);
this.disposeWithMe(
transformer.changeEnd$.subscribe((state) => {
const cropObject = state.objects.values().next().value as ImageCropperObject;
if (!(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
if (!checkIfMove({ left, top, height, width, angle }, startTransform)) {
transformer.createControlForCopper(cropObject);
return;
}
const applyObject = this._getApplyObjectByCropObject(cropObject);
if (applyObject == null) {
return;
}
const srcRect = getImageCropRect(applyObject, cropObject);
cropObject.refreshSrcRect(srcRect.srcRect, applyObject.getState());
transformer.createControlForCopper(cropObject);
})
);
this._endCropListener(scene);
}
private _addHoverForImageCopper(cropper: ImageCropperObject): void {
this.disposeWithMe(cropper.onPointerEnter$.subscribeEvent(() => {
cropper.cursor = CURSOR_TYPE.MOVE;
}));
this.disposeWithMe(cropper.onPointerLeave$.subscribeEvent(() => {
cropper.cursor = CURSOR_TYPE.DEFAULT;
}));
}
private _endCropListener(scene: Scene): void {
const transformer = scene.getTransformerByCreate();
this.disposeWithMe(
transformer.clearControl$.subscribe((changeSelf) => {
if (changeSelf === true) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
})
);
}
}

View File

@@ -0,0 +1,285 @@
/**
* 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 { ICommandInfo, IDrawingSearch, Nullable } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Image, Scene } from '@univerjs/engine-render';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
Inject,
IUniverInstanceService,
ThemeService,
toDisposable,
} from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, IRenderManagerService } from '@univerjs/engine-render';
import { bufferTime, combineLatest, filter, map } from 'rxjs';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { DrawingRenderService, ensureDrawingRenderLayer } from '../../services/drawing-render.service';
import { MobileImagePreviewButton } from '../../views/image-preview/mobile-image-preview-button';
import { getCurrentUnitInfo } from '../utils';
import { MobileImageCropperController } from './image-cropper.controller';
export class MobileImageUpdateController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService,
@Inject(ThemeService) private readonly _themeService: ThemeService,
@Inject(MobileImageCropperController) private readonly _imageCropperController: MobileImageCropperController
) {
super();
this._initialize();
}
private _initialize(): void {
this._drawingAddListener();
this._commandExecutedListener();
this._imageUpdateListener();
}
private _commandExecutedListener(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === ImageResetSizeOperation.id) {
const params = command.params as IDrawingSearch[];
if (params == null) {
return;
}
this._resetImageSize(params);
}
})
);
}
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
return { scene, transformer: scene.getTransformerByCreate() };
}
private _resetImageSize(params: IDrawingSearch[]): void {
const updateParams: IImageData[] = [];
const sceneList: Scene[] = [];
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (imageShape == null) {
return true;
}
const imageData = this._drawingManagerService.getDrawingByParam(param);
if (imageData == null) {
return true;
}
if (imageData.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
(imageShape as Image).resetSize();
const { width, height } = (imageShape as Image).getNativeSize();
if (!sceneList.includes(scene)) {
sceneList.push(scene);
}
updateParams.push({
...imageData,
transform: {
...imageData.transform,
height,
width,
angle: 0,
},
srcRect: null,
prstGeom: null,
} as IImageData);
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
sceneList.forEach((scene) => {
scene.getTransformerByCreate().refreshControls().changeNotification();
});
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, params);
}
private _drawingAddListener(): void {
this.disposeWithMe(
this._drawingManagerService.add$
.pipe(
bufferTime(33),
filter((batches) => batches.length > 0),
map((batches) => batches.flat()),
map((items) => {
const map = new Map<string, IDrawingSearch>();
for (const item of items) {
map.set(`${item.unitId}|${item.subUnitId}|${item.drawingId}`, item);
}
return [...map.values()];
}),
filter((items) => items.length > 0)
)
.subscribe((uniqueParams) => {
this._insertImages(uniqueParams);
})
);
}
private _insertImages(params: IDrawingSearch[]): void {
params.forEach(async (param) => {
const { unitId, subUnitId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
const currentSubUnitId = getCurrentUnitInfo(this._currentUniverService, unitId)?.subUnitId;
if (renderObject == null || currentSubUnitId !== subUnitId) {
return;
}
const imageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (imageParam == null) {
return;
}
const images = await this._drawingRenderService.renderImages(imageParam, renderObject.scene);
const currentImageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (currentImageParam) {
this._drawingManagerService.refreshTransform([currentImageParam]);
}
if (images == null || images.length === 0) {
return;
}
for (const image of images) {
this._addHoverForImage(image);
this._addDialogForImage(image, param);
}
});
}
private _imageUpdateListener(): void {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (drawingParam == null) {
return;
}
const { transform, drawingType, srcRect, prstGeom, source, imageSourceType } = drawingParam;
if (drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(drawingShapeKey) as Image;
if (imageShape == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(imageShape as Image & { setClipBounds?: (clipBounds?: unknown) => void }).setClipBounds?.((transform as { clipBounds?: unknown }).clipBounds);
ensureDrawingRenderLayer(scene, imageShape, drawingParam);
imageShape.setSrcRect(srcRect);
imageShape.setPrstGeom(prstGeom);
if (source != null && source.length > 0 && (imageSourceType === ImageSourceType.BASE64 || imageSourceType === ImageSourceType.URL)) {
imageShape.changeSource(source);
}
});
})
);
}
private _addHoverForImage(image: Image): void {
this.disposeWithMe(toDisposable(image.onPointerEnter$.subscribeEvent(() => {
image.cursor = CURSOR_TYPE.GRAB;
})));
this.disposeWithMe(toDisposable(image.onPointerLeave$.subscribeEvent(() => {
image.cursor = CURSOR_TYPE.DEFAULT;
})));
}
private _addDialogForImage(image: Image, param: IDrawingSearch): void {
const preview = () => {
const native = image.getNative();
if (native) {
this._drawingRenderService.previewImage(
`${image.oKey}-viewer-dialog`,
native.src,
image.getNativeSize().width,
image.getNativeSize().height
);
}
};
const scene = image.getScene();
if (scene) {
const button = new MobileImagePreviewButton(image, scene, this._themeService, preview);
const registration = this.disposeWithMe(() => button.dispose());
button.disposeWithMe(() => registration.dispose());
button.disposeWithMe(combineLatest([
this._drawingManagerService.focus$,
this._imageCropperController.cropping$,
]).subscribe(([drawings, cropping]) => {
button.setPreviewEnabled(!cropping && !drawings.some((drawing) =>
drawing.unitId === param.unitId && drawing.subUnitId === param.subUnitId && drawing.drawingId === param.drawingId));
}));
}
this.disposeWithMe(toDisposable(image.onDblclick$.subscribeEvent(preview)));
}
}

View File

@@ -0,0 +1,92 @@
/**
* 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, Inject, Injector } from '@univerjs/core';
import { BuiltInUIPart, connectInjector, IMenuManagerService, IUIPartsService } from '@univerjs/ui';
import {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../../commands/operations/drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeOperation,
} from '../../commands/operations/drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../../commands/operations/drawing-group.operation';
import { AutoImageCropOperation, CloseImageCropOperation, OpenImageCropOperation } from '../../commands/operations/image-crop.operation';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { menuSchema } from '../../menu/schema';
import { MobileCropActions } from '../../views/crop/MobileCropActions';
export class MobileDrawingUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IUIPartsService private readonly _uiPartsService: IUIPartsService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initMenus();
this._initCommands();
this._initUiParts();
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands(): void {
[
OpenImageCropOperation,
CloseImageCropOperation,
ImageResetSizeOperation,
SetDrawingAlignOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignBottomOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignVerticalOperation,
AutoImageCropOperation,
SetDrawingGroupOperation,
CancelDrawingGroupOperation,
SetDrawingArrangeOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initUiParts(): void {
this.disposeWithMe(this._uiPartsService.registerComponent(
BuiltInUIPart.CONTENT,
() => connectInjector(MobileCropActions, this._injector)
));
}
}

View File

@@ -0,0 +1,85 @@
/**
* 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 {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../commands/operations/drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeOperation,
} from '../commands/operations/drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../commands/operations/drawing-group.operation';
import { AutoImageCropOperation, CloseImageCropOperation, OpenImageCropOperation } from '../commands/operations/image-crop.operation';
import { ImageResetSizeOperation } from '../commands/operations/image-reset-size.operation';
import { menuSchema } from '../menu/schema';
export class DrawingUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
this._init();
}
private _init(): void {
this._initMenus();
this._initCommands();
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands() {
[
OpenImageCropOperation,
CloseImageCropOperation,
ImageResetSizeOperation,
SetDrawingAlignOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignBottomOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignVerticalOperation,
AutoImageCropOperation,
SetDrawingGroupOperation,
CancelDrawingGroupOperation,
SetDrawingArrangeOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
}

View File

@@ -0,0 +1,145 @@
/**
* 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, IDrawingSearch, IUniverInstanceService, Nullable, Workbook } from '@univerjs/core';
import type { IDrawingManagerService } from '@univerjs/drawing';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import { UniverInstanceType } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { DRAWING_OBJECT_LAYER_INDEX, DrawingGroupObject, Group } from '@univerjs/engine-render';
import { resolveDrawingUIRotateEnabled } from '../utils/rotate-enabled';
export function getDrawingRenderObject(scene: Scene, drawingSearch: IDrawingSearch): BaseObject | null {
const key = getDrawingShapeKeyByDrawingSearch(drawingSearch);
return scene.getObjectIncludeInGroup(key) ?? null;
}
export function disposeDrawingRenderObject(scene: Scene, drawingSearch: IDrawingSearch): boolean {
const object = getDrawingRenderObject(scene, drawingSearch);
if (object == null) {
return false;
}
object.dispose();
return true;
}
function getRenderObjectForDrawing(scene: Scene, drawing: IDrawingParam): BaseObject | null {
return getDrawingRenderObject(scene, drawing);
}
export function syncGroupRotateEnabled(
group: Group,
groupParam: IDrawingParam,
scene: Scene,
drawingManagerService: IDrawingManagerService,
children?: readonly IDrawingParam[]
): void {
const rotateEnabled = resolveDrawingUIRotateEnabled(groupParam, {
getChildren: (drawing) => {
if (children && drawing.drawingId === groupParam.drawingId && drawing.unitId === groupParam.unitId && drawing.subUnitId === groupParam.subUnitId) {
return children;
}
return drawingManagerService.getDrawingsByGroup(drawing);
},
getRenderObject: (drawing) => getRenderObjectForDrawing(scene, drawing),
});
group.transformerConfig = {
...group.transformerConfig,
rotateEnabled,
};
}
export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObject, scene: Scene, drawingManagerService: IDrawingManagerService) {
const groupParam = drawingManagerService.getDrawingByParam(objectParam);
if (groupParam == null) {
return;
}
const groupKey = getDrawingShapeKeyByDrawingSearch(objectParam);
const groupObject = scene.getObjectIncludeInGroup(groupKey);
if (groupObject && !(groupObject instanceof Group)) {
return;
}
if (groupObject != null) {
const objects = groupObject.getObjects();
for (const obj of objects) {
if (obj.oKey === object.oKey) {
return;
}
}
groupObject.addObject(object);
syncGroupRotateEnabled(groupObject, groupParam, scene, drawingManagerService);
return;
}
const group = new DrawingGroupObject(groupKey);
scene.addObject(group, DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
group.addObject(object);
syncGroupRotateEnabled(group, groupParam, scene, drawingManagerService);
const { transform, groupBaseBound } = groupParam;
if (groupBaseBound) {
group.setBaseBound(groupBaseBound);
}
if (groupParam.groupId) {
group.isInGroup = true;
insertGroupObject(
{ drawingId: groupParam.groupId, unitId: objectParam.unitId, subUnitId: objectParam.subUnitId },
group,
scene,
drawingManagerService
);
}
transform && group.transformByState(
{
left: transform.left,
top: transform.top,
angle: transform.angle,
width: transform.width,
height: transform.height,
}
);
}
export function getCurrentUnitInfo(currentUniverService: IUniverInstanceService, propUnitId?: string) {
const current = propUnitId ? currentUniverService.getUnit(propUnitId) : currentUniverService.getFocusedUnit();
if (current == null) {
return;
}
const unitId = current.getUnitId();
let subUnitId: Nullable<string>;
if (current.type === UniverInstanceType.UNIVER_SHEET) {
subUnitId = (current as Workbook).getActiveSheet()?.getSheetId();
} else if (current.type === UniverInstanceType.UNIVER_DOC) {
subUnitId = unitId;
} else if (current.type === UniverInstanceType.UNIVER_SLIDE) {
subUnitId = unitId;
}
return { unitId, subUnitId, current };
}