/** * Copyright 2023-present DreamNum Co., Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { DocumentDataModel, ICommandInfo, IDocDrawingPosition, IDrawingParam, IImageIoServiceParam, ITextRangeParam, Nullable } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { IDocDrawing, IDrawingDocTransform, IInsertDocDrawingCommandParams, ISetDocDrawingArrangeCommandParams, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing'; import type { BaseObject, Documents, Image, IRenderContext, IRenderModule, ITransformerConfig } from '@univerjs/engine-render'; import type { LocaleKey } from '../../locale/types'; import { BooleanNumber, Disposable, DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, ICommandService, IContextService, IImageIoService, ImageUploadStatusType, Inject, IPermissionService, LocaleService, PositionedObjectLayoutType, WrapTextType, } from '@univerjs/core'; import { MessageType } from '@univerjs/design'; import { buildDocTransform, canEditDocumentTargets, docDrawingPositionToTransform, DocSelectionManagerService, DocSkeletonManagerService, getDocumentDrawingSegmentId, getDocumentEditTargetObjectIds, getDocumentEntityParentPermissionObjectIds, getDocumentEntityPermissionObjectId, RichTextEditingMutation, } from '@univerjs/docs'; import { collectDocDrawings, findDocDrawing, IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing'; import { DocSelectionRenderService } from '@univerjs/docs-ui'; import { DRAWING_IMAGE_ALLOW_IMAGE_LIST, DRAWING_IMAGE_COUNT_LIMIT, DRAWING_IMAGE_HEIGHT_LIMIT, DRAWING_IMAGE_WIDTH_LIMIT, getDrawingImageAllowSize, getDrawingShapeKeyByDrawingSearch, getImageSize, IDrawingManagerService, } from '@univerjs/drawing'; import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render'; import { ILocalFileService, IMessageService } from '@univerjs/ui'; import { debounceTime } from 'rxjs'; import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command'; import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command'; import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service'; import { getDocImageCropUpdates } from './doc-drawing-crop'; import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation'; interface IImageInsertPosition { left: number; top: number; } export class DocDrawingUpdateRenderController extends Disposable implements IRenderModule { private readonly _editableTransformerConfigs = new WeakMap(); private readonly _editableEventedStates = new WeakMap(); private _editStatusUpdateScheduled = false; private _pendingEditStatusDrawingIds: Set | null | undefined; private _lastEditStatusContextKey: string | null = null; constructor( private readonly _context: IRenderContext, @ICommandService private readonly _commandService: ICommandService, @Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService, @IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService, @IImageIoService private readonly _imageIoService: IImageIoService, @IDocDrawingService private readonly _docDrawingService: IDocDrawingService, @IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService, @IPermissionService private readonly _permissionService: IPermissionService, @IContextService private readonly _contextService: IContextService, @IMessageService private readonly _messageService: IMessageService, @Inject(LocaleService) private readonly _localeService: LocaleService, @Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService, @Inject(DocRefreshDrawingsService) private readonly _docRefreshDrawingsService: DocRefreshDrawingsService, @ILocalFileService private readonly _fileOpenerService: ILocalFileService ) { super(); this._updateOrderListener(); this._updateImageCropListener(); this._groupDrawingListener(); this._focusDrawingListener(); this._transformDrawingListener(); this._editAreaChangeListener(); const scheduleEditStatusUpdate = (drawings: Array<{ unitId: string; drawingId: string }>) => { const relevantDrawings = drawings.filter((drawing) => drawing.unitId === this._context?.unitId); if (relevantDrawings.length > 0) { const drawingIds = relevantDrawings .map((drawing) => drawing.drawingId) .filter((drawingId): drawingId is string => typeof drawingId === 'string' && drawingId.length > 0); this._scheduleDrawingsEditStatusUpdate( drawingIds.length === relevantDrawings.length ? new Set(drawingIds) : undefined ); } }; this.disposeWithMe(this._drawingManagerService.add$.subscribe(scheduleEditStatusUpdate)); this.disposeWithMe(this._drawingManagerService.update$.subscribe(scheduleEditStatusUpdate)); this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => { this._updateDrawingsEditStatus(undefined, true); })); } override dispose(): void { super.dispose(); // @ts-ignore delete this._context; } async insertDocImage(): Promise { const insertPosition = this._getCurrentImageInsertPosition(); const textRange = this._getCurrentImageInsertTextRange(); if (!this._canInsertDocImage(textRange)) { return false; } const files = await this._fileOpenerService.openFile({ multiple: true, accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','), }); if (this._disposed || !this._canInsertDocImage(textRange)) { return false; } const fileLength = files.length; if (fileLength > DRAWING_IMAGE_COUNT_LIMIT) { this._messageService.show({ type: MessageType.Error, content: this._localeService.t('docs-drawing-ui.update-status.exceedMaxCount', String(DRAWING_IMAGE_COUNT_LIMIT)), }); return false; } else if (fileLength === 0) { return false; } return await this._insertFloatImages(files, insertPosition, textRange); } // eslint-disable-next-line max-lines-per-function private async _insertFloatImages( files: File[], insertPosition: Nullable, textRange: Nullable ) { let imageParams: Nullable[] = []; try { imageParams = await Promise.all(files.map((file) => this._imageIoService.saveImage(file))); } catch (error) { const type = (error as Error).message; let content = ''; switch (type) { case ImageUploadStatusType.ERROR_EXCEED_SIZE: content = this._localeService.t('docs-drawing-ui.update-status.exceedMaxSize', String(getDrawingImageAllowSize() / (1024 * 1024))); break; case ImageUploadStatusType.ERROR_IMAGE_TYPE: content = this._localeService.t('docs-drawing-ui.update-status.invalidImageType'); break; case ImageUploadStatusType.ERROR_IMAGE: content = this._localeService.t('docs-drawing-ui.update-status.invalidImage'); break; default: break; } this._messageService.show({ type: MessageType.Error, content, }); } if (this._disposed || imageParams.length === 0 || !this._canInsertDocImage(textRange)) { return false; } const { unitId } = this._context; const docDrawingParams: IDocDrawing[] = []; for (const imageParam of imageParams) { if (imageParam == null) { continue; } const { imageId, imageSourceType, source, base64Cache } = imageParam; const { width, height, image } = await getImageSize(base64Cache || ''); if (this._disposed || !this._canInsertDocImage(textRange)) { return false; } this._imageIoService.addImageSourceCache(imageId, imageSourceType, image); let scale = 1; if (width > DRAWING_IMAGE_WIDTH_LIMIT || height > DRAWING_IMAGE_HEIGHT_LIMIT) { const scaleWidth = DRAWING_IMAGE_WIDTH_LIMIT / width; const scaleHeight = DRAWING_IMAGE_HEIGHT_LIMIT / height; scale = Math.min(scaleWidth, scaleHeight); } const imagePosition = insertPosition ?? this._getCurrentImageInsertPosition(); const docTransform = this._getImagePosition(width * scale, height * scale, imagePosition); if (docTransform == null) { return false; } const transform = docDrawingPositionToTransform(docTransform); if (transform != null && imagePosition != null) { transform.top = imagePosition.top; } const docDrawingParam: IDocDrawing = { unitId, subUnitId: unitId, drawingId: imageId, drawingType: DrawingTypeEnum.DRAWING_IMAGE, imageSourceType, source, transform, docTransform, behindDoc: BooleanNumber.FALSE, title: '', description: '', layoutType: PositionedObjectLayoutType.INLINE, // Insert inline drawing by default. wrapText: WrapTextType.BOTH_SIDES, distB: 0, distL: 0, distR: 0, distT: 0, }; const isInHeaderFooter = this._isInsertInHeaderFooter(); if (isInHeaderFooter) { docDrawingParam.isMultiTransform = BooleanNumber.TRUE; docDrawingParam.transforms = docDrawingParam.transform ? [docDrawingParam.transform] : null; } docDrawingParams.push(docDrawingParam); } return await this._commandService.executeCommand(InsertDocDrawingCommand.id, { unitId, drawings: docDrawingParams, textRange: textRange ?? undefined, }); } private _isInsertInHeaderFooter() { const { unitId } = this._context; const viewModel = this._renderManagerSrv.getRenderUnitById(unitId) ?.with(DocSkeletonManagerService) .getViewModel(); const editArea = viewModel?.getEditArea(); return editArea === DocumentEditArea.HEADER || editArea === DocumentEditArea.FOOTER; } private _getImagePosition( imageWidth: number, imageHeight: number, insertPosition?: Nullable ): Nullable { // TODO: NO need to get the cursor position, because the insert image is inline. const position = insertPosition ?? this._getCurrentImageInsertPosition() ?? { left: 0, top: 0, }; return buildDocTransform(imageWidth, imageHeight, { left: position.left, }); } private _getCurrentImageInsertPosition(): Nullable { const position = this._docSelectionRenderService.getActiveTextRange()?.getAbsolutePosition(); if (position == null) { return null; } return { left: position.left, top: position.top, }; } private _getCurrentImageInsertTextRange(): Nullable { return this._docSelectionRenderService.getAllTextRanges().find((range) => range.isActive) ?? this._docSelectionManagerService.getActiveTextRange(); } private _updateOrderListener() { this.disposeWithMe( this._drawingManagerService.featurePluginOrderUpdate$.subscribe((params) => { const { unitId, subUnitId, drawingIds, arrangeType } = params; this._commandService.executeCommand(SetDocDrawingArrangeCommand.id, { unitId, subUnitId, drawingIds, arrangeType, }); }) ); } private _updateImageCropListener() { this.disposeWithMe( this._drawingManagerService.featurePluginUpdate$.subscribe((params) => { const drawings = params.flatMap((param) => this._getImageCropUpdates(param)); if (drawings.length > 0) { const { unitId } = this._context; this._commandService.executeCommand(UpdateDrawingDocTransformCommand.id, { unitId, subUnitId: unitId, drawings, }); } }) ); } private _getImageCropUpdates(param: IDrawingParam): IDrawingDocTransform[] { const { unitId, subUnitId, drawingId } = param; if ([unitId, subUnitId].some((id) => id !== this._context.unitId)) { return []; } return getDocImageCropUpdates( param, this._getDocDrawing(drawingId), this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId }) ?? undefined ); } private _getDocDrawing(drawingId: string): IDocDrawing | undefined { return findDocDrawing(this._context.unit.getSnapshot(), drawingId)?.drawing as IDocDrawing | undefined; } private _groupDrawingListener() { this.disposeWithMe( this._drawingManagerService.featurePluginGroupUpdate$.subscribe((params) => { this._commandService.executeCommand(GroupDocDrawingCommand.id, params); }) ); this.disposeWithMe( this._drawingManagerService.featurePluginUngroupUpdate$.subscribe((params) => { this._commandService.executeCommand(UngroupDocDrawingCommand.id, params); }) ); } private _getCurrentSceneAndTransformer() { const { scene, mainComponent } = this._context; if (scene == null || mainComponent == null) { return; } const transformer = scene.getTransformerByCreate(); const { docsLeft, docsTop } = (mainComponent as Documents).getOffsetConfig(); return { scene, transformer, docsLeft, docsTop }; } protected _getTransformerInteractionOptions(): ITransformerConfig { return { moveOnlyWhenSelected: false }; } protected _isDocumentInteractionFocusing(unitId: string): boolean { return this._docSelectionRenderService.isFocusing || this._drawingManagerService.getFocusDrawings().some((drawing) => drawing.unitId === unitId); } private _transformDrawingListener() { const res = this._getCurrentSceneAndTransformer(); if (res && res.transformer) { res.transformer.resetProps(this._getTransformerInteractionOptions()); this.disposeWithMe(res.transformer.changeEnd$.pipe(debounceTime(30)).subscribe(() => { this._docSelectionManagerService.refreshSelection(); })); } else { throw new Error('transformer is not init'); } } private _focusDrawingListener() { this.disposeWithMe( this._drawingManagerService.focus$.subscribe((params) => { const { transformer, docsLeft, docsTop } = this._getCurrentSceneAndTransformer() ?? {}; if (params == null || params.length === 0) { this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false); this._docDrawingService.focusDrawing([]); if (transformer) { transformer.resetProps({ zeroTop: 0, zeroLeft: 0, }); } } else { this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true); this._docDrawingService.focusDrawing(params); this._setDrawingSelections(params); const prevSegmentId = this._docSelectionRenderService.getSegment(); const segmentId = getDocumentDrawingSegmentId(this._context.unit, params[0].drawingId); // Change segmentId when click drawing in different segment. if (prevSegmentId !== segmentId) { this._docSelectionRenderService.setSegment(segmentId); } if (transformer) { transformer.resetProps({ zeroTop: docsTop, zeroLeft: docsLeft, }); } } this._updateDrawingsEditStatus(); }) ); } // Update drawings edit status and opacity. You can not edit header footer images when you are editing body. and vice verse. private _updateDrawingsEditStatus(drawingIds?: ReadonlySet, force = false) { if (!this._context) return; const { unit: docDataModel, scene, unitId } = this._context; const viewModel = this._renderManagerSrv .getRenderUnitById(unitId) ?.with(DocSkeletonManagerService) .getViewModel(); if (viewModel == null || docDataModel == null) { return; } const snapshot = docDataModel.getSnapshot(); const { drawings } = collectDocDrawings(snapshot); const isEditBody = viewModel.getEditArea() === DocumentEditArea.BODY; const isDocInteractionFocusing = this._isDocumentInteractionFocusing(unitId); const contextKey = `${viewModel.getEditArea()}:${isDocInteractionFocusing}`; const effectiveDrawingIds = drawingIds != null && contextKey === this._lastEditStatusContextKey ? drawingIds : undefined; if (effectiveDrawingIds == null) { if (!force && contextKey === this._lastEditStatusContextKey) { return; } this._lastEditStatusContextKey = contextKey; } const readOnlyDrawingIds = new Set(); const drawingShapesById = this._indexDrawingShapes(scene.getAllObjects(), unitId); for (const key of Object.keys(drawings)) { const drawing = drawings[key]; if (effectiveDrawingIds != null && !effectiveDrawingIds.has(drawing.drawingId)) { continue; } const segmentId = getDocumentDrawingSegmentId(docDataModel, drawing.drawingId); const editable = canEditDocumentTargets(this._permissionService, unitId, [ ...getDocumentEntityParentPermissionObjectIds(docDataModel, segmentId, 'drawing', drawing.drawingId), getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawing.drawingId), ]); this._recordReadOnlyDrawing(readOnlyDrawingIds, drawing.drawingId, editable); const drawingShapes = drawingShapesById.get(drawing.drawingId) ?? []; if (drawingShapes.length) { for (const shape of drawingShapes) { scene.detachTransformerFrom(shape); this._setTransformerEditable(shape, editable); try { (shape as Image).setOpacity(isDocInteractionFocusing ? 0.5 : 1); } catch { } if (!isDocInteractionFocusing) { continue; } if ( (isEditBody && drawing.isMultiTransform !== BooleanNumber.TRUE) || (!isEditBody && drawing.isMultiTransform === BooleanNumber.TRUE) ) { this._attachTransformerIfEditable(shape, editable, drawing.allowTransform); try { (shape as Image).setOpacity(1); } catch { } } } } } this._clearReadOnlyDrawingFocus(unitId, readOnlyDrawingIds); } private _indexDrawingShapes(shapes: BaseObject[], unitId: string): Map { const prefix = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId: unitId, drawingId: '' }); const result = new Map(); for (const shape of shapes) { if (!shape.oKey.startsWith(prefix)) { continue; } const drawingId = shape.oKey.slice(prefix.length).split('#-#', 1)[0]; if (!drawingId) { continue; } const drawingShapes = result.get(drawingId) ?? []; drawingShapes.push(shape); result.set(drawingId, drawingShapes); } return result; } private _recordReadOnlyDrawing(readOnlyDrawingIds: Set, drawingId: string, editable: boolean): void { if (!editable) { readOnlyDrawingIds.add(drawingId); } } private _attachTransformerIfEditable(shape: BaseObject, editable: boolean, allowTransform?: boolean): void { if (editable && allowTransform !== false) { this._context.scene.attachTransformerTo(shape); } } private _clearReadOnlyDrawingFocus(unitId: string, readOnlyDrawingIds: Set): void { if (readOnlyDrawingIds.size === 0) { return; } const focusedDrawings = this._drawingManagerService.getFocusDrawings(); const editableFocus = focusedDrawings.filter((drawing) => drawing.unitId !== unitId || !readOnlyDrawingIds.has(drawing.drawingId)); if (editableFocus.length !== focusedDrawings.length) { this._drawingManagerService.focusDrawing(editableFocus); } } private _setTransformerEditable(shape: BaseObject, editable: boolean): void { if (editable) { const config = this._editableTransformerConfigs.get(shape); if (config) { shape.transformerConfig = config; this._editableTransformerConfigs.delete(shape); } const evented = this._editableEventedStates.get(shape); if (evented !== undefined) { shape.evented = evented; this._editableEventedStates.delete(shape); } return; } if (!this._editableTransformerConfigs.has(shape)) { this._editableTransformerConfigs.set(shape, shape.transformerConfig); } if (!this._editableEventedStates.has(shape)) { this._editableEventedStates.set(shape, shape.evented); } shape.evented = false; this._context.scene.getTransformer()?.clearControlByIds([shape.oKey]); shape.transformerConfig = { ...shape.transformerConfig, moveEnabled: false, resizeEnabled: false, rotateEnabled: false, }; } private _scheduleDrawingsEditStatusUpdate(drawingIds?: ReadonlySet): void { if (drawingIds == null) { this._pendingEditStatusDrawingIds = null; this._lastEditStatusContextKey = null; } else if (this._pendingEditStatusDrawingIds !== null) { this._pendingEditStatusDrawingIds ??= new Set(); drawingIds.forEach((drawingId) => this._pendingEditStatusDrawingIds?.add(drawingId)); } if (this._editStatusUpdateScheduled) { return; } this._editStatusUpdateScheduled = true; queueMicrotask(() => { this._editStatusUpdateScheduled = false; if (!this._disposed) { const pendingDrawingIds = this._pendingEditStatusDrawingIds; this._pendingEditStatusDrawingIds = undefined; this._updateDrawingsEditStatus(pendingDrawingIds ?? undefined); } }); } private _canInsertDocImage(textRange: Nullable): boolean { const { unit, unitId } = this._context; const objectIds = textRange?.startOffset == null || textRange.endOffset == null ? [] : getDocumentEditTargetObjectIds(unit, textRange.segmentId ?? '', textRange); return canEditDocumentTargets(this._permissionService, unitId, objectIds); } private _editAreaChangeListener() { const { unitId } = this._context; const viewModel = this._renderManagerSrv .getRenderUnitById(unitId) ?.with(DocSkeletonManagerService) .getViewModel(); if (viewModel == null) { return; } this._updateDrawingsEditStatus(); this.disposeWithMe( viewModel.editAreaChange$.subscribe(() => { this._updateDrawingsEditStatus(); }) ); this.disposeWithMe( this._docSelectionRenderService.onFocus$.subscribe(() => { this._updateDrawingsEditStatus(); }) ); this.disposeWithMe( this._docSelectionRenderService.onBlur$.subscribe(() => { this._updateDrawingsEditStatus(); }) ); this.disposeWithMe( this._docRefreshDrawingsService.refreshDrawings$.subscribe((skeleton) => { if (skeleton == null) { return; } // To wait the image is rendered. queueMicrotask(() => { this._updateDrawingsEditStatus(undefined, true); }); }) ); this.disposeWithMe( this._commandService.onCommandExecuted(async (command: ICommandInfo) => { if (command.id !== RichTextEditingMutation.id) { return; } const params = command.params as Partial | undefined; if (params?.unitId && params.unitId !== this._context.unitId) { return; } const drawingIds = getDocMutationAffectedDrawingIds(params?.actions ?? null); if (drawingIds?.size === 0) { return; } // To wait the image is rendered. this._scheduleDrawingsEditStatusUpdate(drawingIds ?? undefined); }) ); } private _setDrawingSelections(params: IDrawingParam[]) { const { unit } = this._context; const ranges = params.flatMap((item) => { const segmentId = getDocumentDrawingSegmentId(unit, item.drawingId); const body = unit.getSelfOrHeaderFooterModel(segmentId)?.getBody(); const block = body?.customBlocks?.find((block) => block.blockId === item.drawingId); return block ? [{ startOffset: block.startIndex, endOffset: block.startIndex + 1, segmentId }] : []; }); this._docSelectionManagerService.replaceDocRanges(ranges); } }