/** * 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 { IDrawingSearch } from '@univerjs/core'; import { ContextService, DocumentDataModel, DrawingTypeEnum, ICommandService, IContextService, Injector, IPermissionService, IUniverInstanceService, PermissionService, toDisposable, } from '@univerjs/core'; import { setDocumentPermissionValue } from '@univerjs/docs'; import { DocDrawingService, IDocDrawingAdapterService, IDocDrawingService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing'; import { DocCanvasPopManagerService, MOBILE_DOC_ELEMENT_MENU } from '@univerjs/docs-ui'; import { IDrawingManagerService } from '@univerjs/drawing'; import { COMPONENT_MOBILE_IMAGE_POPUP_MENU, OpenImageCropOperation } from '@univerjs/drawing-ui'; import { IRenderManagerService } from '@univerjs/engine-render'; import { UnitAction } from '@univerjs/protocol'; import { IMenuManagerService } from '@univerjs/ui'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { EditDocDrawingOperation } from '../../commands/operations/edit-doc-drawing.operation'; import { MobileDocDrawingPopupMenuController } from '../../controllers/mobile/drawing-popup-menu.controller'; import { DocDrawingFloatingToolbarAdapterService } from '../../services/doc-drawing-floating-toolbar-adapter.service'; import { DocDrawingPopupMenuController } from '../drawing-popup-menu.controller'; function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mobile = false) { const unitId = 'doc-1'; const drawingId = 'drawing-1'; const injector = new Injector(); const createControl$ = new Subject(); const clearControl$ = new Subject(); const changing$ = new Subject(); const focus$ = new Subject(); const remove$ = new Subject(); const currentDocument$ = new Subject(); const disposedDocument$ = new Subject(); const renderCreated$ = new Subject(); const renderDisposed$ = new Subject(); const drawingObject = { oKey: drawingId }; const selectedObjects = new Map([[drawingId, drawingObject]]); const popupDisposable = { dispose: vi.fn(), canDispose: () => false, }; const drawing = { unitId, subUnitId: unitId, drawingId, drawingType, }; const documentDataModel = new DocumentDataModel({ id: unitId, body: { dataStream: '\b\r\n', customBlocks: [{ startIndex: 0, blockId: drawingId }], }, }); const transformer = { createControl$, clearControl$, changing$, getSelectedObjectMap: () => selectedObjects, clearSelectedObjects: vi.fn(), }; const scene = { getAllObjects: () => [], getTransformerByCreate: () => transformer, }; const executeCommand = vi.fn(); injector.add([ICommandService, { useValue: { executeCommand, onCommandExecuted: () => toDisposable(() => undefined), } as never, }]); const contextService = new ContextService(); injector.add([IContextService, { useValue: contextService }]); injector.add([IPermissionService, { useClass: PermissionService }]); injector.add([IUniverInstanceService, { useValue: { getAllUnitsForType: () => [documentDataModel], getCurrentTypeOfUnit$: () => currentDocument$, getTypeOfUnitDisposed$: () => disposedDocument$, getUnit: () => documentDataModel, getUnitType: () => undefined, } as never, }]); injector.add([IRenderManagerService, { useValue: { created$: renderCreated$, disposed$: renderDisposed$, getRenderUnitById: () => ({ scene }), has: () => true, } as never, }]); injector.add([IDrawingManagerService, { useValue: { focus$, remove$, focusDrawing: vi.fn(), getDrawingByParam: () => drawing, getDrawingOKey: () => drawing, getFocusDrawings: () => [], } as never, }]); injector.add([IDocDrawingAdapterService, { useValue: { getEditDrawingCommandInfo: () => null, } as never, }]); injector.add([IDocDrawingService, { useClass: DocDrawingService }]); const attachPopupToObject = vi.fn(() => popupDisposable); injector.add([DocCanvasPopManagerService, { useValue: { attachPopupToObject, } as never, }]); injector.add([IMenuManagerService, { useValue: { getFlatMenuByPositionKey: () => [], } as never, }]); injector.add([DocDrawingFloatingToolbarAdapterService]); if (mobile) { injector.add([MobileDocDrawingPopupMenuController]); } else { injector.add([DocDrawingPopupMenuController]); } const controller = mobile ? injector.get(MobileDocDrawingPopupMenuController) : injector.get(DocDrawingPopupMenuController); return { clearControl$, createControl$, contextService, controller, executeCommand, injector, attachPopupToObject, popupDisposable, selectedObjects, remove$, transformer, }; } describe('DocDrawingPopupMenuController', () => { it('keeps the mobile popup controller independent from the desktop controller', () => { expect(MobileDocDrawingPopupMenuController.prototype).not.toBeInstanceOf(DocDrawingPopupMenuController); }); it('does not expose mobile callbacks through the desktop popup', () => { const harness = createControllerHarness(); const { controller } = harness; try { harness.createControl$.next(); const extraProps = harness.attachPopupToObject.mock.calls[0][1].extraProps; expect(extraProps).not.toHaveProperty('onClose'); expect(extraProps).not.toHaveProperty('onEdit'); expect(extraProps).not.toHaveProperty('onDelete'); } finally { controller.dispose(); harness.injector.dispose(); } }); it('closes a mobile popup without clearing the object being edited', () => { const harness = createControllerHarness(DrawingTypeEnum.DRAWING_CHART, true); const { controller } = harness; try { harness.createControl$.next(); const onClose = harness.attachPopupToObject.mock.calls[0][1].extraProps?.onClose; expect(onClose).toBeTypeOf('function'); if (typeof onClose === 'function') { onClose(); } expect(harness.popupDisposable.dispose).toHaveBeenCalledOnce(); expect(harness.selectedObjects.size).toBe(1); expect(harness.transformer.clearSelectedObjects).not.toHaveBeenCalled(); } finally { controller.dispose(); harness.injector.dispose(); } }); it('keeps generic drawing edit and delete callbacks in the mobile controller', async () => { const harness = createControllerHarness(DrawingTypeEnum.DRAWING_TABLE, true); const { controller } = harness; try { harness.createControl$.next(); const popupOptions = harness.attachPopupToObject.mock.calls[0][1]; const extraProps = popupOptions.extraProps as typeof popupOptions.extraProps & { onEdit?: () => unknown; onDelete?: () => unknown; }; expect(popupOptions.componentKey).toBe(MOBILE_DOC_ELEMENT_MENU); await extraProps?.onEdit?.(); expect(harness.executeCommand).toHaveBeenCalledWith(EditDocDrawingOperation.id, { unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1', }); await extraProps?.onDelete?.(); expect(harness.executeCommand).toHaveBeenCalledWith(RemoveDocDrawingCommand.id, { unitId: 'doc-1', drawings: [{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' }], }); } finally { controller.dispose(); harness.injector.dispose(); } }); it.each([false, true])('removes the floating menu and selection handles when its drawing is deleted (mobile: %s)', (mobile) => { const { controller, createControl$, injector, popupDisposable, remove$, transformer } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile); try { createControl$.next(); remove$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'another-drawing' }]); expect(popupDisposable.dispose).not.toHaveBeenCalled(); remove$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' }]); expect(popupDisposable.dispose).toHaveBeenCalledOnce(); expect(transformer.clearSelectedObjects).toHaveBeenCalledOnce(); } finally { controller.dispose(); injector.dispose(); } }); it.each([false, true])('keeps an active popup during refresh and removes it after the selected drawing is deleted (mobile: %s)', (mobile) => { const { controller, clearControl$, createControl$, injector, popupDisposable, selectedObjects } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile); try { createControl$.next(); clearControl$.next(false); expect(popupDisposable.dispose).not.toHaveBeenCalled(); selectedObjects.clear(); clearControl$.next(true); expect(popupDisposable.dispose).toHaveBeenCalledOnce(); } finally { controller.dispose(); injector.dispose(); } }); it.each([false, true])('removes an active popup when document editing is revoked even if drawing focus is empty (mobile: %s)', (mobile) => { const { controller, createControl$, injector, popupDisposable } = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, mobile); try { createControl$.next(); expect(popupDisposable.dispose).not.toHaveBeenCalled(); setDocumentPermissionValue(injector.get(IPermissionService), 'doc-1', 'doc-1', UnitAction.Edit, false); expect(popupDisposable.dispose).toHaveBeenCalledOnce(); } finally { controller.dispose(); injector.dispose(); } }); it('uses the mobile drawing menu for images and charts', () => { const imageHarness = createControllerHarness(DrawingTypeEnum.DRAWING_IMAGE, true); const imageController = imageHarness.controller; try { imageHarness.createControl$.next(); expect(imageHarness.attachPopupToObject).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ componentKey: COMPONENT_MOBILE_IMAGE_POPUP_MENU, extraProps: expect.objectContaining({ variant: 'doc-floating-toolbar', menuItems: [ expect.objectContaining({ commandId: EditDocDrawingOperation.id }), expect.objectContaining({ commandId: OpenImageCropOperation.id }), expect.objectContaining({ commandId: RemoveDocDrawingCommand.id }), ], }), }), 'doc-1' ); } finally { imageController.dispose(); imageHarness.injector.dispose(); } const chartHarness = createControllerHarness(DrawingTypeEnum.DRAWING_CHART, true); const chartController = chartHarness.controller; try { chartHarness.createControl$.next(); expect(chartHarness.attachPopupToObject).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ componentKey: COMPONENT_MOBILE_IMAGE_POPUP_MENU, extraProps: expect.objectContaining({ variant: 'doc-chart-floating-toolbar', menuItems: [ expect.objectContaining({ commandId: EditDocDrawingOperation.id, label: 'docs-drawing-ui.image-popup.edit' }), expect.objectContaining({ commandId: RemoveDocDrawingCommand.id }), ], }), }), 'doc-1' ); } finally { chartController.dispose(); chartHarness.injector.dispose(); } }); });