diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 1b2c912928f3..d75a9a3fb0fb 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -114,6 +114,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private samplePickerPlugin: SamplePickerPlugin; private snapshots: Snapshots; private markdownPanePlugin: MarkdownPanePlugin; + private imageEditPlugin: ImageEditPlugin; private findReplacePlugin: FindReplacePlugin; private findReplaceContext: FindReplaceContext; @@ -173,26 +174,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)}
); @@ -237,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, }); @@ -553,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/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 83b03db04669..a2a55a701ac2 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -72,6 +72,7 @@ const initialState: OptionState = { experimentalFeatures: new Set([ 'HandleEnterKey', 'CloneIndependentRoot', + 'ImageEditV2', 'CacheList', 'TransformTableBorderColors', ]), diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx index dc9aa65739af..18a989f40e3b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -14,6 +14,7 @@ export class ExperimentalFeatures extends React.Component 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 90b37d6af95a..bd3f5ae5fc4c 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -184,6 +184,7 @@ class SelectionPlugin implements PluginWithState { // Image selection if ( + !editor.isExperimentalFeatureEnabled('ImageEditV2') && selection?.type == 'image' && (rawEvent.button == MouseLeftButton || (rawEvent.button == MouseRightButton && @@ -691,6 +692,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/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 89df3775ba2b..6e90ca5424dd 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,14 @@ 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 + ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -150,7 +157,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, @@ -189,7 +203,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 +249,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 +295,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 +341,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 +383,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 +433,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 +479,9 @@ describe('formatContentModel', () => { core, mockedModel, undefined, - onNodeCreated + onNodeCreated, + undefined, + undefined ); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -476,7 +534,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 +604,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 +653,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 +694,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 +739,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 +779,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 +882,8 @@ describe('formatContentModel', () => { { ignoreSelection: true, }, + undefined, + undefined, undefined ); expect(triggerEvent).toHaveBeenCalled(); @@ -1114,7 +1216,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 +1262,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 +1305,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 +1356,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 +1398,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 +1438,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); }); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts index f18db041d4a2..2064a89b793b 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts @@ -40,7 +40,8 @@ const ORDINAL_LENGTH = 2; if ( numberSegment && numberSegment.segmentType == 'Text' && - ((numericValue = getNumericValue(numberSegment.text, true /* checkFullText */)) !== null) && + (numericValue = getNumericValue(numberSegment.text, true /* checkFullText */)) !== + null && getOrdinal(numericValue) === value ) { shouldAddSuperScript = true; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 4d357d146d15..14fc86303cf3 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -1,47 +1,11 @@ -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 { ImageEditPluginV2 } from './ImageEditPluginV2'; +import { LegacyImageEditPlugin } from './LegacyImageEditPlugin'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { - ContentChangedEvent, - ContentModelImage, EditorPlugin, IEditor, ImageEditOperation, ImageEditor, - ImageMetadataFormat, - KeyDownEvent, - MouseDownEvent, - MouseUpEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -55,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 @@ -69,23 +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; - protected isEditing = false; + private imageEditPlugin: LegacyImageEditPlugin | ImageEditPluginV2 | null = null; protected options: ImageEditOptions; constructor(options?: ImageEditOptions) { @@ -99,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 @@ -106,40 +60,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * @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(); - } - } - }, - }, - }); + const isV2Enabled = editor.isExperimentalFeatureEnabled('ImageEditV2'); + this.imageEditPlugin = isV2Enabled + ? new ImageEditPluginV2(this.options) + : new LegacyImageEditPlugin(this.options); + this.imageEditPlugin.initialize(editor); } /** @@ -148,13 +73,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.isEditing = false; - this.cleanInfo(); - this.editor = null; + this.imageEditPlugin?.dispose(); } /** @@ -164,707 +83,40 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * @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; - } - }); + this.imageEditPlugin?.onPluginEvent(event); } - 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); - } + flipImage(direction: 'vertical' | 'horizontal'): void { + this.imageEditPlugin?.flipImage(direction); } - 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; - }); - } + cropImage(): void { + this.imageEditPlugin?.cropImage(); } - 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 */ - ); - } - } + canRegenerateImage(image: HTMLImageElement): boolean { + return !!this.imageEditPlugin?.canRegenerateImage(image); } - private setContentHandler() { - if (this.selectedImage) { - this.cleanInfo(); - setImageState(this.selectedImage, ''); - this.isEditing = false; - this.isCropMode = false; - } + rotateImage(angleRad: number): void { + this.imageEditPlugin?.rotateImage(angleRad); } - private formatEventHandler(event: ContentChangedEvent) { - if (this.isEditing && event.formatApiName !== IMAGE_EDIT_FORMAT_EVENT) { - this.cleanInfo(); - this.isEditing = false; - this.isCropMode = false; - } + isOperationAllowed(operation: ImageEditOperation): boolean { + return !!this.imageEditPlugin?.isOperationAllowed(operation); } - private contentChangedHandler(editor: IEditor, event: ContentChangedEvent) { - switch (event.source) { - case ChangeSource.SetContent: - this.setContentHandler(); - 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( + this.imageEditPlugin?.applyFormatWithContentModel( editor, - false /* isCrop */, - true /* shouldSelect*/, - true /* isApiOperation */ + isCropMode, + shouldSelectImage, + 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/lib/imageEdit/ImageEditPluginV2.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts new file mode 100644 index 000000000000..07fd3fa551ea --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPluginV2.ts @@ -0,0 +1,750 @@ +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 { + ChangeSource, + getSafeIdSelector, + isElementOfType, + isNodeOfType, + 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, + 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 IMAGE_EDIT_CLASS = 'imageEdit'; +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 + * - Rotate image + * - Flip image + */ +export class ImageEditPluginV2 implements ImageEditor, EditorPlugin { + public 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; + /** + * @deprecated it will always be false + **/ + public isEditing: boolean = false; + + 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 => { + 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); + } + }, + }, + }); + } + + /** + * 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 '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 selectionChangeHandler(editor: IEditor, event: SelectionChangedEvent) { + if (this.selectedImage) { + this.applyFormatWithContentModel(editor, this.isCropMode); + } else if (event.newSelection && event.newSelection.type == 'image') { + editor.getDocument().defaultView?.requestAnimationFrame(() => { + this.applyFormatWithContentModel(editor, this.isCropMode); + }); + } + } + + 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(editor: IEditor, 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(editor, event); + break; + } + } + + /** + * EXPOSED FOR TESTING PURPOSE ONLY + */ + public applyFormatWithContentModel( + editor: IEditor, + isCropMode: 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, 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; + context.clearModelCache = true; + } + ); + + this.cleanInfo(); + 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 ( + 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, + } + ); + } + + 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 */, true); + } + } + + 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..7662fc6944d9 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/LegacyImageEditPlugin.ts @@ -0,0 +1,873 @@ +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'; + +/** + * @internal + * ImageEdit plugin handles the following image editing features: + * - Resize image + * - Crop image + * - Rotate image + * - Flip image + */ +export class LegacyImageEditPlugin implements ImageEditor, EditorPlugin { + public 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; + public 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(); + const image = selection?.type == 'image' ? selection.image : this.selectedImage; + if (image) { + this.cleanInfo(); + setImageState(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 + */ + public 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/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..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,7 +11,10 @@ import type { import type { ImageEditOptions } from '../types/ImageEditOptions'; import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; -const IMAGE_EDIT_SHADOW_ROOT = 'ImageEditShadowRoot'; +/** + * @internal + */ +export const IMAGE_EDIT_SHADOW_ROOT = 'ImageEditShadowRoot'; /** * @internal diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 0f23445b63b4..094da443d798 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'); @@ -838,7 +838,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; @@ -863,7 +863,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; @@ -880,7 +880,7 @@ describe('ImageEditPlugin', () => { }); }); -class TestPlugin extends ImageEditPlugin { +class TestPlugin extends LegacyImageEditPlugin { public setIsEditing(isEditing: boolean) { this.isEditing = isEditing; } @@ -922,7 +922,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/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/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts index 3120182d7ba9..131a602dc801 100644 --- a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -54,6 +54,11 @@ export type ExperimentalFeature = | 'CloneIndependentRoot' /** + * Use selection change event instead of mouse up to insert the image edit handles + */ + | 'ImageEditV2' + + /* * Allow caching list item elements. */ | 'CacheList' diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index c269e4830784..39995d41b760 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -43,6 +43,11 @@ export interface FormatContentModelOptions { * When pass to true, scroll the editing caret into view after write DOM tree if need */ scrollCaretIntoView?: boolean; + + /** + * When true, selection change event will not be triggered bt formatContentModel function + */ + skipSelectionChangedEvent?: boolean; } /**