snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
311
packages/drawing-ui/src/controllers/__tests__/utils.spec.ts
Normal file
311
packages/drawing-ui/src/controllers/__tests__/utils.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
packages/drawing-ui/src/controllers/components.controller.ts
Normal file
80
packages/drawing-ui/src/controllers/components.controller.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
934
packages/drawing-ui/src/controllers/drawing-update.controller.ts
Normal file
934
packages/drawing-ui/src/controllers/drawing-update.controller.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
525
packages/drawing-ui/src/controllers/image-cropper.controller.ts
Normal file
525
packages/drawing-ui/src/controllers/image-cropper.controller.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
298
packages/drawing-ui/src/controllers/image-update.controller.ts
Normal file
298
packages/drawing-ui/src/controllers/image-update.controller.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
92
packages/drawing-ui/src/controllers/mobile/ui.controller.ts
Normal file
92
packages/drawing-ui/src/controllers/mobile/ui.controller.ts
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
85
packages/drawing-ui/src/controllers/ui.controller.ts
Normal file
85
packages/drawing-ui/src/controllers/ui.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
145
packages/drawing-ui/src/controllers/utils.ts
Normal file
145
packages/drawing-ui/src/controllers/utils.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user