diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index b547f8107f6..fec4dcc3634 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,4 +1,5 @@ import { addRangeToSelection } from './addRangeToSelection'; +import { areSameSelection } from '../../corePlugin/cache/areSameSelection'; import { ensureImageHasSpanParent } from './ensureImageHasSpanParent'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; @@ -24,6 +25,12 @@ const SELECTION_SELECTOR = '*::selection'; * @internal */ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionChangedEvent) => { + const existingSelection = core.api.getDOMSelection(core); + + if (existingSelection && selection && areSameSelection(existingSelection, selection)) { + return; + } + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. // Set skipReselectOnFocus to skip this behavior const skipReselectOnFocus = core.selection.skipReselectOnFocus; @@ -150,6 +157,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const eventData: SelectionChangedEvent = { eventType: 'selectionChanged', newSelection: selection, + previousSelection: existingSelection, }; core.api.triggerEvent(core, eventData, true /*broadcast*/); diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts index d78f569be94..470a79e9814 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts @@ -1,10 +1,15 @@ -import type { CacheSelection, DOMSelection } from 'roosterjs-content-model-types'; +import type { + CacheSelection, + DOMSelection, + RangeSelection, + RangeSelectionForCache, +} from 'roosterjs-content-model-types'; /** * @internal * Check if the given selections are the same */ -export function areSameSelection(sel1: DOMSelection, sel2: CacheSelection): boolean { +export function areSameSelection(sel1: DOMSelection, sel2: CacheSelection | DOMSelection): boolean { if (sel1 == sel2) { return true; } @@ -25,12 +30,36 @@ export function areSameSelection(sel1: DOMSelection, sel2: CacheSelection): bool case 'range': default: - return ( - sel2.type == 'range' && - sel1.range.startContainer == sel2.start.node && - sel1.range.endContainer == sel2.end.node && - sel1.range.startOffset == sel2.start.offset && - sel1.range.endOffset == sel2.end.offset - ); + if (sel2.type == 'range') { + const { startContainer, startOffset, endContainer, endOffset } = sel1.range; + + if (isCacheSelection(sel2)) { + const { start, end } = sel2; + + return ( + startContainer == start.node && + endContainer == end.node && + startOffset == start.offset && + endOffset == end.offset + ); + } else { + const { range } = sel2; + + return ( + startContainer == range.startContainer && + endContainer == range.endContainer && + startOffset == range.startOffset && + endOffset == range.endOffset + ); + } + } else { + return false; + } } } + +function isCacheSelection( + sel: RangeSelectionForCache | RangeSelection +): sel is RangeSelectionForCache { + return !!(sel as RangeSelectionForCache).start; +} 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 395a0ef3684..e09aa63b008 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -18,7 +18,6 @@ import type { SelectionPluginState, EditorOptions, DOMHelper, - MouseUpEvent, ParsedTable, TableSelectionInfo, TableCellCoordinate, @@ -26,8 +25,6 @@ import type { } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; -const MouseMiddleButton = 1; -const MouseRightButton = 2; const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; @@ -48,7 +45,6 @@ class SelectionPlugin implements PluginWithState { private state: SelectionPluginState; private disposer: (() => void) | null = null; private isSafari = false; - private isMac = false; private scrollTopCache: number = 0; constructor(options: EditorOptions) { @@ -99,7 +95,6 @@ class SelectionPlugin implements PluginWithState { const document = this.editor.getDocument(); this.isSafari = !!env.isSafari; - this.isMac = !!env.isMac; document.addEventListener('selectionchange', this.onSelectionChange); if (this.isSafari) { this.disposer = this.editor.attachDomEvent({ @@ -142,7 +137,7 @@ class SelectionPlugin implements PluginWithState { break; case 'mouseUp': - this.onMouseUp(event); + this.onMouseUp(); break; case 'keyDown': @@ -166,17 +161,9 @@ class SelectionPlugin implements PluginWithState { let image: HTMLImageElement | null; // Image selection - if ( - rawEvent.button === MouseRightButton && - (image = - this.getClickingImage(rawEvent) ?? - this.getContainedTargetImage(rawEvent, selection)) && - image.isContentEditable - ) { + if ((image = this.getClickingImage(rawEvent)) && image.isContentEditable) { this.selectImageWithRange(image, rawEvent); - return; - } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeOrAfterElement(editor, selection.image); + return; } @@ -297,20 +284,7 @@ class SelectionPlugin implements PluginWithState { } } - private onMouseUp(event: MouseUpEvent) { - let image: HTMLImageElement | null; - - if ( - (image = this.getClickingImage(event.rawEvent)) && - image.isContentEditable && - event.rawEvent.button != MouseMiddleButton && - (event.rawEvent.button == - MouseRightButton /* it's not possible to drag using right click */ || - event.isClicking) - ) { - this.selectImageWithRange(image, event.rawEvent); - } - + private onMouseUp() { this.detachMouseEvent(); } @@ -552,27 +526,6 @@ class SelectionPlugin implements PluginWithState { : null; } - // MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. - // Make sure we capture image target even if image is wrapped - private getContainedTargetImage = ( - event: MouseEvent, - previousSelection: DOMSelection | null - ): HTMLImageElement | null => { - if (!this.isMac || !previousSelection || previousSelection.type !== 'image') { - return null; - } - - const target = event.target as Node; - if ( - isNodeOfType(target, 'ELEMENT_NODE') && - isElementOfType(target, 'span') && - target.firstChild === previousSelection.image - ) { - return previousSelection.image; - } - return null; - }; - private onFocus = () => { if (!this.state.skipReselectOnFocus && this.state.selection) { this.setDOMSelection(this.state.selection, this.state.tableSelection); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 6cccb46a8e4..44e90ed1d4f 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -3,6 +3,7 @@ import { canRegenerateImage } from './utils/canRegenerateImage'; import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; import { createImageWrapper } from './utils/createImageWrapper'; import { Cropper } from './Cropper/cropperContext'; +import { formatInsertPointWithContentModel } from 'roosterjs-content-model-api/lib'; import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; import { getSelectedImageMetadata } from './utils/updateImageEditInfo'; @@ -16,6 +17,7 @@ import { isElementOfType, isNodeOfType, mutateSegment, + toArray, unwrap, } from 'roosterjs-content-model-dom'; import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; @@ -23,6 +25,8 @@ import type { DragAndDropContext } from './types/DragAndDropContext'; import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { + DOMInsertPoint, + DOMSelection, EditorPlugin, IEditor, ImageEditOperation, @@ -42,6 +46,7 @@ const DefaultOptions: Partial = { }; const IMAGE_EDIT_CHANGE_SOURCE = 'ImageEdit'; +const MouseLeftButton = 0; /** * ImageEdit plugin handles the following image editing features: @@ -86,15 +91,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { initialize(editor: IEditor) { this.editor = editor; this.disposer = editor.attachDomEvent({ - blur: { - beforeDispatch: () => { - this.formatImageWithContentModel( - editor, - true /* shouldSelectImage */, - true /* shouldSelectAsImageSelection*/ - ); - }, - }, + // blur: { + // beforeDispatch: () => { + // this.formatImageWithContentModel( + // editor, + // true /* shouldSelectImage */, + // true /* shouldSelectAsImageSelection*/ + // ); + // }, + // }, }); } @@ -118,7 +123,46 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * exclusively by another plugin. * @param event The event to handle: */ - onPluginEvent(_event: PluginEvent) {} + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + switch (event.eventType) { + case 'selectionChanged': + { + const selection = event.newSelection; + const previousSelection = event.previousSelection; + + if (previousSelection?.type == 'image' && selection && this.shadowSpan) { + const previousImage = previousSelection.image; + if (previousImage) { + this.formatImageWhenSelectionChange(this.editor, selection); + } + } + } + + break; + + // case 'keyUp': + case 'mouseUp': + { + const selection = this.editor.getDOMSelection(); + + if ( + selection?.type == 'image' && + (event.eventType != 'mouseUp' || event.rawEvent.button == MouseLeftButton) + ) { + const image = selection.image; + + if (image) { + this.startRotateAndResize(this.editor, image); + } + } + } + break; + } + } private startEditing( editor: IEditor, @@ -126,7 +170,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { apiOperation?: ImageEditOperation ) { const imageSpan = image.parentElement; - if (!imageSpan || (imageSpan && !isElementOfType(imageSpan, 'span'))) { + if ( + !imageSpan || + (imageSpan && !isElementOfType(imageSpan, 'span')) || + image.width <= 0 || + image.height <= 0 + ) { return; } this.imageEditInfo = getSelectedImageMetadata(editor, image); @@ -171,6 +220,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (this.wrapper && this.selectedImage && this.shadowSpan) { this.removeImageWrapper(); } + this.startEditing(editor, image, apiOperation); if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { this.dndHelpers = [ @@ -439,6 +489,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image.isSelectedAsImageSelection = shouldSelectAsImageSelection; } }); + + this.cleanInfo(); return true; } @@ -446,14 +498,54 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { }, { changeSource: IMAGE_EDIT_CHANGE_SOURCE, - onNodeCreated: () => { - this.cleanInfo(); - }, } ); } } + private formatImageWhenSelectionChange(editor: IEditor, newSelection: DOMSelection) { + const { selectedImage, imageEditInfo, lastSrc, clonedImage } = this; + + if ( + selectedImage?.parentNode && + lastSrc && + imageEditInfo && + clonedImage && + this.shadowSpan + ) { + const parent = selectedImage.parentNode; + const index = toArray(parent.childNodes).indexOf(selectedImage); + const domIp: DOMInsertPoint = { + node: parent, + offset: index, + }; + + formatInsertPointWithContentModel(editor, domIp, (model, context, insertPoint) => { + if (insertPoint) { + const { paragraph, marker } = insertPoint; + const markerIndex = paragraph.segments.indexOf(marker); + const image = paragraph.segments[markerIndex + 1]; + + if (markerIndex >= 0 && image?.segmentType == 'Image') { + applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + + return true; + } + } + + return false; + }); + } + } + private removeImageWrapper() { let image: HTMLImageElement | null = null; if (this.shadowSpan && this.shadowSpan.parentElement) { 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 9a11e44565f..01c3a3c275e 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -141,8 +141,10 @@ const cloneImage = (image: HTMLImageElement, editInfo: ImageMetadataFormat) => { imageClone.removeAttribute('id'); imageClone.style.removeProperty('max-width'); imageClone.style.removeProperty('max-height'); - imageClone.style.width = editInfo.widthPx + 'px'; - imageClone.style.height = editInfo.heightPx + 'px'; + if (editInfo.widthPx && editInfo.heightPx) { + imageClone.style.width = editInfo.widthPx + 'px'; + imageClone.style.height = editInfo.heightPx + 'px'; + } } return imageClone; }; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts index 3d9085f8778..3b6fef73e66 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts @@ -1,16 +1,36 @@ import { getSelectedSegments } from 'roosterjs-content-model-dom'; import type { + ContentModelImage, + ReadonlyContentModelDocument, ReadonlyContentModelImage, - ShallowMutableContentModelDocument, } from 'roosterjs-content-model-types'; /** * @internal */ export function getSelectedContentModelImage( - model: ShallowMutableContentModelDocument + model: ReadonlyContentModelDocument, + mutate: true +): ContentModelImage | null; + +/** + * @internal + */ +export function getSelectedContentModelImage( + model: ReadonlyContentModelDocument +): ReadonlyContentModelImage | null; + +/** + * @internal + */ +export function getSelectedContentModelImage( + model: ReadonlyContentModelDocument, + mutate?: boolean ): ReadonlyContentModelImage | null { - const selectedSegments = getSelectedSegments(model, false /*includeFormatHolder*/); + const selectedSegments = mutate + ? getSelectedSegments(model, false, true) + : getSelectedSegments(model, false /*includeFormatHolder*/); + if (selectedSegments.length == 1 && selectedSegments[0].segmentType == 'Image') { return selectedSegments[0]; } diff --git a/packages/roosterjs-content-model-types/lib/event/SelectionChangedEvent.ts b/packages/roosterjs-content-model-types/lib/event/SelectionChangedEvent.ts index d692e5a3873..65f91752468 100644 --- a/packages/roosterjs-content-model-types/lib/event/SelectionChangedEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/SelectionChangedEvent.ts @@ -9,4 +9,9 @@ export interface SelectionChangedEvent extends BasePluginEvent<'selectionChanged * The new selection after change */ newSelection: DOMSelection | null; + + /** + * Previous selection before change + */ + previousSelection: DOMSelection | null; }