From 7b991e6182b5f17f5249683df62dddd1428df603 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 17 Nov 2025 13:40:42 -0300 Subject: [PATCH 1/8] selection --- .../demoButtons/createImageEditButtons.ts | 2 +- .../formatContentModel/formatContentModel.ts | 13 +- .../setContentModel/setContentModel.ts | 5 +- .../corePlugin/selection/SelectionPlugin.ts | 14 +- .../lib/editor/Editor.ts | 1 + .../lib/imageEdit/ImageEditPlugin.ts | 153 +++++++----------- .../lib/imageEdit/utils/canRegenerateImage.ts | 18 ++- .../lib/imageEdit/utils/createImageWrapper.ts | 2 +- .../lib/editor/EditorCore.ts | 3 +- .../parameter/FormatContentModelOptions.ts | 2 + 10 files changed, 98 insertions(+), 115 deletions(-) diff --git a/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts index ded34f732c39..7634453903af 100644 --- a/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts +++ b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts @@ -62,7 +62,7 @@ function createImageFlipButton(handler: ImageEditor): RibbonButton<'buttonNameFl items: flipDirections, }, isDisabled: formatState => !formatState.canAddImageAltText, - onClick: (_editor, flipDirection) => { + onClick: (editor, flipDirection) => { handler.flipImage(flipDirection as 'horizontal' | 'vertical'); }, }; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 000a58025503..5ebf6eff0a14 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -25,8 +25,13 @@ export const formatContentModel: FormatContentModel = ( options, domToModelOptions ) => { - const { onNodeCreated, rawEvent, selectionOverride, scrollCaretIntoView: scroll } = - options || {}; + const { + onNodeCreated, + rawEvent, + selectionOverride, + scrollCaretIntoView: scroll, + skipSelectionChangedEvent, + } = options || {}; const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { newEntities: [], @@ -63,7 +68,9 @@ export const formatContentModel: FormatContentModel = ( core, model, hasFocus ? undefined : { ignoreSelection: true }, // If editor did not have focus before format, do not set focus after format - onNodeCreated + onNodeCreated, + undefined /* isInitializing */, + skipSelectionChangedEvent ) ?? undefined; handlePendingFormat(core, context, selection); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index 0400bf8668e1..fb7e3f3df1bb 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -21,7 +21,8 @@ export const setContentModel: SetContentModel = ( model, option, onNodeCreated, - isInitializing + isInitializing, + skipSelectionChangedEvent ) => { const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); const modelToDomContext = option @@ -54,7 +55,7 @@ export const setContentModel: SetContentModel = ( updateCache(core.cache, model, selection); if (!option?.ignoreSelection && selection) { - core.api.setDOMSelection(core, selection); + core.api.setDOMSelection(core, selection, skipSelectionChangedEvent); } else { core.selection.selection = selection; } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 2d7f8f0c0ce5..19e495b12986 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -24,7 +24,7 @@ import type { } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; -const MouseRightButton = 2; +//const MouseRightButton = 2; const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; @@ -182,17 +182,6 @@ class SelectionPlugin implements PluginWithState { const selection = editor.getDOMSelection(); let image: HTMLImageElement | null; - // Image selection - if ( - selection?.type == 'image' && - (rawEvent.button == MouseLeftButton || - (rawEvent.button == MouseRightButton && - !this.getClickingImage(rawEvent) && - !this.getContainedTargetImage(rawEvent, selection))) - ) { - this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); - } - if ( (image = this.getClickingImage(rawEvent) ?? @@ -691,6 +680,7 @@ class SelectionPlugin implements PluginWithState { //If am image selection changed to a wider range due a keyboard event, we should update the selection const selection = this.editor.getDocument().getSelection(); + if (selection && selection.focusNode) { const image = isSingleImageInSelection(selection); if (newSelection?.type == 'image' && !image) { diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 9f18ceca3a5b..896cab1e1548 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -140,6 +140,7 @@ export class Editor implements IEditor { */ getDOMSelection(): DOMSelection | null { const core = this.getCore(); + console.log(core.api.getDOMSelection(core)); return core.api.getDOMSelection(core); } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index bf9e08a90394..a92b9b5c8ef8 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -1,7 +1,7 @@ import { applyChange } from './utils/applyChange'; import { canRegenerateImage } from './utils/canRegenerateImage'; import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; -import { createImageWrapper } from './utils/createImageWrapper'; +import { createImageWrapper, IMAGE_EDIT_SHADOW_ROOT } from './utils/createImageWrapper'; import { Cropper } from './Cropper/cropperContext'; import { EDITING_MARKER, findEditingImage } from './utils/findEditingImage'; import { filterInnerResizerHandles } from './utils/filterInnerResizerHandles'; @@ -10,17 +10,18 @@ import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; import { getSelectedImage } from './utils/getSelectedImage'; import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; import { ImageEditElementClass } from './types/ImageEditElementClass'; -import { normalizeImageSelection } from './utils/normalizeImageSelection'; import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; import { updateHandleCursor } from './utils/updateHandleCursor'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; +//import { normalizeImageSelection } from './utils/normalizeImageSelection'; import { ChangeSource, getSafeIdSelector, - getSelectedParagraphs, + //getSelectedParagraphs, isElementOfType, + isEntityElement, isNodeOfType, mutateBlock, mutateSegment, @@ -41,8 +42,10 @@ import type { ImageMetadataFormat, KeyDownEvent, MouseDownEvent, - MouseUpEvent, + // MouseDownEvent, + // MouseUpEvent, PluginEvent, + SelectionChangedEvent, } from 'roosterjs-content-model-types'; const DefaultOptions: Partial = { @@ -55,7 +58,7 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; -const MouseRightButton = 2; +//const MouseRightButton = 2; const DRAG_ID = '_dragging'; const IMAGE_EDIT_CLASS = 'imageEdit'; const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; @@ -85,7 +88,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private croppers: HTMLDivElement[] = []; private zoomScale: number = 1; private disposer: (() => void) | null = null; - protected isEditing = false; protected options: ImageEditOptions; constructor(options?: ImageEditOptions) { @@ -108,17 +110,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { initialize(editor: IEditor) { this.editor = editor; this.disposer = editor.attachDomEvent({ - blur: { - beforeDispatch: () => { - if (this.isEditing && this.editor && !this.editor.isDisposed()) { - this.applyFormatWithContentModel( - this.editor, - this.isCropMode, - true /* shouldSelectImage */ - ); - } - }, - }, dragstart: { beforeDispatch: ev => { if (this.editor) { @@ -139,6 +130,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } }, }, + blur: { + beforeDispatch: ev => { + if (this.editor && this.selectedImage) { + if (this.editor) { + this.applyFormatWithContentModel(this.editor, this.isCropMode); + } + } + }, + }, }); } @@ -152,7 +152,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.disposer(); this.disposer = null; } - this.isEditing = false; this.cleanInfo(); this.editor = null; } @@ -168,15 +167,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return; } switch (event.eventType) { - case 'mouseDown': - this.mouseDownHandler(this.editor, event); - break; - case 'mouseUp': - this.mouseUpHandler(this.editor, event); + case 'selectionChanged': + this.selectionChangeHandler(this.editor, event); break; case 'keyDown': this.keyDownHandler(this.editor, event); break; + case 'mouseDown': + this.mouseDownHandler(this.editor, event); + break; case 'contentChanged': this.contentChangedHandler(this.editor, event); break; @@ -190,14 +189,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private handleBeforeLogicalRootChange() { - if (this.isEditing && this.editor && !this.editor.isDisposed()) { - this.applyFormatWithContentModel( - this.editor, - this.isCropMode, - false /* shouldSelectImage */ - ); - this.removeImageWrapper(); - this.cleanInfo(); + if (this.selectedImage && this.editor && !this.editor.isDisposed()) { + this.applyFormatWithContentModel(this.editor, this.isCropMode); } } @@ -210,6 +203,26 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { }); } + private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { + const selection = editor.getDOMSelection(); + const target = event.rawEvent.target as Element; + const isEditingImage = + target.firstElementChild?.id == IMAGE_EDIT_SHADOW_ROOT && target.childElementCount == 1; + if (selection?.type == 'image' && (isEditingImage || isEntityElement(target))) { + const range = editor.getDocument().createRange(); + const image = this.removeImageWrapper(); + if (image) { + range.selectNode(image); + range.collapse(); + editor.setDOMSelection({ + type: 'range', + range, + isReverted: false, + }); + } + } + } + private isImageSelection(target: Node): target is HTMLElement { return ( isNodeOfType(target, 'ELEMENT_NODE') && @@ -223,24 +236,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ); } - private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { - const selection = editor.getDOMSelection(); - if ((selection && selection.type == 'image') || this.isEditing) { - const shouldSelectImage = - this.isImageSelection(event.rawEvent.target as Node) && - event.rawEvent.button === MouseRightButton; - this.applyFormatWithContentModel(editor, this.isCropMode, shouldSelectImage); - } - } - - private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - if ( - this.isEditing && - this.isImageSelection(event.rawEvent.target as Node) && - event.rawEvent.button !== MouseRightButton && - !this.isCropMode - ) { - this.applyFormatWithContentModel(editor, this.isCropMode); + private selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { + if ((event.newSelection && event.newSelection.type == 'image') || this.selectedImage) { + editor.getDocument().defaultView?.requestAnimationFrame(() => { + this.applyFormatWithContentModel(editor, this.isCropMode); + }); } } @@ -273,7 +273,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private keyDownHandler(editor: IEditor, event: KeyDownEvent) { - if (this.isEditing) { + if (this.selectedImage) { if ( event.rawEvent.key === 'Escape' || event.rawEvent.key === 'Delete' || @@ -290,7 +290,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.applyFormatWithContentModel( editor, this.isCropMode, - true /** should selectImage */, false /* isApiOperation */ ); } @@ -302,16 +301,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (selection?.type == 'image') { this.cleanInfo(); setImageState(selection.image, ''); - this.isEditing = false; - this.isCropMode = false; } } private formatEventHandler(event: ContentChangedEvent) { - if (this.isEditing && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { + if (this.selectedImage && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { this.cleanInfo(); - this.isEditing = false; - this.isCropMode = false; } } @@ -335,7 +330,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { protected applyFormatWithContentModel( editor: IEditor, isCropMode: boolean, - shouldSelectImage?: boolean, isApiOperation?: boolean ) { let editingImageModel: ContentModelImage | undefined; @@ -347,21 +341,21 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { const editingImage = getSelectedImage(model); const previousSelectedImage = isApiOperation ? editingImage - : findEditingImage(model); + : findEditingImage(model, undefined); let result = false; // Skip adding undo snapshot for now. If we detect any changes later, we will reset it context.skipUndoSnapshot = 'SkipAll'; + const clickInDifferentImage = previousSelectedImage?.image != editingImage?.image; + if ( - shouldSelectImage || - previousSelectedImage?.image != editingImage?.image || + clickInDifferentImage || previousSelectedImage?.image.format.imageState == EDITING_MARKER || isApiOperation ) { const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( - (this.isEditing || isApiOperation) && previousSelectedImage && lastSrc && selectedImage && @@ -385,42 +379,20 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (this.wasImageResized || changeState == 'FullyChanged') { context.skipUndoSnapshot = false; } - const isSameImage = - previousSelectedImage?.image === editingImage?.image; - image.isSelected = isSameImage || shouldSelectImage; - image.isSelectedAsImageSelection = isSameImage || shouldSelectImage; image.format.imageState = undefined; - - if (selection?.type == 'range' && !selection.range.collapsed) { - const selectedParagraphs = getSelectedParagraphs(model, true); - const isImageInRange = selectedParagraphs.some(paragraph => - paragraph.segments.includes(image) - ); - if (isImageInRange) { - image.isSelected = true; - } - } } ); - if (shouldSelectImage) { - normalizeImageSelection(previousSelectedImage); - } - this.cleanInfo(); result = true; } - this.isEditing = false; - this.isCropMode = false; - if ( + clickInDifferentImage && editingImage && selection?.type == 'image' && - !shouldSelectImage && !isApiOperation ) { - this.isEditing = true; this.isCropMode = isCropMode; mutateSegment(editingImage.paragraph, editingImage.image, image => { editingImageModel = image; @@ -436,6 +408,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return result; }, { + skipSelectionChangedEvent: true, onNodeCreated: (model, node) => { if ( !isApiOperation && @@ -544,6 +517,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { public startRotateAndResize(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { if (this.imageEditInfo) { this.startEditing(editor, image, ['resize', 'rotate']); + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { const isMobileOrTable = !!editor.getEnvironment().isMobileOrTablet; this.dndHelpers = [ @@ -779,12 +753,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.applyFormatWithContentModel( - editor, - false /* isCrop */, - true /* shouldSelect*/, - true /* isApiOperation */ - ); + this.applyFormatWithContentModel(editor, false /* isCrop */, true /* isApiOperation */); } /** @@ -836,10 +805,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (this.editor) { this.editImage(this.editor, image, ['flip'], imageEditInfo => { const angleRad = imageEditInfo.angleRad || 0; - const isInVerticalPostion = + const isInVerticalPosition = (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || (angleRad <= -Math.PI / 2 && angleRad > (-3 * Math.PI) / 4); - if (isInVerticalPostion) { + if (isInVerticalPosition) { if (direction === 'horizontal') { imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; } else { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts index 4e679b9a8dbf..485119e36ec9 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts @@ -1,3 +1,5 @@ +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; + /** * @internal * Check if we can regenerate edited image from the source image. @@ -6,17 +8,19 @@ * @returns True when we can regenerate the edited image, otherwise false */ export function canRegenerateImage(img: HTMLImageElement | null): boolean { - if (!img) { + const image = img && isElementOfType(img, 'span') ? getWrappedImage(img) : img; + // CHECK INSIDE WRAPPER + if (!image) { return false; } try { - const canvas = img.ownerDocument.createElement('canvas'); + const canvas = image.ownerDocument.createElement('canvas'); canvas.width = 10; canvas.height = 10; const context = canvas.getContext('2d'); if (context) { - context.drawImage(img, 0, 0); + context.drawImage(image, 0, 0); context.getImageData(0, 0, 1, 1); return true; } @@ -26,3 +30,11 @@ export function canRegenerateImage(img: HTMLImageElement | null): boolean { return false; } } + +const getWrappedImage = (wrapper: HTMLSpanElement) => { + const image = wrapper.firstElementChild; + if (image && isNodeOfType(image, 'ELEMENT_NODE') && isElementOfType(image, 'img')) { + return image; + } + return null; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts index a2ec3ffa62ab..1942fec8e7d1 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -11,7 +11,7 @@ import type { import type { ImageEditOptions } from '../types/ImageEditOptions'; import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; -const IMAGE_EDIT_SHADOW_ROOT = 'ImageEditShadowRoot'; +export const IMAGE_EDIT_SHADOW_ROOT = 'ImageEditShadowRoot'; /** * @internal diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 965ebb007225..042b69c2fe73 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -64,7 +64,8 @@ export type SetContentModel = ( model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated, - isInitializing?: boolean + isInitializing?: boolean, + skipSelectionChangedEvent?: boolean ) => DOMSelection | null; /** diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index c269e4830784..3911816cee47 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -43,6 +43,8 @@ export interface FormatContentModelOptions { * When pass to true, scroll the editing caret into view after write DOM tree if need */ scrollCaretIntoView?: boolean; + + skipSelectionChangedEvent?: boolean; } /** From 5cc4050eee978fe96ce2b0cd6c05267ae5a65568 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 17 Nov 2025 13:52:56 -0300 Subject: [PATCH 2/8] selection handler --- packages/roosterjs-content-model-core/lib/editor/Editor.ts | 1 - .../lib/imageEdit/ImageEditPlugin.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 896cab1e1548..9f18ceca3a5b 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -140,7 +140,6 @@ export class Editor implements IEditor { */ getDOMSelection(): DOMSelection | null { const core = this.getCore(); - console.log(core.api.getDOMSelection(core)); return core.api.getDOMSelection(core); } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index a92b9b5c8ef8..5878fb3ce437 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -398,7 +398,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editingImageModel = image; isRTL = editingImage.paragraph.format.direction == 'rtl'; this.imageEditInfo = updateImageEditInfo(image, selection.image); - image.format.imageState = 'isEditing'; + image.format.imageState = EDITING_MARKER; }); result = true; From 82b9e03599bae4ce42e7acb3fbdfdfe870f3cf3d Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 18 Nov 2025 15:56:08 -0300 Subject: [PATCH 3/8] selection handler --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 17 +- .../plugins/SampleImageEditPlugin.tsx | 42 ++++ demo/scripts/utils/imageEditOperator.ts | 23 +++ .../lib/imageEdit/ImageEditPlugin.ts | 182 +++++++++--------- 4 files changed, 163 insertions(+), 101 deletions(-) create mode 100644 demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx create mode 100644 demo/scripts/utils/imageEditOperator.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index f79a894b55e0..5e4dd19eadea 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -110,6 +110,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private samplePickerPlugin: SamplePickerPlugin; private snapshots: Snapshots; private markdownPanePlugin: MarkdownPanePlugin; + private imageEditPlugin: ImageEditPlugin; protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; @@ -164,26 +165,24 @@ export class MainPane extends React.Component<{}, MainPaneState> { }, activeTab: 'all', }; + + this.imageEditPlugin = new ImageEditPlugin({ + disableSideResize: this.state.initState.disableSideResize, + }); } render() { const theme = getTheme(this.state.isDarkMode); - const imageEditPlugin = this.state.initState.pluginList.imageEditPlugin - ? new ImageEditPlugin({ - disableSideResize: this.state.initState.disableSideResize, - }) - : null; - return ( {this.renderTitleBar()} {!this.state.popoutWindow && this.renderTabs()} - {!this.state.popoutWindow && this.renderRibbon(imageEditPlugin)} + {!this.state.popoutWindow && this.renderRibbon(this.imageEditPlugin)}
{this.state.popoutWindow - ? this.renderPopout(imageEditPlugin) - : this.renderMainPane(imageEditPlugin)} + ? this.renderPopout(this.imageEditPlugin) + : this.renderMainPane(this.imageEditPlugin)}
); diff --git a/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx b/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx new file mode 100644 index 000000000000..2fc7c59fa474 --- /dev/null +++ b/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx @@ -0,0 +1,42 @@ +import { addImageEditOperator, removeImageEditOperator } from '../../utils/imageEditOperator'; +import { ImageEditOptions, ImageEditPlugin } from 'roosterjs-content-model-plugins'; +import type { EditorPlugin, IEditor } from 'roosterjs-content-model-types'; + +/** + * A plugin to help get HTML content from editor + */ +export class SampleImageEditPlugin extends ImageEditPlugin implements EditorPlugin { + private imageEditId = 'imageEdit'; + + /** + * Create a new instance of UpdateContentPlugin class + * @param onUpdate A callback to be invoked when update happens + */ + constructor(options?: ImageEditOptions) { + super(options); + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'SampleImageEditPlugin'; + } + + /** + * Initialize this plugin + * @param editor The editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + addImageEditOperator(this.imageEditId, this); + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + removeImageEditOperator(this.imageEditId); + } +} diff --git a/demo/scripts/utils/imageEditOperator.ts b/demo/scripts/utils/imageEditOperator.ts new file mode 100644 index 000000000000..41283705e7ad --- /dev/null +++ b/demo/scripts/utils/imageEditOperator.ts @@ -0,0 +1,23 @@ +import type { ImageEditor } from 'roosterjs-content-model-types'; + +const imageEdit: { [id: string]: ImageEditor } = {}; + +export function addImageEditOperator(id: string, imageEditPlugin: ImageEditor): void { + imageEdit[id] = imageEditPlugin; +} + +export function removeImageEditOperator(id: string): void { + delete imageEdit[id]; +} + +export function operateImageEdit( + id: string | null, + callback: (imageEditPlugin: ImageEditor) => void +): void { + if (!!id) { + const imageEditPlugin = imageEdit[id]; + if (imageEditPlugin) { + callback(imageEditPlugin); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 5878fb3ce437..ea387b4284c2 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -133,9 +133,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { blur: { beforeDispatch: ev => { if (this.editor && this.selectedImage) { - if (this.editor) { - this.applyFormatWithContentModel(this.editor, this.isCropMode); - } + this.applyFormatWithContentModel(this.editor, this.isCropMode); } }, }, @@ -238,9 +236,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { if ((event.newSelection && event.newSelection.type == 'image') || this.selectedImage) { - editor.getDocument().defaultView?.requestAnimationFrame(() => { - this.applyFormatWithContentModel(editor, this.isCropMode); - }); + this.applyFormatWithContentModel(editor, this.isCropMode); } } @@ -335,102 +331,104 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); let isRTL: boolean = false; + editor.getDocument().defaultView?.requestAnimationFrame(() => { + editor.formatContentModel( + (model, context) => { + const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model, undefined); + let result = false; + + // Skip adding undo snapshot for now. If we detect any changes later, we will reset it + context.skipUndoSnapshot = 'SkipAll'; + + const clickInDifferentImage = + previousSelectedImage?.image != editingImage?.image; - editor.formatContentModel( - (model, context) => { - const editingImage = getSelectedImage(model); - const previousSelectedImage = isApiOperation - ? editingImage - : findEditingImage(model, undefined); - let result = false; - - // Skip adding undo snapshot for now. If we detect any changes later, we will reset it - context.skipUndoSnapshot = 'SkipAll'; - - const clickInDifferentImage = previousSelectedImage?.image != editingImage?.image; - - if ( - clickInDifferentImage || - previousSelectedImage?.image.format.imageState == EDITING_MARKER || - isApiOperation - ) { - const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( - previousSelectedImage && - lastSrc && - selectedImage && - imageEditInfo && - clonedImage + clickInDifferentImage || + previousSelectedImage?.image.format.imageState == EDITING_MARKER || + isApiOperation ) { - mutateSegment( - previousSelectedImage.paragraph, - previousSelectedImage.image, - image => { - const changeState = applyChange( - editor, - selectedImage, - image, - imageEditInfo, - lastSrc, - this.wasImageResized || this.isCropMode, - clonedImage - ); - - if (this.wasImageResized || changeState == 'FullyChanged') { - context.skipUndoSnapshot = false; + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + previousSelectedImage && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + const changeState = applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + + if (this.wasImageResized || changeState == 'FullyChanged') { + context.skipUndoSnapshot = false; + } + image.format.imageState = undefined; } - image.format.imageState = undefined; - } - ); + ); - this.cleanInfo(); - result = true; - } + this.cleanInfo(); + result = true; + } - if ( - clickInDifferentImage && - editingImage && - selection?.type == 'image' && - !isApiOperation - ) { - this.isCropMode = isCropMode; - mutateSegment(editingImage.paragraph, editingImage.image, image => { - editingImageModel = image; - isRTL = editingImage.paragraph.format.direction == 'rtl'; - this.imageEditInfo = updateImageEditInfo(image, selection.image); - image.format.imageState = EDITING_MARKER; - }); - - result = true; + if ( + clickInDifferentImage && + editingImage && + selection?.type == 'image' && + !isApiOperation + ) { + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + isRTL = editingImage.paragraph.format.direction == 'rtl'; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.format.imageState = EDITING_MARKER; + }); + + result = true; + } } - } - return result; - }, - { - skipSelectionChangedEvent: true, - onNodeCreated: (model, node) => { - if ( - !isApiOperation && - editingImageModel && - editingImageModel == model && - editingImageModel.format.imageState == EDITING_MARKER && - isNodeOfType(node, 'ELEMENT_NODE') && - isElementOfType(node, 'img') - ) { - if (isCropMode) { - this.startCropMode(editor, node, isRTL); - } else { - this.startRotateAndResize(editor, node, isRTL); + return result; + }, + { + skipSelectionChangedEvent: true, + onNodeCreated: (model, node) => { + if ( + !isApiOperation && + editingImageModel && + editingImageModel == model && + editingImageModel.format.imageState == EDITING_MARKER && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node, isRTL); + } else { + this.startRotateAndResize(editor, node, isRTL); + } } - } + }, + apiName: IMAGE_EDIT_FORMAT_EVENT, }, - apiName: IMAGE_EDIT_FORMAT_EVENT, - }, - { - tryGetFromCache: true, - } - ); + { + tryGetFromCache: true, + } + ); + }); } private startEditing( From a2af193851c1b3bb98f694f506c6539f705635ad Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Nov 2025 11:58:14 -0300 Subject: [PATCH 4/8] WIP --- .../plugins/SampleImageEditPlugin.tsx | 42 - .../editorOptions/EditorOptionsPlugin.ts | 1 + .../editorOptions/ExperimentalFeatures.tsx | 1 + .../corePlugin/selection/SelectionPlugin.ts | 14 +- .../lib/imageEdit/ImageEditPlugin.ts | 782 +--------------- .../lib/imageEdit/ImageEditPluginV2.ts | 838 +++++++++++++++++ .../lib/imageEdit/LegacyImageEditPlugin.ts | 871 ++++++++++++++++++ .../test/imageEdit/ImageEditPluginTest.ts | 50 +- .../lib/editor/ExperimentalFeature.ts | 7 +- 9 files changed, 1775 insertions(+), 831 deletions(-) delete mode 100644 demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts diff --git a/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx b/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx deleted file mode 100644 index 2fc7c59fa474..000000000000 --- a/demo/scripts/controlsV2/plugins/SampleImageEditPlugin.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { addImageEditOperator, removeImageEditOperator } from '../../utils/imageEditOperator'; -import { ImageEditOptions, ImageEditPlugin } from 'roosterjs-content-model-plugins'; -import type { EditorPlugin, IEditor } from 'roosterjs-content-model-types'; - -/** - * A plugin to help get HTML content from editor - */ -export class SampleImageEditPlugin extends ImageEditPlugin implements EditorPlugin { - private imageEditId = 'imageEdit'; - - /** - * Create a new instance of UpdateContentPlugin class - * @param onUpdate A callback to be invoked when update happens - */ - constructor(options?: ImageEditOptions) { - super(options); - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'SampleImageEditPlugin'; - } - - /** - * Initialize this plugin - * @param editor The editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - addImageEditOperator(this.imageEditId, this); - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - removeImageEditOperator(this.imageEditId); - } -} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 8411d930ecff..84945f69ddab 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -67,6 +67,7 @@ const initialState: OptionState = { 'HandleEnterKey', 'CustomCopyCut', 'CloneIndependentRoot', + 'ImageEditV2', ]), }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx index 3779f3c71896..2044ba96f0fc 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -15,6 +15,7 @@ export class ExperimentalFeatures extends React.Component ); } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 19e495b12986..1aa7bbb0d692 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -24,7 +24,7 @@ import type { } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; -//const MouseRightButton = 2; +const MouseRightButton = 2; const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; @@ -182,6 +182,18 @@ class SelectionPlugin implements PluginWithState { const selection = editor.getDOMSelection(); let image: HTMLImageElement | null; + // Image selection + if ( + !editor.isExperimentalFeatureEnabled('ImageEditV2') && + selection?.type == 'image' && + (rawEvent.button == MouseLeftButton || + (rawEvent.button == MouseRightButton && + !this.getClickingImage(rawEvent) && + !this.getContainedTargetImage(rawEvent, selection))) + ) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + if ( (image = this.getClickingImage(rawEvent) ?? diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index ea387b4284c2..9f48a407b645 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -1,51 +1,12 @@ -import { applyChange } from './utils/applyChange'; -import { canRegenerateImage } from './utils/canRegenerateImage'; -import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; -import { createImageWrapper, IMAGE_EDIT_SHADOW_ROOT } from './utils/createImageWrapper'; -import { Cropper } from './Cropper/cropperContext'; -import { EDITING_MARKER, findEditingImage } from './utils/findEditingImage'; -import { filterInnerResizerHandles } from './utils/filterInnerResizerHandles'; -import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; -import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; -import { getSelectedImage } from './utils/getSelectedImage'; -import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; -import { ImageEditElementClass } from './types/ImageEditElementClass'; -import { Resizer } from './Resizer/resizerContext'; -import { Rotator } from './Rotator/rotatorContext'; -import { updateHandleCursor } from './utils/updateHandleCursor'; -import { updateRotateHandle } from './Rotator/updateRotateHandle'; -import { updateWrapper } from './utils/updateWrapper'; -//import { normalizeImageSelection } from './utils/normalizeImageSelection'; -import { - ChangeSource, - getSafeIdSelector, - //getSelectedParagraphs, - isElementOfType, - isEntityElement, - isNodeOfType, - mutateBlock, - mutateSegment, - setImageState, - unwrap, -} from 'roosterjs-content-model-dom'; -import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; -import type { DragAndDropContext } from './types/DragAndDropContext'; -import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; +import { ImageEditPluginV2 } from './ImageEditPluginV2'; +import { LegacyImageEditPlugin } from './LegacyImageEditPlugin'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { - ContentChangedEvent, - ContentModelImage, EditorPlugin, IEditor, ImageEditOperation, ImageEditor, - ImageMetadataFormat, - KeyDownEvent, - MouseDownEvent, - // MouseDownEvent, - // MouseUpEvent, PluginEvent, - SelectionChangedEvent, } from 'roosterjs-content-model-types'; const DefaultOptions: Partial = { @@ -58,12 +19,6 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; -//const MouseRightButton = 2; -const DRAG_ID = '_dragging'; -const IMAGE_EDIT_CLASS = 'imageEdit'; -const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; -const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; - /** * ImageEdit plugin handles the following image editing features: * - Resize image @@ -72,22 +27,7 @@ const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; * - Flip image */ export class ImageEditPlugin implements ImageEditor, EditorPlugin { - protected editor: IEditor | null = null; - private shadowSpan: HTMLSpanElement | null = null; - private selectedImage: HTMLImageElement | null = null; - protected wrapper: HTMLSpanElement | null = null; - protected imageEditInfo: ImageMetadataFormat | null = null; - private imageHTMLOptions: ImageHtmlOptions | null = null; - private dndHelpers: DragAndDropHelper[] = []; - private clonedImage: HTMLImageElement | null = null; - private lastSrc: string | null = null; - private wasImageResized: boolean = false; - private isCropMode: boolean = false; - private resizers: HTMLDivElement[] = []; - private rotators: HTMLDivElement[] = []; - private croppers: HTMLDivElement[] = []; - private zoomScale: number = 1; - private disposer: (() => void) | null = null; + private imageEditPlugin: LegacyImageEditPlugin | ImageEditPluginV2 | null = null; protected options: ImageEditOptions; constructor(options?: ImageEditOptions) { @@ -108,36 +48,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * @param editor The editor object */ initialize(editor: IEditor) { - this.editor = editor; - this.disposer = editor.attachDomEvent({ - dragstart: { - beforeDispatch: ev => { - if (this.editor) { - const target = ev.target as Node; - if (this.isImageSelection(target)) { - target.id = target.id + DRAG_ID; - } - } - }, - }, - dragend: { - beforeDispatch: ev => { - if (this.editor) { - const target = ev.target as Node; - if (this.isImageSelection(target) && target.id.includes(DRAG_ID)) { - target.id = target.id.replace(DRAG_ID, '').trim(); - } - } - }, - }, - blur: { - beforeDispatch: ev => { - if (this.editor && this.selectedImage) { - this.applyFormatWithContentModel(this.editor, this.isCropMode); - } - }, - }, - }); + const isV2Enabled = editor.isExperimentalFeatureEnabled('ImageEditV2'); + this.imageEditPlugin = isV2Enabled + ? new ImageEditPluginV2(this.options) + : new LegacyImageEditPlugin(this.options); + this.imageEditPlugin.initialize(editor); } /** @@ -146,12 +61,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * called, plugin should not call to any editor method since it will result in error. */ dispose() { - if (this.disposer) { - this.disposer(); - this.disposer = null; - } - this.cleanInfo(); - this.editor = null; + this.imageEditPlugin?.dispose(); } /** @@ -161,678 +71,26 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * @param event The event to handle: */ onPluginEvent(event: PluginEvent) { - if (!this.editor) { - return; - } - switch (event.eventType) { - case 'selectionChanged': - this.selectionChangeHandler(this.editor, event); - break; - case 'keyDown': - this.keyDownHandler(this.editor, event); - break; - case 'mouseDown': - this.mouseDownHandler(this.editor, event); - break; - case 'contentChanged': - this.contentChangedHandler(this.editor, event); - break; - case 'extractContentWithDom': - this.removeImageEditing(event.clonedRoot); - break; - case 'beforeLogicalRootChange': - this.handleBeforeLogicalRootChange(); - break; - } - } - - private handleBeforeLogicalRootChange() { - if (this.selectedImage && this.editor && !this.editor.isDisposed()) { - this.applyFormatWithContentModel(this.editor, this.isCropMode); - } - } - - private removeImageEditing(clonedRoot: HTMLElement) { - const images = clonedRoot.querySelectorAll('img'); - images.forEach(image => { - if (image.dataset.editingInfo) { - delete image.dataset.editingInfo; - } - }); - } - - private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - const selection = editor.getDOMSelection(); - const target = event.rawEvent.target as Element; - const isEditingImage = - target.firstElementChild?.id == IMAGE_EDIT_SHADOW_ROOT && target.childElementCount == 1; - if (selection?.type == 'image' && (isEditingImage || isEntityElement(target))) { - const range = editor.getDocument().createRange(); - const image = this.removeImageWrapper(); - if (image) { - range.selectNode(image); - range.collapse(); - editor.setDOMSelection({ - type: 'range', - range, - isReverted: false, - }); - } - } - } - - private isImageSelection(target: Node): target is HTMLElement { - return ( - isNodeOfType(target, 'ELEMENT_NODE') && - (isElementOfType(target, 'img') || - !!( - isElementOfType(target, 'span') && - target.firstElementChild && - isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && - isElementOfType(target.firstElementChild, 'img') - )) - ); - } - - private selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { - if ((event.newSelection && event.newSelection.type == 'image') || this.selectedImage) { - this.applyFormatWithContentModel(editor, this.isCropMode); - } - } - - private onDropHandler(editor: IEditor) { - const selection = editor.getDOMSelection(); - if (selection?.type == 'image') { - editor.formatContentModel(model => { - const imageDragged = findEditingImage(model, selection.image.id); - const imageDropped = findEditingImage( - model, - selection.image.id.replace(DRAG_ID, '').trim() - ); - if (imageDragged && imageDropped) { - const draggedIndex = imageDragged.paragraph.segments.indexOf( - imageDragged.image - ); - mutateBlock(imageDragged.paragraph).segments.splice(draggedIndex, 1); - const segment = imageDropped.image; - const paragraph = imageDropped.paragraph; - mutateSegment(paragraph, segment, image => { - image.isSelected = true; - image.isSelectedAsImageSelection = true; - }); - - return true; - } - return false; - }); - } - } - - private keyDownHandler(editor: IEditor, event: KeyDownEvent) { - if (this.selectedImage) { - if ( - event.rawEvent.key === 'Escape' || - event.rawEvent.key === 'Delete' || - event.rawEvent.key === 'Backspace' - ) { - if (event.rawEvent.key === 'Escape') { - this.removeImageWrapper(); - } - this.cleanInfo(); - } else { - if (event.rawEvent.key == 'Enter' && this.isCropMode) { - event.rawEvent.preventDefault(); - } - this.applyFormatWithContentModel( - editor, - this.isCropMode, - false /* isApiOperation */ - ); - } - } - } - - private setContentHandler(editor: IEditor) { - const selection = editor.getDOMSelection(); - if (selection?.type == 'image') { - this.cleanInfo(); - setImageState(selection.image, ''); - } - } - - private formatEventHandler(event: ContentChangedEvent) { - if (this.selectedImage && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { - this.cleanInfo(); - } - } - - private contentChangedHandler(editor: IEditor, event: ContentChangedEvent) { - switch (event.source) { - case ChangeSource.SetContent: - this.setContentHandler(editor); - break; - case ChangeSource.Format: - this.formatEventHandler(event); - break; - case ChangeSource.Drop: - this.onDropHandler(editor); - break; - } - } - - /** - * EXPOSED FOR TESTING PURPOSE ONLY - */ - protected applyFormatWithContentModel( - editor: IEditor, - isCropMode: boolean, - isApiOperation?: boolean - ) { - let editingImageModel: ContentModelImage | undefined; - const selection = editor.getDOMSelection(); - let isRTL: boolean = false; - editor.getDocument().defaultView?.requestAnimationFrame(() => { - editor.formatContentModel( - (model, context) => { - const editingImage = getSelectedImage(model); - const previousSelectedImage = isApiOperation - ? editingImage - : findEditingImage(model, undefined); - let result = false; - - // Skip adding undo snapshot for now. If we detect any changes later, we will reset it - context.skipUndoSnapshot = 'SkipAll'; - - const clickInDifferentImage = - previousSelectedImage?.image != editingImage?.image; - - if ( - clickInDifferentImage || - previousSelectedImage?.image.format.imageState == EDITING_MARKER || - isApiOperation - ) { - const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; - if ( - previousSelectedImage && - lastSrc && - selectedImage && - imageEditInfo && - clonedImage - ) { - mutateSegment( - previousSelectedImage.paragraph, - previousSelectedImage.image, - image => { - const changeState = applyChange( - editor, - selectedImage, - image, - imageEditInfo, - lastSrc, - this.wasImageResized || this.isCropMode, - clonedImage - ); - - if (this.wasImageResized || changeState == 'FullyChanged') { - context.skipUndoSnapshot = false; - } - image.format.imageState = undefined; - } - ); - - this.cleanInfo(); - result = true; - } - - if ( - clickInDifferentImage && - editingImage && - selection?.type == 'image' && - !isApiOperation - ) { - this.isCropMode = isCropMode; - mutateSegment(editingImage.paragraph, editingImage.image, image => { - editingImageModel = image; - isRTL = editingImage.paragraph.format.direction == 'rtl'; - this.imageEditInfo = updateImageEditInfo(image, selection.image); - image.format.imageState = EDITING_MARKER; - }); - - result = true; - } - } - - return result; - }, - { - skipSelectionChangedEvent: true, - onNodeCreated: (model, node) => { - if ( - !isApiOperation && - editingImageModel && - editingImageModel == model && - editingImageModel.format.imageState == EDITING_MARKER && - isNodeOfType(node, 'ELEMENT_NODE') && - isElementOfType(node, 'img') - ) { - if (isCropMode) { - this.startCropMode(editor, node, isRTL); - } else { - this.startRotateAndResize(editor, node, isRTL); - } - } - }, - apiName: IMAGE_EDIT_FORMAT_EVENT, - }, - { - tryGetFromCache: true, - } - ); - }); + this.imageEditPlugin?.onPluginEvent(event); } - private startEditing( - editor: IEditor, - image: HTMLImageElement, - apiOperation: ImageEditOperation[] - ) { - if (!this.imageEditInfo) { - this.imageEditInfo = getSelectedImageMetadata(editor, image); - } - - if ( - (this.imageEditInfo.widthPx == 0 || this.imageEditInfo.heightPx == 0) && - !image.complete - ) { - // Image dimensions are zero and loading is incomplete, wait for image to load. - image.onload = () => { - this.updateImageDimensionsIfZero(image); - this.startEditingInternal(editor, image, apiOperation); - image.onload = null; - image.onerror = null; - }; - image.onerror = () => { - image.onload = null; - image.onerror = null; - }; - } else { - this.updateImageDimensionsIfZero(image); - this.startEditingInternal(editor, image, apiOperation); - } - } - - private updateImageDimensionsIfZero(image: HTMLImageElement) { - if (this.imageEditInfo?.widthPx === 0 || this.imageEditInfo?.heightPx === 0) { - this.imageEditInfo.widthPx = image.clientWidth; - this.imageEditInfo.heightPx = image.clientHeight; - } - } - - private startEditingInternal( - editor: IEditor, - image: HTMLImageElement, - apiOperation: ImageEditOperation[] - ) { - if (!this.imageEditInfo) { - this.imageEditInfo = getSelectedImageMetadata(editor, image); - } - - this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); - this.lastSrc = image.getAttribute('src'); - - const { - resizers, - rotators, - wrapper, - shadowSpan, - imageClone, - croppers, - } = createImageWrapper( - editor, - image, - this.options, - this.imageEditInfo, - this.imageHTMLOptions, - apiOperation - ); - this.shadowSpan = shadowSpan; - this.selectedImage = image; - this.wrapper = wrapper; - this.clonedImage = imageClone; - this.wasImageResized = checkIfImageWasResized(image); - this.resizers = resizers; - this.rotators = rotators; - this.croppers = croppers; - this.zoomScale = editor.getDOMHelper().calculateZoomScale(); - - editor.setEditorStyle(IMAGE_EDIT_CLASS, `outline-style:none!important;`, [ - `span:has(>img${getSafeIdSelector(this.selectedImage.id)})`, - ]); - - editor.setEditorStyle(IMAGE_EDIT_CLASS_CARET, `caret-color: transparent;`); - } - - public startRotateAndResize(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { - if (this.imageEditInfo) { - this.startEditing(editor, image, ['resize', 'rotate']); - - if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { - const isMobileOrTable = !!editor.getEnvironment().isMobileOrTablet; - this.dndHelpers = [ - ...getDropAndDragHelpers( - this.wrapper, - this.imageEditInfo, - this.options, - ImageEditElementClass.ResizeHandle, - Resizer, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.resizers, - undefined /* croppers */, - isRTL - ); - this.wasImageResized = true; - } - }, - this.zoomScale, - isMobileOrTable - ), - ...getDropAndDragHelpers( - this.wrapper, - this.imageEditInfo, - this.options, - ImageEditElementClass.RotateHandle, - Rotator, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined /* resizers */, - undefined /* croppers */, - isRTL, - true /* isRotating */ - ); - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad, - !!this.options?.disableSideResize - ); - this.updateResizeHandleDirection( - this.resizers, - this.imageEditInfo.angleRad - ); - } - }, - this.zoomScale, - isMobileOrTable - ), - ]; - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.resizers, - undefined /* croppers */, - isRTL - ); - - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad, - !!this.options?.disableSideResize - ); - } - } - } - - private updateResizeHandleDirection(resizers: HTMLDivElement[], angleRad: number | undefined) { - const resizeHandles = filterInnerResizerHandles(resizers); - if (angleRad !== undefined) { - updateHandleCursor(resizeHandles, angleRad); - } - } - - private updateRotateHandleState( - editor: IEditor, - image: HTMLImageElement, - wrapper: HTMLSpanElement, - rotators: HTMLDivElement[], - angleRad: number | undefined, - disableSideResize: boolean - ) { - const viewport = editor.getVisibleViewport(); - const smallImage = isASmallImage(image.width, image.height); - if (viewport && rotators && rotators.length > 0) { - const rotator = rotators[0]; - const rotatorHandle = rotator.firstElementChild; - if ( - isNodeOfType(rotatorHandle, 'ELEMENT_NODE') && - isElementOfType(rotatorHandle, 'div') - ) { - updateRotateHandle( - viewport, - angleRad ?? 0, - wrapper, - rotator, - rotatorHandle, - smallImage, - disableSideResize - ); - } - } - } - - public isOperationAllowed(operation: ImageEditOperation): boolean { - return ( - operation === 'resize' || - operation === 'rotate' || - operation === 'flip' || - operation === 'crop' - ); - } - - public canRegenerateImage(image: HTMLImageElement): boolean { - return canRegenerateImage(image); + flipImage(direction: 'vertical' | 'horizontal'): void { + this.imageEditPlugin?.flipImage(direction); } - private startCropMode(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { - if (this.imageEditInfo) { - this.startEditing(editor, image, ['crop']); - if (this.imageEditInfo && this.selectedImage && this.wrapper && this.clonedImage) { - this.dndHelpers = [ - ...getDropAndDragHelpers( - this.wrapper, - this.imageEditInfo, - this.options, - ImageEditElementClass.CropHandle, - Cropper, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined /* resizers */, - this.croppers, - isRTL - ); - this.isCropMode = true; - } - }, - this.zoomScale, - !!editor.getEnvironment().isMobileOrTablet - ), - ]; - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined /* resizers */, - this.croppers, - isRTL - ); - } - } + cropImage(): void { + this.imageEditPlugin?.cropImage(); } - public cropImage() { - if (!this.editor) { - return; - } - if (!this.editor.getEnvironment().isSafari) { - this.editor.focus(); // Safari will keep the selection when click crop, then the focus() call should not be called - } - const selection = this.editor.getDOMSelection(); - if (selection?.type == 'image') { - this.applyFormatWithContentModel( - this.editor, - true /* isCropMode */, - false /* shouldSelectImage */ - ); - } - } - - private editImage( - editor: IEditor, - image: HTMLImageElement, - apiOperation: ImageEditOperation[], - operation: (imageEditInfo: ImageMetadataFormat) => void - ) { - this.startEditing(editor, image, apiOperation); - if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { - return; - } - - operation(this.imageEditInfo); - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper - ); - - this.applyFormatWithContentModel(editor, false /* isCrop */, true /* isApiOperation */); - } - - /** - * Exported for testing purpose only - */ - public cleanInfo() { - this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); - this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); - this.selectedImage = null; - this.shadowSpan = null; - this.wrapper = null; - this.imageEditInfo = null; - this.imageHTMLOptions = null; - this.dndHelpers.forEach(helper => helper.dispose()); - this.dndHelpers = []; - this.clonedImage = null; - this.lastSrc = null; - this.wasImageResized = false; - this.isCropMode = false; - this.resizers = []; - this.rotators = []; - this.croppers = []; - } - - private removeImageWrapper() { - let image: HTMLImageElement | null = null; - if (this.shadowSpan && this.shadowSpan.parentElement) { - if ( - this.shadowSpan.firstElementChild && - isNodeOfType(this.shadowSpan.firstElementChild, 'ELEMENT_NODE') && - isElementOfType(this.shadowSpan.firstElementChild, 'img') - ) { - image = this.shadowSpan.firstElementChild; - } - unwrap(this.shadowSpan); - this.shadowSpan = null; - this.wrapper = null; - } - - return image; + canRegenerateImage(image: HTMLImageElement): boolean { + return !!this.imageEditPlugin?.canRegenerateImage(image); } - public flipImage(direction: 'horizontal' | 'vertical') { - const selection = this.editor?.getDOMSelection(); - if (!this.editor || !selection || selection.type !== 'image') { - return; - } - const image = selection.image; - if (this.editor) { - this.editImage(this.editor, image, ['flip'], imageEditInfo => { - const angleRad = imageEditInfo.angleRad || 0; - const isInVerticalPosition = - (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || - (angleRad <= -Math.PI / 2 && angleRad > (-3 * Math.PI) / 4); - if (isInVerticalPosition) { - if (direction === 'horizontal') { - imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; - } else { - imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; - } - } else { - if (direction === 'vertical') { - imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; - } else { - imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; - } - } - }); - } + rotateImage(angleRad: number): void { + this.imageEditPlugin?.rotateImage(angleRad); } - public rotateImage(angleRad: number) { - const selection = this.editor?.getDOMSelection(); - if (!this.editor || !selection || selection.type !== 'image') { - return; - } - const image = selection.image; - if (this.editor) { - this.editImage(this.editor, image, [], imageEditInfo => { - imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; - }); - } + isOperationAllowed(operation: ImageEditOperation): boolean { + return !!this.imageEditPlugin?.isOperationAllowed(operation); } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts new file mode 100644 index 000000000000..93749a9112ee --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts @@ -0,0 +1,838 @@ +import { applyChange } from './utils/applyChange'; +import { canRegenerateImage } from './utils/canRegenerateImage'; +import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; +import { createImageWrapper, IMAGE_EDIT_SHADOW_ROOT } from './utils/createImageWrapper'; +import { Cropper } from './Cropper/cropperContext'; +import { EDITING_MARKER, findEditingImage } from './utils/findEditingImage'; +import { filterInnerResizerHandles } from './utils/filterInnerResizerHandles'; +import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; +import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; +import { getSelectedImage } from './utils/getSelectedImage'; +import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; +import { ImageEditElementClass } from './types/ImageEditElementClass'; +import { Resizer } from './Resizer/resizerContext'; +import { Rotator } from './Rotator/rotatorContext'; +import { updateHandleCursor } from './utils/updateHandleCursor'; +import { updateRotateHandle } from './Rotator/updateRotateHandle'; +import { updateWrapper } from './utils/updateWrapper'; +//import { normalizeImageSelection } from './utils/normalizeImageSelection'; +import { + ChangeSource, + getSafeIdSelector, + //getSelectedParagraphs, + isElementOfType, + isEntityElement, + isNodeOfType, + mutateBlock, + mutateSegment, + setImageState, + unwrap, +} from 'roosterjs-content-model-dom'; +import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; +import type { DragAndDropContext } from './types/DragAndDropContext'; +import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; +import type { ImageEditOptions } from './types/ImageEditOptions'; +import type { + ContentChangedEvent, + ContentModelImage, + EditorPlugin, + IEditor, + ImageEditOperation, + ImageEditor, + ImageMetadataFormat, + KeyDownEvent, + MouseDownEvent, + // MouseDownEvent, + // MouseUpEvent, + PluginEvent, + SelectionChangedEvent, +} from 'roosterjs-content-model-types'; + +const DefaultOptions: Partial = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: ['resize', 'rotate'], +}; + +//const MouseRightButton = 2; +const DRAG_ID = '_dragging'; +const IMAGE_EDIT_CLASS = 'imageEdit'; +const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; +const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; + +/** + * ImageEdit plugin handles the following image editing features: + * - Resize image + * - Crop image + * - Rotate image + * - Flip image + */ +export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { + protected editor: IEditor | null = null; + private shadowSpan: HTMLSpanElement | null = null; + private selectedImage: HTMLImageElement | null = null; + protected wrapper: HTMLSpanElement | null = null; + protected imageEditInfo: ImageMetadataFormat | null = null; + private imageHTMLOptions: ImageHtmlOptions | null = null; + private dndHelpers: DragAndDropHelper[] = []; + private clonedImage: HTMLImageElement | null = null; + private lastSrc: string | null = null; + private wasImageResized: boolean = false; + private isCropMode: boolean = false; + private resizers: HTMLDivElement[] = []; + private rotators: HTMLDivElement[] = []; + private croppers: HTMLDivElement[] = []; + private zoomScale: number = 1; + private disposer: (() => void) | null = null; + protected options: ImageEditOptions; + + constructor(options?: ImageEditOptions) { + this.options = { ...DefaultOptions, ...options }; + } + + /** + * Get name of this plugin + */ + getName() { + return 'ImageEdit'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = editor.attachDomEvent({ + dragstart: { + beforeDispatch: ev => { + if (this.editor) { + const target = ev.target as Node; + if (this.isImageSelection(target)) { + target.id = target.id + DRAG_ID; + } + } + }, + }, + dragend: { + beforeDispatch: ev => { + if (this.editor) { + const target = ev.target as Node; + if (this.isImageSelection(target) && target.id.includes(DRAG_ID)) { + target.id = target.id.replace(DRAG_ID, '').trim(); + } + } + }, + }, + blur: { + beforeDispatch: ev => { + if (this.editor && this.selectedImage) { + this.applyFormatWithContentModel(this.editor, this.isCropMode); + } + }, + }, + }); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + this.cleanInfo(); + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + switch (event.eventType) { + case 'selectionChanged': + this.selectionChangeHandler(this.editor, event); + break; + case 'keyDown': + this.keyDownHandler(this.editor, event); + break; + case 'mouseDown': + this.mouseDownHandler(this.editor, event); + break; + case 'contentChanged': + this.contentChangedHandler(this.editor, event); + break; + case 'extractContentWithDom': + this.removeImageEditing(event.clonedRoot); + break; + case 'beforeLogicalRootChange': + this.handleBeforeLogicalRootChange(); + break; + } + } + + private handleBeforeLogicalRootChange() { + if (this.selectedImage && this.editor && !this.editor.isDisposed()) { + this.applyFormatWithContentModel(this.editor, this.isCropMode); + } + } + + private removeImageEditing(clonedRoot: HTMLElement) { + const images = clonedRoot.querySelectorAll('img'); + images.forEach(image => { + if (image.dataset.editingInfo) { + delete image.dataset.editingInfo; + } + }); + } + + private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { + const selection = editor.getDOMSelection(); + const target = event.rawEvent.target as Element; + const isEditingImage = + target.firstElementChild?.id == IMAGE_EDIT_SHADOW_ROOT && target.childElementCount == 1; + if (selection?.type == 'image' && (isEditingImage || isEntityElement(target))) { + const range = editor.getDocument().createRange(); + const image = this.removeImageWrapper(); + if (image) { + range.selectNode(image); + range.collapse(); + editor.setDOMSelection({ + type: 'range', + range, + isReverted: false, + }); + } + } + } + + private isImageSelection(target: Node): target is HTMLElement { + return ( + isNodeOfType(target, 'ELEMENT_NODE') && + (isElementOfType(target, 'img') || + !!( + isElementOfType(target, 'span') && + target.firstElementChild && + isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(target.firstElementChild, 'img') + )) + ); + } + + private selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { + if ((event.newSelection && event.newSelection.type == 'image') || this.selectedImage) { + this.applyFormatWithContentModel(editor, this.isCropMode); + } + } + + private onDropHandler(editor: IEditor) { + const selection = editor.getDOMSelection(); + if (selection?.type == 'image') { + editor.formatContentModel(model => { + const imageDragged = findEditingImage(model, selection.image.id); + const imageDropped = findEditingImage( + model, + selection.image.id.replace(DRAG_ID, '').trim() + ); + if (imageDragged && imageDropped) { + const draggedIndex = imageDragged.paragraph.segments.indexOf( + imageDragged.image + ); + mutateBlock(imageDragged.paragraph).segments.splice(draggedIndex, 1); + const segment = imageDropped.image; + const paragraph = imageDropped.paragraph; + mutateSegment(paragraph, segment, image => { + image.isSelected = true; + image.isSelectedAsImageSelection = true; + }); + + return true; + } + return false; + }); + } + } + + private keyDownHandler(editor: IEditor, event: KeyDownEvent) { + if (this.selectedImage) { + if ( + event.rawEvent.key === 'Escape' || + event.rawEvent.key === 'Delete' || + event.rawEvent.key === 'Backspace' + ) { + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } + this.cleanInfo(); + } else { + if (event.rawEvent.key == 'Enter' && this.isCropMode) { + event.rawEvent.preventDefault(); + } + this.applyFormatWithContentModel( + editor, + this.isCropMode, + false /* isApiOperation */ + ); + } + } + } + + private setContentHandler(editor: IEditor) { + const selection = editor.getDOMSelection(); + if (selection?.type == 'image') { + this.cleanInfo(); + setImageState(selection.image, ''); + } + } + + private formatEventHandler(event: ContentChangedEvent) { + if (this.selectedImage && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { + this.cleanInfo(); + } + } + + private contentChangedHandler(editor: IEditor, event: ContentChangedEvent) { + switch (event.source) { + case ChangeSource.SetContent: + this.setContentHandler(editor); + break; + case ChangeSource.Format: + this.formatEventHandler(event); + break; + case ChangeSource.Drop: + this.onDropHandler(editor); + break; + } + } + + /** + * EXPOSED FOR TESTING PURPOSE ONLY + */ + protected applyFormatWithContentModel( + editor: IEditor, + isCropMode: boolean, + isApiOperation?: boolean + ) { + let editingImageModel: ContentModelImage | undefined; + const selection = editor.getDOMSelection(); + let isRTL: boolean = false; + editor.getDocument().defaultView?.requestAnimationFrame(() => { + editor.formatContentModel( + (model, context) => { + const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model, undefined); + let result = false; + + // Skip adding undo snapshot for now. If we detect any changes later, we will reset it + context.skipUndoSnapshot = 'SkipAll'; + + const clickInDifferentImage = + previousSelectedImage?.image != editingImage?.image; + + if ( + clickInDifferentImage || + previousSelectedImage?.image.format.imageState == EDITING_MARKER || + isApiOperation + ) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + previousSelectedImage && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + const changeState = applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + + if (this.wasImageResized || changeState == 'FullyChanged') { + context.skipUndoSnapshot = false; + } + image.format.imageState = undefined; + } + ); + + this.cleanInfo(); + result = true; + } + + if ( + clickInDifferentImage && + editingImage && + selection?.type == 'image' && + !isApiOperation + ) { + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + isRTL = editingImage.paragraph.format.direction == 'rtl'; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.format.imageState = EDITING_MARKER; + }); + + result = true; + } + } + + return result; + }, + { + skipSelectionChangedEvent: true, + onNodeCreated: (model, node) => { + if ( + !isApiOperation && + editingImageModel && + editingImageModel == model && + editingImageModel.format.imageState == EDITING_MARKER && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node, isRTL); + } else { + this.startRotateAndResize(editor, node, isRTL); + } + } + }, + apiName: IMAGE_EDIT_FORMAT_EVENT, + }, + { + tryGetFromCache: true, + } + ); + }); + } + + private startEditing( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[] + ) { + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); + } + + if ( + (this.imageEditInfo.widthPx == 0 || this.imageEditInfo.heightPx == 0) && + !image.complete + ) { + // Image dimensions are zero and loading is incomplete, wait for image to load. + image.onload = () => { + this.updateImageDimensionsIfZero(image); + this.startEditingInternal(editor, image, apiOperation); + image.onload = null; + image.onerror = null; + }; + image.onerror = () => { + image.onload = null; + image.onerror = null; + }; + } else { + this.updateImageDimensionsIfZero(image); + this.startEditingInternal(editor, image, apiOperation); + } + } + + private updateImageDimensionsIfZero(image: HTMLImageElement) { + if (this.imageEditInfo?.widthPx === 0 || this.imageEditInfo?.heightPx === 0) { + this.imageEditInfo.widthPx = image.clientWidth; + this.imageEditInfo.heightPx = image.clientHeight; + } + } + + private startEditingInternal( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[] + ) { + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); + } + + this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); + this.lastSrc = image.getAttribute('src'); + + const { + resizers, + rotators, + wrapper, + shadowSpan, + imageClone, + croppers, + } = createImageWrapper( + editor, + image, + this.options, + this.imageEditInfo, + this.imageHTMLOptions, + apiOperation + ); + this.shadowSpan = shadowSpan; + this.selectedImage = image; + this.wrapper = wrapper; + this.clonedImage = imageClone; + this.wasImageResized = checkIfImageWasResized(image); + this.resizers = resizers; + this.rotators = rotators; + this.croppers = croppers; + this.zoomScale = editor.getDOMHelper().calculateZoomScale(); + + editor.setEditorStyle(IMAGE_EDIT_CLASS, `outline-style:none!important;`, [ + `span:has(>img${getSafeIdSelector(this.selectedImage.id)})`, + ]); + + editor.setEditorStyle(IMAGE_EDIT_CLASS_CARET, `caret-color: transparent;`); + } + + public startRotateAndResize(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['resize', 'rotate']); + + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + const isMobileOrTable = !!editor.getEnvironment().isMobileOrTablet; + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.ResizeHandle, + Resizer, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers, + undefined /* croppers */, + isRTL + ); + this.wasImageResized = true; + } + }, + this.zoomScale, + isMobileOrTable + ), + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.RotateHandle, + Rotator, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + undefined /* croppers */, + isRTL, + true /* isRotating */ + ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad, + !!this.options?.disableSideResize + ); + this.updateResizeHandleDirection( + this.resizers, + this.imageEditInfo.angleRad + ); + } + }, + this.zoomScale, + isMobileOrTable + ), + ]; + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers, + undefined /* croppers */, + isRTL + ); + + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad, + !!this.options?.disableSideResize + ); + } + } + } + + private updateResizeHandleDirection(resizers: HTMLDivElement[], angleRad: number | undefined) { + const resizeHandles = filterInnerResizerHandles(resizers); + if (angleRad !== undefined) { + updateHandleCursor(resizeHandles, angleRad); + } + } + + private updateRotateHandleState( + editor: IEditor, + image: HTMLImageElement, + wrapper: HTMLSpanElement, + rotators: HTMLDivElement[], + angleRad: number | undefined, + disableSideResize: boolean + ) { + const viewport = editor.getVisibleViewport(); + const smallImage = isASmallImage(image.width, image.height); + if (viewport && rotators && rotators.length > 0) { + const rotator = rotators[0]; + const rotatorHandle = rotator.firstElementChild; + if ( + isNodeOfType(rotatorHandle, 'ELEMENT_NODE') && + isElementOfType(rotatorHandle, 'div') + ) { + updateRotateHandle( + viewport, + angleRad ?? 0, + wrapper, + rotator, + rotatorHandle, + smallImage, + disableSideResize + ); + } + } + } + + public isOperationAllowed(operation: ImageEditOperation): boolean { + return ( + operation === 'resize' || + operation === 'rotate' || + operation === 'flip' || + operation === 'crop' + ); + } + + public canRegenerateImage(image: HTMLImageElement): boolean { + return canRegenerateImage(image); + } + + private startCropMode(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['crop']); + if (this.imageEditInfo && this.selectedImage && this.wrapper && this.clonedImage) { + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.CropHandle, + Cropper, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + this.croppers, + isRTL + ); + this.isCropMode = true; + } + }, + this.zoomScale, + !!editor.getEnvironment().isMobileOrTablet + ), + ]; + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + this.croppers, + isRTL + ); + } + } + } + + public cropImage() { + if (!this.editor) { + return; + } + if (!this.editor.getEnvironment().isSafari) { + this.editor.focus(); // Safari will keep the selection when click crop, then the focus() call should not be called + } + const selection = this.editor.getDOMSelection(); + if (selection?.type == 'image') { + this.applyFormatWithContentModel( + this.editor, + true /* isCropMode */, + false /* shouldSelectImage */ + ); + } + } + + private editImage( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[], + operation: (imageEditInfo: ImageMetadataFormat) => void + ) { + this.startEditing(editor, image, apiOperation); + if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { + return; + } + + operation(this.imageEditInfo); + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper + ); + + this.applyFormatWithContentModel(editor, false /* isCrop */, true /* isApiOperation */); + } + + /** + * Exported for testing purpose only + */ + public cleanInfo() { + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); + this.selectedImage = null; + this.shadowSpan = null; + this.wrapper = null; + this.imageEditInfo = null; + this.imageHTMLOptions = null; + this.dndHelpers.forEach(helper => helper.dispose()); + this.dndHelpers = []; + this.clonedImage = null; + this.lastSrc = null; + this.wasImageResized = false; + this.isCropMode = false; + this.resizers = []; + this.rotators = []; + this.croppers = []; + } + + private removeImageWrapper() { + let image: HTMLImageElement | null = null; + if (this.shadowSpan && this.shadowSpan.parentElement) { + if ( + this.shadowSpan.firstElementChild && + isNodeOfType(this.shadowSpan.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(this.shadowSpan.firstElementChild, 'img') + ) { + image = this.shadowSpan.firstElementChild; + } + unwrap(this.shadowSpan); + this.shadowSpan = null; + this.wrapper = null; + } + + return image; + } + + public flipImage(direction: 'horizontal' | 'vertical') { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, ['flip'], imageEditInfo => { + const angleRad = imageEditInfo.angleRad || 0; + const isInVerticalPosition = + (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || + (angleRad <= -Math.PI / 2 && angleRad > (-3 * Math.PI) / 4); + if (isInVerticalPosition) { + if (direction === 'horizontal') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } else { + if (direction === 'vertical') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } + }); + } + } + + public rotateImage(angleRad: number) { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, [], imageEditInfo => { + imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; + }); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts new file mode 100644 index 000000000000..0a1f96b65f44 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts @@ -0,0 +1,871 @@ +import { applyChange } from './utils/applyChange'; +import { canRegenerateImage } from './utils/canRegenerateImage'; +import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; +import { createImageWrapper } from './utils/createImageWrapper'; +import { Cropper } from './Cropper/cropperContext'; +import { EDITING_MARKER, findEditingImage } from './utils/findEditingImage'; +import { filterInnerResizerHandles } from './utils/filterInnerResizerHandles'; +import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; +import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; +import { getSelectedImage } from './utils/getSelectedImage'; +import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; +import { ImageEditElementClass } from './types/ImageEditElementClass'; +import { normalizeImageSelection } from './utils/normalizeImageSelection'; +import { Resizer } from './Resizer/resizerContext'; +import { Rotator } from './Rotator/rotatorContext'; +import { updateHandleCursor } from './utils/updateHandleCursor'; +import { updateRotateHandle } from './Rotator/updateRotateHandle'; +import { updateWrapper } from './utils/updateWrapper'; +import { + ChangeSource, + getSafeIdSelector, + getSelectedParagraphs, + isElementOfType, + isNodeOfType, + mutateBlock, + mutateSegment, + setImageState, + unwrap, +} from 'roosterjs-content-model-dom'; +import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; +import type { DragAndDropContext } from './types/DragAndDropContext'; +import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; +import type { ImageEditOptions } from './types/ImageEditOptions'; +import type { + ContentChangedEvent, + ContentModelImage, + EditorPlugin, + IEditor, + ImageEditOperation, + ImageEditor, + ImageMetadataFormat, + KeyDownEvent, + MouseDownEvent, + MouseUpEvent, + PluginEvent, +} from 'roosterjs-content-model-types'; + +const DefaultOptions: Partial = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: ['resize', 'rotate'], +}; + +const MouseRightButton = 2; +const DRAG_ID = '_dragging'; +const IMAGE_EDIT_CLASS = 'imageEdit'; +const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; +const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; + +/** + * ImageEdit plugin handles the following image editing features: + * - Resize image + * - Crop image + * - Rotate image + * - Flip image + */ +export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { + protected editor: IEditor | null = null; + private shadowSpan: HTMLSpanElement | null = null; + private selectedImage: HTMLImageElement | null = null; + protected wrapper: HTMLSpanElement | null = null; + protected imageEditInfo: ImageMetadataFormat | null = null; + private imageHTMLOptions: ImageHtmlOptions | null = null; + private dndHelpers: DragAndDropHelper[] = []; + private clonedImage: HTMLImageElement | null = null; + private lastSrc: string | null = null; + private wasImageResized: boolean = false; + private isCropMode: boolean = false; + private resizers: HTMLDivElement[] = []; + private rotators: HTMLDivElement[] = []; + private croppers: HTMLDivElement[] = []; + private zoomScale: number = 1; + private disposer: (() => void) | null = null; + protected isEditing = false; + protected options: ImageEditOptions; + + constructor(options?: ImageEditOptions) { + this.options = { ...DefaultOptions, ...options }; + } + + /** + * Get name of this plugin + */ + getName() { + return 'ImageEdit'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = editor.attachDomEvent({ + blur: { + beforeDispatch: () => { + if (this.isEditing && this.editor && !this.editor.isDisposed()) { + this.applyFormatWithContentModel( + this.editor, + this.isCropMode, + true /* shouldSelectImage */ + ); + } + }, + }, + dragstart: { + beforeDispatch: ev => { + if (this.editor) { + const target = ev.target as Node; + if (this.isImageSelection(target)) { + target.id = target.id + DRAG_ID; + } + } + }, + }, + dragend: { + beforeDispatch: ev => { + if (this.editor) { + const target = ev.target as Node; + if (this.isImageSelection(target) && target.id.includes(DRAG_ID)) { + target.id = target.id.replace(DRAG_ID, '').trim(); + } + } + }, + }, + }); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + this.isEditing = false; + this.cleanInfo(); + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + switch (event.eventType) { + case 'mouseDown': + this.mouseDownHandler(this.editor, event); + break; + case 'mouseUp': + this.mouseUpHandler(this.editor, event); + break; + case 'keyDown': + this.keyDownHandler(this.editor, event); + break; + case 'contentChanged': + this.contentChangedHandler(this.editor, event); + break; + case 'extractContentWithDom': + this.removeImageEditing(event.clonedRoot); + break; + case 'beforeLogicalRootChange': + this.handleBeforeLogicalRootChange(); + break; + } + } + + private handleBeforeLogicalRootChange() { + if (this.isEditing && this.editor && !this.editor.isDisposed()) { + this.applyFormatWithContentModel( + this.editor, + this.isCropMode, + false /* shouldSelectImage */ + ); + this.removeImageWrapper(); + this.cleanInfo(); + } + } + + private removeImageEditing(clonedRoot: HTMLElement) { + const images = clonedRoot.querySelectorAll('img'); + images.forEach(image => { + if (image.dataset.editingInfo) { + delete image.dataset.editingInfo; + } + }); + } + + private isImageSelection(target: Node): target is HTMLElement { + return ( + isNodeOfType(target, 'ELEMENT_NODE') && + (isElementOfType(target, 'img') || + !!( + isElementOfType(target, 'span') && + target.firstElementChild && + isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(target.firstElementChild, 'img') + )) + ); + } + + private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { + const selection = editor.getDOMSelection(); + if ((selection && selection.type == 'image') || this.isEditing) { + const shouldSelectImage = + this.isImageSelection(event.rawEvent.target as Node) && + event.rawEvent.button === MouseRightButton; + this.applyFormatWithContentModel(editor, this.isCropMode, shouldSelectImage); + } + } + + private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { + if ( + this.isEditing && + this.isImageSelection(event.rawEvent.target as Node) && + event.rawEvent.button !== MouseRightButton && + !this.isCropMode + ) { + this.applyFormatWithContentModel(editor, this.isCropMode); + } + } + + private onDropHandler(editor: IEditor) { + const selection = editor.getDOMSelection(); + if (selection?.type == 'image') { + editor.formatContentModel(model => { + const imageDragged = findEditingImage(model, selection.image.id); + const imageDropped = findEditingImage( + model, + selection.image.id.replace(DRAG_ID, '').trim() + ); + if (imageDragged && imageDropped) { + const draggedIndex = imageDragged.paragraph.segments.indexOf( + imageDragged.image + ); + mutateBlock(imageDragged.paragraph).segments.splice(draggedIndex, 1); + const segment = imageDropped.image; + const paragraph = imageDropped.paragraph; + mutateSegment(paragraph, segment, image => { + image.isSelected = true; + image.isSelectedAsImageSelection = true; + }); + + return true; + } + return false; + }); + } + } + + private keyDownHandler(editor: IEditor, event: KeyDownEvent) { + if (this.isEditing) { + if ( + event.rawEvent.key === 'Escape' || + event.rawEvent.key === 'Delete' || + event.rawEvent.key === 'Backspace' + ) { + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } + this.cleanInfo(); + } else { + if (event.rawEvent.key == 'Enter' && this.isCropMode) { + event.rawEvent.preventDefault(); + } + this.applyFormatWithContentModel( + editor, + this.isCropMode, + true /** should selectImage */, + false /* isApiOperation */ + ); + } + } + } + + private setContentHandler(editor: IEditor) { + const selection = editor.getDOMSelection(); + if (selection?.type == 'image') { + this.cleanInfo(); + setImageState(selection.image, ''); + this.isEditing = false; + this.isCropMode = false; + } + } + + private formatEventHandler(event: ContentChangedEvent) { + if (this.isEditing && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { + this.cleanInfo(); + this.isEditing = false; + this.isCropMode = false; + } + } + + private contentChangedHandler(editor: IEditor, event: ContentChangedEvent) { + switch (event.source) { + case ChangeSource.SetContent: + this.setContentHandler(editor); + break; + case ChangeSource.Format: + this.formatEventHandler(event); + break; + case ChangeSource.Drop: + this.onDropHandler(editor); + break; + } + } + + /** + * EXPOSED FOR TESTING PURPOSE ONLY + */ + protected applyFormatWithContentModel( + editor: IEditor, + isCropMode: boolean, + shouldSelectImage?: boolean, + isApiOperation?: boolean + ) { + let editingImageModel: ContentModelImage | undefined; + const selection = editor.getDOMSelection(); + let isRTL: boolean = false; + + editor.formatContentModel( + (model, context) => { + const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model); + let result = false; + + // Skip adding undo snapshot for now. If we detect any changes later, we will reset it + context.skipUndoSnapshot = 'SkipAll'; + + if ( + shouldSelectImage || + previousSelectedImage?.image != editingImage?.image || + previousSelectedImage?.image.format.imageState == EDITING_MARKER || + isApiOperation + ) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + (this.isEditing || isApiOperation) && + previousSelectedImage && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + const changeState = applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + + if (this.wasImageResized || changeState == 'FullyChanged') { + context.skipUndoSnapshot = false; + } + const isSameImage = + previousSelectedImage?.image === editingImage?.image; + image.isSelected = isSameImage || shouldSelectImage; + image.isSelectedAsImageSelection = isSameImage || shouldSelectImage; + image.format.imageState = undefined; + + if (selection?.type == 'range' && !selection.range.collapsed) { + const selectedParagraphs = getSelectedParagraphs(model, true); + const isImageInRange = selectedParagraphs.some(paragraph => + paragraph.segments.includes(image) + ); + if (isImageInRange) { + image.isSelected = true; + } + } + } + ); + + if (shouldSelectImage) { + normalizeImageSelection(previousSelectedImage); + } + + this.cleanInfo(); + result = true; + } + + this.isEditing = false; + this.isCropMode = false; + + if ( + editingImage && + selection?.type == 'image' && + !shouldSelectImage && + !isApiOperation + ) { + this.isEditing = true; + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + isRTL = editingImage.paragraph.format.direction == 'rtl'; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.format.imageState = 'isEditing'; + }); + + result = true; + } + } + + return result; + }, + { + onNodeCreated: (model, node) => { + if ( + !isApiOperation && + editingImageModel && + editingImageModel == model && + editingImageModel.format.imageState == EDITING_MARKER && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node, isRTL); + } else { + this.startRotateAndResize(editor, node, isRTL); + } + } + }, + apiName: IMAGE_EDIT_FORMAT_EVENT, + }, + { + tryGetFromCache: true, + } + ); + } + + private startEditing( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[] + ) { + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); + } + + if ( + (this.imageEditInfo.widthPx == 0 || this.imageEditInfo.heightPx == 0) && + !image.complete + ) { + // Image dimensions are zero and loading is incomplete, wait for image to load. + image.onload = () => { + this.updateImageDimensionsIfZero(image); + this.startEditingInternal(editor, image, apiOperation); + image.onload = null; + image.onerror = null; + }; + image.onerror = () => { + image.onload = null; + image.onerror = null; + }; + } else { + this.updateImageDimensionsIfZero(image); + this.startEditingInternal(editor, image, apiOperation); + } + } + + private updateImageDimensionsIfZero(image: HTMLImageElement) { + if (this.imageEditInfo?.widthPx === 0 || this.imageEditInfo?.heightPx === 0) { + this.imageEditInfo.widthPx = image.clientWidth; + this.imageEditInfo.heightPx = image.clientHeight; + } + } + + private startEditingInternal( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[] + ) { + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); + } + + this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); + this.lastSrc = image.getAttribute('src'); + + const { + resizers, + rotators, + wrapper, + shadowSpan, + imageClone, + croppers, + } = createImageWrapper( + editor, + image, + this.options, + this.imageEditInfo, + this.imageHTMLOptions, + apiOperation + ); + this.shadowSpan = shadowSpan; + this.selectedImage = image; + this.wrapper = wrapper; + this.clonedImage = imageClone; + this.wasImageResized = checkIfImageWasResized(image); + this.resizers = resizers; + this.rotators = rotators; + this.croppers = croppers; + this.zoomScale = editor.getDOMHelper().calculateZoomScale(); + + editor.setEditorStyle(IMAGE_EDIT_CLASS, `outline-style:none!important;`, [ + `span:has(>img${getSafeIdSelector(this.selectedImage.id)})`, + ]); + + editor.setEditorStyle(IMAGE_EDIT_CLASS_CARET, `caret-color: transparent;`); + } + + public startRotateAndResize(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['resize', 'rotate']); + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + const isMobileOrTable = !!editor.getEnvironment().isMobileOrTablet; + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.ResizeHandle, + Resizer, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers, + undefined /* croppers */, + isRTL + ); + this.wasImageResized = true; + } + }, + this.zoomScale, + isMobileOrTable + ), + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.RotateHandle, + Rotator, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + undefined /* croppers */, + isRTL, + true /* isRotating */ + ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad, + !!this.options?.disableSideResize + ); + this.updateResizeHandleDirection( + this.resizers, + this.imageEditInfo.angleRad + ); + } + }, + this.zoomScale, + isMobileOrTable + ), + ]; + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers, + undefined /* croppers */, + isRTL + ); + + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad, + !!this.options?.disableSideResize + ); + } + } + } + + private updateResizeHandleDirection(resizers: HTMLDivElement[], angleRad: number | undefined) { + const resizeHandles = filterInnerResizerHandles(resizers); + if (angleRad !== undefined) { + updateHandleCursor(resizeHandles, angleRad); + } + } + + private updateRotateHandleState( + editor: IEditor, + image: HTMLImageElement, + wrapper: HTMLSpanElement, + rotators: HTMLDivElement[], + angleRad: number | undefined, + disableSideResize: boolean + ) { + const viewport = editor.getVisibleViewport(); + const smallImage = isASmallImage(image.width, image.height); + if (viewport && rotators && rotators.length > 0) { + const rotator = rotators[0]; + const rotatorHandle = rotator.firstElementChild; + if ( + isNodeOfType(rotatorHandle, 'ELEMENT_NODE') && + isElementOfType(rotatorHandle, 'div') + ) { + updateRotateHandle( + viewport, + angleRad ?? 0, + wrapper, + rotator, + rotatorHandle, + smallImage, + disableSideResize + ); + } + } + } + + public isOperationAllowed(operation: ImageEditOperation): boolean { + return ( + operation === 'resize' || + operation === 'rotate' || + operation === 'flip' || + operation === 'crop' + ); + } + + public canRegenerateImage(image: HTMLImageElement): boolean { + return canRegenerateImage(image); + } + + private startCropMode(editor: IEditor, image: HTMLImageElement, isRTL: boolean) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['crop']); + if (this.imageEditInfo && this.selectedImage && this.wrapper && this.clonedImage) { + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.CropHandle, + Cropper, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + this.croppers, + isRTL + ); + this.isCropMode = true; + } + }, + this.zoomScale, + !!editor.getEnvironment().isMobileOrTablet + ), + ]; + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined /* resizers */, + this.croppers, + isRTL + ); + } + } + } + + public cropImage() { + if (!this.editor) { + return; + } + if (!this.editor.getEnvironment().isSafari) { + this.editor.focus(); // Safari will keep the selection when click crop, then the focus() call should not be called + } + const selection = this.editor.getDOMSelection(); + if (selection?.type == 'image') { + this.applyFormatWithContentModel( + this.editor, + true /* isCropMode */, + false /* shouldSelectImage */ + ); + } + } + + private editImage( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation[], + operation: (imageEditInfo: ImageMetadataFormat) => void + ) { + this.startEditing(editor, image, apiOperation); + if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { + return; + } + + operation(this.imageEditInfo); + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper + ); + + this.applyFormatWithContentModel( + editor, + false /* isCrop */, + true /* shouldSelect*/, + true /* isApiOperation */ + ); + } + + /** + * Exported for testing purpose only + */ + public cleanInfo() { + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); + this.selectedImage = null; + this.shadowSpan = null; + this.wrapper = null; + this.imageEditInfo = null; + this.imageHTMLOptions = null; + this.dndHelpers.forEach(helper => helper.dispose()); + this.dndHelpers = []; + this.clonedImage = null; + this.lastSrc = null; + this.wasImageResized = false; + this.isCropMode = false; + this.resizers = []; + this.rotators = []; + this.croppers = []; + } + + private removeImageWrapper() { + let image: HTMLImageElement | null = null; + if (this.shadowSpan && this.shadowSpan.parentElement) { + if ( + this.shadowSpan.firstElementChild && + isNodeOfType(this.shadowSpan.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(this.shadowSpan.firstElementChild, 'img') + ) { + image = this.shadowSpan.firstElementChild; + } + unwrap(this.shadowSpan); + this.shadowSpan = null; + this.wrapper = null; + } + + return image; + } + + public flipImage(direction: 'horizontal' | 'vertical') { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, ['flip'], imageEditInfo => { + const angleRad = imageEditInfo.angleRad || 0; + const isInVerticalPostion = + (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || + (angleRad <= -Math.PI / 2 && angleRad > (-3 * Math.PI) / 4); + if (isInVerticalPostion) { + if (direction === 'horizontal') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } else { + if (direction === 'vertical') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } + }); + } + } + + public rotateImage(angleRad: number) { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, [], imageEditInfo => { + imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; + }); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index c20067e4f8c4..e259ab45affb 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -3,8 +3,8 @@ import * as findImage from '../../lib/imageEdit/utils/findEditingImage'; import * as getSelectedImage from '../../lib/imageEdit/utils/getSelectedImage'; import * as normalizeImageSelection from '../../lib/imageEdit/utils/normalizeImageSelection'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; -import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; import { initEditor } from '../TestHelper'; +import { LegacyImageEditPlugin } from '../../lib/imageEdit/LegacyImageEditPlugin'; import { ChangeSource, createImage, @@ -23,7 +23,7 @@ import { ImageSelection, } from 'roosterjs-content-model-types'; -describe('ImageEditPlugin', () => { +describe('LegacyImageEditPlugin', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -164,7 +164,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -223,7 +223,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); const cleanInfoSpy = spyOn(plugin, 'cleanInfo'); getDOMSelectionSpy.and.returnValue({ @@ -257,7 +257,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -279,7 +279,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -300,7 +300,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -333,7 +333,7 @@ describe('ImageEditPlugin', () => { }); it('cropImage', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const mockedImage = { getAttribute: getAttributeSpy, }; @@ -348,7 +348,7 @@ describe('ImageEditPlugin', () => { }); it('flip', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const image = new Image(); image.src = 'test'; getDOMSelectionSpy.and.returnValue({ @@ -365,7 +365,7 @@ describe('ImageEditPlugin', () => { }); it('rotate', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const image = new Image(); image.src = 'test'; getDOMSelectionSpy.and.returnValue({ @@ -419,7 +419,7 @@ describe('ImageEditPlugin', () => { textColor: '#000000', }, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const editor = initEditor('image_edit', [plugin], modelWithUnsupportedId); spyOn(editor, 'setEditorStyle').and.callThrough(); (plugin as any).imageEditInfo = { @@ -450,7 +450,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -483,7 +483,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -504,7 +504,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -540,7 +540,7 @@ describe('ImageEditPlugin', () => { const mockedImage = { getAttribute: getAttributeSpy, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); getDOMSelectionSpy.and.returnValue({ type: 'image', @@ -600,7 +600,7 @@ describe('ImageEditPlugin', () => { id: 'image_0', getAttribute: getAttributeSpy, } as any; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); const draggedImage = mockedImage as HTMLImageElement; getDOMSelectionSpy.and.returnValue({ @@ -637,7 +637,7 @@ describe('ImageEditPlugin', () => { }); it('dragImage only', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); const draggedImage = document.createElement('img'); draggedImage.id = 'image_0'; @@ -650,7 +650,7 @@ describe('ImageEditPlugin', () => { }); it('dragImage at same place', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); const draggedImage = document.createElement('img'); draggedImage.id = 'image_0'; @@ -701,7 +701,7 @@ describe('ImageEditPlugin', () => { textColor: '#000000', }, }; - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); spyOn(editor, 'setEditorStyle').and.callThrough(); @@ -735,7 +735,7 @@ describe('ImageEditPlugin', () => { }); it('extractContentWithDom', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); const clonedRoot = document.createElement('div'); const image = document.createElement('img'); @@ -783,7 +783,7 @@ describe('ImageEditPlugin', () => { }); it('contentChanged - should remove isEditing', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); plugin.initialize(editor); const image = document.createElement('img'); @@ -808,7 +808,7 @@ describe('ImageEditPlugin', () => { }); it('should call applyFormatWithContentModel and clean up on beforeLogicalRootChange when editing', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); // Simulate editing state plugin['isEditing'] = true; @@ -833,7 +833,7 @@ describe('ImageEditPlugin', () => { }); it('should do nothing on beforeLogicalRootChange if not editing', () => { - const plugin = new ImageEditPlugin(); + const plugin = new LegacyImageEditPlugin(); plugin.initialize(editor); plugin['isEditing'] = false; plugin['editor'] = editor; @@ -850,7 +850,7 @@ describe('ImageEditPlugin', () => { }); }); -class TestPlugin extends ImageEditPlugin { +class TestPlugin extends LegacyImageEditPlugin { public setIsEditing(isEditing: boolean) { this.isEditing = isEditing; } @@ -888,7 +888,7 @@ interface TestOptions { shouldCleanInfo?: boolean; } -describe('ImageEditPlugin - applyFormatWithContentModel', () => { +describe('LegacyImageEditPlugin - applyFormatWithContentModel', () => { function runTest( model: ContentModelDocument, expectedModel: ContentModelDocument, diff --git a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts index 1f2637808c02..3a02e5e8ea29 100644 --- a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -42,4 +42,9 @@ export type ExperimentalFeature = * Get cloned root element from an independent HTML document instead of current document. * So any operation to the cloned root won't trigger network request for resources like images */ - | 'CloneIndependentRoot'; + | 'CloneIndependentRoot' + + /** + * Use selection change event instead of mouse up to insert the image edit handles + */ + | 'ImageEditV2'; From 9f9ec23273523d8ca3328ae71080753cf881fe23 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Nov 2025 12:19:10 -0300 Subject: [PATCH 5/8] fix test --- .../formatContentModelTest.ts | 189 ++++++++++++++++-- .../setContentModel/setContentModelTest.ts | 6 +- 2 files changed, 171 insertions(+), 24 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 89df3775ba2b..3f7c4acde1bc 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -112,7 +112,16 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -150,7 +159,16 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -189,7 +207,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -228,7 +253,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -267,7 +299,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(0); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -306,7 +345,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(0); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -341,7 +387,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -384,7 +437,14 @@ describe('formatContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -423,7 +483,9 @@ describe('formatContentModel', () => { core, mockedModel, undefined, - onNodeCreated + onNodeCreated, + undefined, + undefined ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -476,7 +538,14 @@ describe('formatContentModel', () => { ); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -539,7 +608,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -581,7 +657,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalled(); expect(createContentModel).toHaveBeenCalledWith(core, undefined, range); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -615,7 +698,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalled(); expect(createContentModel).toHaveBeenCalledWith(core, options, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -653,7 +743,14 @@ describe('formatContentModel', () => { expect(getClientWidth).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -686,7 +783,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -782,6 +886,7 @@ describe('formatContentModel', () => { { ignoreSelection: true, }, + undefined, undefined ); expect(triggerEvent).toHaveBeenCalled(); @@ -1114,7 +1219,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ snapshotsManager: { hasNewContent: true, @@ -1153,7 +1265,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ isNested: false, snapshotsManager: {}, @@ -1189,7 +1308,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ isNested: false, snapshotsManager: {}, @@ -1233,7 +1359,14 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ isNested: false, snapshotsManager: {}, @@ -1268,7 +1401,14 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(0); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ isNested: true, snapshotsManager: {}, @@ -1301,7 +1441,14 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(0); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(setContentModel).toHaveBeenCalledWith( + core, + mockedModel, + undefined, + undefined, + undefined, + undefined + ); expect(core.undo).toEqual({ isNested: true, snapshotsManager: {}, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 14b8dc1f2b9a..3f9a0e330dbd 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -84,7 +84,7 @@ describe('setContentModel', () => { mockedModel, mockedContext ); - expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange, undefined); expect(core.cache.cachedSelection).toBe(mockedRange); expect(flushMutationsSpy).toHaveBeenCalledWith(true); }); @@ -108,7 +108,7 @@ describe('setContentModel', () => { mockedModel, mockedContext ); - expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange, undefined); }); it('with default option, no shadow edit, with additional option', () => { @@ -138,7 +138,7 @@ describe('setContentModel', () => { mockedModel, mockedContext ); - expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange, undefined); expect(mockedOnFixUpModel).toHaveBeenCalledWith(mockedModel); expect(mockedOnFixUpModel).toHaveBeenCalledBefore(contentModelToDomSpy); }); From 3ea4bd6ae54cb0e99c6969536a4b0dc0b73cf6c8 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 23 Dec 2025 16:26:02 -0300 Subject: [PATCH 6/8] fixes --- .../coreApi/formatContentModel/formatContentModelTest.ts | 5 +---- .../lib/imageEdit/ImageEditPluginV2.ts | 1 + .../lib/imageEdit/LegacyImageEditPlugin.ts | 6 ++++-- .../lib/imageEdit/utils/createImageWrapper.ts | 3 +++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 3f7c4acde1bc..6e90ca5424dd 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -118,8 +118,6 @@ describe('formatContentModel', () => { undefined, undefined, undefined, - undefined, - undefined, undefined ); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -165,8 +163,6 @@ describe('formatContentModel', () => { undefined, undefined, undefined, - undefined, - undefined, undefined ); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -887,6 +883,7 @@ describe('formatContentModel', () => { ignoreSelection: true, }, undefined, + undefined, undefined ); expect(triggerEvent).toHaveBeenCalled(); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts index 93749a9112ee..12bb76585903 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts @@ -65,6 +65,7 @@ const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; /** + * @internal * ImageEdit plugin handles the following image editing features: * - Resize image * - Crop image diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts index 0a1f96b65f44..810c52a1e67f 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts @@ -62,6 +62,7 @@ const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; /** + * @internal * ImageEdit plugin handles the following image editing features: * - Resize image * - Crop image @@ -299,9 +300,10 @@ export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { private setContentHandler(editor: IEditor) { const selection = editor.getDOMSelection(); - if (selection?.type == 'image') { + const image = selection?.type == 'image' ? selection.image : this.selectedImage; + if (image) { this.cleanInfo(); - setImageState(selection.image, ''); + setImageState(image, ''); this.isEditing = false; this.isCropMode = false; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts index 1942fec8e7d1..c955b0d72dea 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -11,6 +11,9 @@ import type { import type { ImageEditOptions } from '../types/ImageEditOptions'; import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; +/** + * @internal + */ export const IMAGE_EDIT_SHADOW_ROOT = 'ImageEditShadowRoot'; /** From eedfc20fcb66a518d7ae693b707c88a3886da343 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 23 Dec 2025 16:28:15 -0300 Subject: [PATCH 7/8] fix conflicts --- .../demoButtons/createImageEditButtons.ts | 2 +- demo/scripts/utils/imageEditOperator.ts | 23 ------------------- .../parameter/FormatContentModelOptions.ts | 3 +++ 3 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 demo/scripts/utils/imageEditOperator.ts diff --git a/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts index 7634453903af..ded34f732c39 100644 --- a/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts +++ b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts @@ -62,7 +62,7 @@ function createImageFlipButton(handler: ImageEditor): RibbonButton<'buttonNameFl items: flipDirections, }, isDisabled: formatState => !formatState.canAddImageAltText, - onClick: (editor, flipDirection) => { + onClick: (_editor, flipDirection) => { handler.flipImage(flipDirection as 'horizontal' | 'vertical'); }, }; diff --git a/demo/scripts/utils/imageEditOperator.ts b/demo/scripts/utils/imageEditOperator.ts deleted file mode 100644 index 41283705e7ad..000000000000 --- a/demo/scripts/utils/imageEditOperator.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ImageEditor } from 'roosterjs-content-model-types'; - -const imageEdit: { [id: string]: ImageEditor } = {}; - -export function addImageEditOperator(id: string, imageEditPlugin: ImageEditor): void { - imageEdit[id] = imageEditPlugin; -} - -export function removeImageEditOperator(id: string): void { - delete imageEdit[id]; -} - -export function operateImageEdit( - id: string | null, - callback: (imageEditPlugin: ImageEditor) => void -): void { - if (!!id) { - const imageEditPlugin = imageEdit[id]; - if (imageEditPlugin) { - callback(imageEditPlugin); - } - } -} diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index 3911816cee47..39995d41b760 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -44,6 +44,9 @@ export interface FormatContentModelOptions { */ scrollCaretIntoView?: boolean; + /** + * When true, selection change event will not be triggered bt formatContentModel function + */ skipSelectionChangedEvent?: boolean; } From 70eb07c9cfe806201b9aa691ffbbefbe37f8fc53 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 30 Dec 2025 14:52:13 -0300 Subject: [PATCH 8/8] image edit --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 5 +- .../lib/imageEdit/ImageEditPlugin.ts | 26 ++ .../lib/imageEdit/ImageEditPluginV2.ts | 303 +++++++----------- .../lib/imageEdit/LegacyImageEditPlugin.ts | 6 +- 4 files changed, 140 insertions(+), 200 deletions(-) diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 6a8bb3ee20cc..d75a9a3fb0fb 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -236,6 +236,9 @@ export class MainPane extends React.Component<{}, MainPaneState> { resetEditorPlugin(pluginState: OptionState) { this.updateContentPlugin.update(); + this.imageEditPlugin = new ImageEditPlugin({ + disableSideResize: pluginState.disableSideResize, + }); this.setState({ initState: pluginState, }); @@ -552,7 +555,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.tableEdit && new TableEditPlugin(), pluginList.watermark && new WatermarkPlugin(watermarkText), pluginList.markdown && new MarkdownPlugin(markdownOptions), - imageEditPlugin, + pluginList.imageEditPlugin && imageEditPlugin, pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 9f48a407b645..14fc86303cf3 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -41,6 +41,18 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return 'ImageEdit'; } + /** + * @deprecated + * When ImageEditV2 is enabled, this will always be false + */ + protected get isEditing() { + return this.imageEditPlugin?.isEditing; + } + + protected get editor() { + return this.imageEditPlugin?.editor; + } + /** * The first method that editor will call to a plugin when editor is initializing. * It will pass in the editor instance, plugin should take this chance to save the @@ -93,4 +105,18 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { isOperationAllowed(operation: ImageEditOperation): boolean { return !!this.imageEditPlugin?.isOperationAllowed(operation); } + + protected applyFormatWithContentModel( + editor: IEditor, + isCropMode: boolean, + shouldSelectImage?: boolean, + isApiOperation?: boolean + ) { + this.imageEditPlugin?.applyFormatWithContentModel( + editor, + isCropMode, + shouldSelectImage, + isApiOperation + ); + } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts index 12bb76585903..07fd3fa551ea 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts @@ -15,15 +15,11 @@ import { Rotator } from './Rotator/rotatorContext'; import { updateHandleCursor } from './utils/updateHandleCursor'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; -//import { normalizeImageSelection } from './utils/normalizeImageSelection'; import { ChangeSource, getSafeIdSelector, - //getSelectedParagraphs, isElementOfType, - isEntityElement, isNodeOfType, - mutateBlock, mutateSegment, setImageState, unwrap, @@ -41,9 +37,6 @@ import type { ImageEditor, ImageMetadataFormat, KeyDownEvent, - MouseDownEvent, - // MouseDownEvent, - // MouseUpEvent, PluginEvent, SelectionChangedEvent, } from 'roosterjs-content-model-types'; @@ -58,8 +51,6 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; -//const MouseRightButton = 2; -const DRAG_ID = '_dragging'; const IMAGE_EDIT_CLASS = 'imageEdit'; const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; @@ -73,7 +64,7 @@ const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; * - Flip image */ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { - protected editor: IEditor | null = null; + public editor: IEditor | null = null; private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; protected wrapper: HTMLSpanElement | null = null; @@ -90,6 +81,10 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { private zoomScale: number = 1; private disposer: (() => void) | null = null; protected options: ImageEditOptions; + /** + * @deprecated it will always be false + **/ + public isEditing: boolean = false; constructor(options?: ImageEditOptions) { this.options = { ...DefaultOptions, ...options }; @@ -113,28 +108,18 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { this.disposer = editor.attachDomEvent({ dragstart: { beforeDispatch: ev => { - if (this.editor) { - const target = ev.target as Node; - if (this.isImageSelection(target)) { - target.id = target.id + DRAG_ID; - } - } - }, - }, - dragend: { - beforeDispatch: ev => { - if (this.editor) { - const target = ev.target as Node; - if (this.isImageSelection(target) && target.id.includes(DRAG_ID)) { - target.id = target.id.replace(DRAG_ID, '').trim(); - } - } - }, - }, - blur: { - beforeDispatch: ev => { - if (this.editor && this.selectedImage) { - this.applyFormatWithContentModel(this.editor, this.isCropMode); + const dragEvent = ev as DragEvent; + const target = ev.target as HTMLElement; + + if ( + target?.id === IMAGE_EDIT_SHADOW_ROOT && + this.shadowSpan === target && + this.selectedImage && + dragEvent.dataTransfer + ) { + dragEvent.dataTransfer.effectAllowed = 'move'; + dragEvent.dataTransfer.setDragImage(this.selectedImage, 0, 0); + dragEvent.dataTransfer.setData('text/html', this.selectedImage.outerHTML); } }, }, @@ -172,9 +157,6 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { case 'keyDown': this.keyDownHandler(this.editor, event); break; - case 'mouseDown': - this.mouseDownHandler(this.editor, event); - break; case 'contentChanged': this.contentChangedHandler(this.editor, event); break; @@ -202,69 +184,12 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { }); } - private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - const selection = editor.getDOMSelection(); - const target = event.rawEvent.target as Element; - const isEditingImage = - target.firstElementChild?.id == IMAGE_EDIT_SHADOW_ROOT && target.childElementCount == 1; - if (selection?.type == 'image' && (isEditingImage || isEntityElement(target))) { - const range = editor.getDocument().createRange(); - const image = this.removeImageWrapper(); - if (image) { - range.selectNode(image); - range.collapse(); - editor.setDOMSelection({ - type: 'range', - range, - isReverted: false, - }); - } - } - } - - private isImageSelection(target: Node): target is HTMLElement { - return ( - isNodeOfType(target, 'ELEMENT_NODE') && - (isElementOfType(target, 'img') || - !!( - isElementOfType(target, 'span') && - target.firstElementChild && - isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && - isElementOfType(target.firstElementChild, 'img') - )) - ); - } - private selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { - if ((event.newSelection && event.newSelection.type == 'image') || this.selectedImage) { + if (this.selectedImage) { this.applyFormatWithContentModel(editor, this.isCropMode); - } - } - - private onDropHandler(editor: IEditor) { - const selection = editor.getDOMSelection(); - if (selection?.type == 'image') { - editor.formatContentModel(model => { - const imageDragged = findEditingImage(model, selection.image.id); - const imageDropped = findEditingImage( - model, - selection.image.id.replace(DRAG_ID, '').trim() - ); - if (imageDragged && imageDropped) { - const draggedIndex = imageDragged.paragraph.segments.indexOf( - imageDragged.image - ); - mutateBlock(imageDragged.paragraph).segments.splice(draggedIndex, 1); - const segment = imageDropped.image; - const paragraph = imageDropped.paragraph; - mutateSegment(paragraph, segment, image => { - image.isSelected = true; - image.isSelectedAsImageSelection = true; - }); - - return true; - } - return false; + } else if (event.newSelection && event.newSelection.type == 'image') { + editor.getDocument().defaultView?.requestAnimationFrame(() => { + this.applyFormatWithContentModel(editor, this.isCropMode); }); } } @@ -301,7 +226,7 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { } } - private formatEventHandler(event: ContentChangedEvent) { + private formatEventHandler(editor: IEditor, event: ContentChangedEvent) { if (this.selectedImage && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { this.cleanInfo(); } @@ -313,10 +238,7 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { this.setContentHandler(editor); break; case ChangeSource.Format: - this.formatEventHandler(event); - break; - case ChangeSource.Drop: - this.onDropHandler(editor); + this.formatEventHandler(editor, event); break; } } @@ -324,7 +246,7 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { /** * EXPOSED FOR TESTING PURPOSE ONLY */ - protected applyFormatWithContentModel( + public applyFormatWithContentModel( editor: IEditor, isCropMode: boolean, isApiOperation?: boolean @@ -332,104 +254,97 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); let isRTL: boolean = false; - editor.getDocument().defaultView?.requestAnimationFrame(() => { - editor.formatContentModel( - (model, context) => { - const editingImage = getSelectedImage(model); - const previousSelectedImage = isApiOperation - ? editingImage - : findEditingImage(model, undefined); - let result = false; - - // Skip adding undo snapshot for now. If we detect any changes later, we will reset it - context.skipUndoSnapshot = 'SkipAll'; - - const clickInDifferentImage = - previousSelectedImage?.image != editingImage?.image; - + editor.formatContentModel( + (model, context) => { + const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model, undefined); + let result = false; + + // Skip adding undo snapshot for now. If we detect any changes later, we will reset it + context.skipUndoSnapshot = 'SkipAll'; + + const clickInDifferentImage = previousSelectedImage?.image != editingImage?.image; + + if ( + clickInDifferentImage || + previousSelectedImage?.image.format.imageState == EDITING_MARKER || + isApiOperation + ) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( - clickInDifferentImage || - previousSelectedImage?.image.format.imageState == EDITING_MARKER || - isApiOperation + previousSelectedImage && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage ) { - const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; - if ( - previousSelectedImage && - lastSrc && - selectedImage && - imageEditInfo && - clonedImage - ) { - mutateSegment( - previousSelectedImage.paragraph, - previousSelectedImage.image, - image => { - const changeState = applyChange( - editor, - selectedImage, - image, - imageEditInfo, - lastSrc, - this.wasImageResized || this.isCropMode, - clonedImage - ); - - if (this.wasImageResized || changeState == 'FullyChanged') { - context.skipUndoSnapshot = false; - } - image.format.imageState = undefined; + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + const changeState = applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + + if (this.wasImageResized || changeState == 'FullyChanged') { + context.skipUndoSnapshot = false; } - ); + image.format.imageState = undefined; + context.clearModelCache = true; + } + ); - this.cleanInfo(); - result = true; - } + this.cleanInfo(); + result = true; + } - if ( - clickInDifferentImage && - editingImage && - selection?.type == 'image' && - !isApiOperation - ) { - this.isCropMode = isCropMode; - mutateSegment(editingImage.paragraph, editingImage.image, image => { - editingImageModel = image; - isRTL = editingImage.paragraph.format.direction == 'rtl'; - this.imageEditInfo = updateImageEditInfo(image, selection.image); - image.format.imageState = EDITING_MARKER; - }); - - result = true; - } + if ( + (clickInDifferentImage || isApiOperation) && + editingImage && + selection?.type == 'image' + ) { + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + isRTL = editingImage.paragraph.format.direction == 'rtl'; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.format.imageState = EDITING_MARKER; + }); + + result = true; } + } - return result; - }, - { - skipSelectionChangedEvent: true, - onNodeCreated: (model, node) => { - if ( - !isApiOperation && - editingImageModel && - editingImageModel == model && - editingImageModel.format.imageState == EDITING_MARKER && - isNodeOfType(node, 'ELEMENT_NODE') && - isElementOfType(node, 'img') - ) { - if (isCropMode) { - this.startCropMode(editor, node, isRTL); - } else { - this.startRotateAndResize(editor, node, isRTL); - } + return result; + }, + { + skipSelectionChangedEvent: true, + onNodeCreated: (model, node) => { + if ( + editingImageModel && + editingImageModel == model && + editingImageModel.format.imageState == EDITING_MARKER && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node, isRTL); + } else { + this.startRotateAndResize(editor, node, isRTL); } - }, - apiName: IMAGE_EDIT_FORMAT_EVENT, + } }, - { - tryGetFromCache: true, - } - ); - }); + apiName: IMAGE_EDIT_FORMAT_EVENT, + } + ); } private startEditing( @@ -723,11 +638,7 @@ export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { } const selection = this.editor.getDOMSelection(); if (selection?.type == 'image') { - this.applyFormatWithContentModel( - this.editor, - true /* isCropMode */, - false /* shouldSelectImage */ - ); + this.applyFormatWithContentModel(this.editor, true /* isCropMode */, true); } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts index 810c52a1e67f..7662fc6944d9 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts @@ -70,7 +70,7 @@ const IMAGE_EDIT_FORMAT_EVENT = 'ImageEditEvent'; * - Flip image */ export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { - protected editor: IEditor | null = null; + public editor: IEditor | null = null; private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; protected wrapper: HTMLSpanElement | null = null; @@ -86,7 +86,7 @@ export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { private croppers: HTMLDivElement[] = []; private zoomScale: number = 1; private disposer: (() => void) | null = null; - protected isEditing = false; + public isEditing = false; protected options: ImageEditOptions; constructor(options?: ImageEditOptions) { @@ -334,7 +334,7 @@ export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { /** * EXPOSED FOR TESTING PURPOSE ONLY */ - protected applyFormatWithContentModel( + public applyFormatWithContentModel( editor: IEditor, isCropMode: boolean, shouldSelectImage?: boolean,