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