From 335b8290e4bdcdfc7cc9f570174a135c1adb9c16 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 13 Jun 2024 18:24:56 -0300 Subject: [PATCH 01/49] WIP --- .../lib/editor/core/createEditorCore.ts | 33 +- .../core/createEditorDefaultSettings.ts | 10 +- .../lib/imageEdit/ImageEditPlugin.ts | 309 +++++++++++++----- .../imageEdit/types/EditableImageFormat.ts | 13 + .../lib/imageEdit/types/ImageAndParagraph.ts | 12 + .../lib/imageEdit/utils/applyChange.ts | 6 +- .../lib/imageEdit/utils/createImageWrapper.ts | 4 +- .../lib/imageEdit/utils/findEditingImage.ts | 50 +++ .../utils/getSelectedContentModelImage.ts | 10 +- .../lib/imageEdit/utils/getSelectedImage.ts | 16 + .../lib/imageEdit/utils/setIsEditing.ts | 9 + .../imageEdit/utils/updateImageEditInfo.ts | 9 + .../lib/editor/EditorPlugin.ts | 15 + .../lib/index.ts | 2 +- .../lib/parameter/ImageEditor.ts | 7 +- 15 files changed, 401 insertions(+), 104 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 7dbea590e45..8e88c134555 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -9,6 +9,8 @@ import type { EditorCore, EditorCorePlugins, EditorOptions, + DomToModelOption, + ModelToDomOption, } from 'roosterjs-content-model-types'; /** @@ -18,6 +20,20 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); + const plugins = (options.plugins ?? []).filter(x => !!x); + const domToModelOptions: DomToModelOption[] = []; + const modelToDomOptions: ModelToDomOption[] = []; + + plugins.forEach(plugin => { + const contentModelConfig = plugin.getContentModelConfig?.(); + if (contentModelConfig?.domToModelOption) { + domToModelOptions.push(contentModelConfig.domToModelOption); + } + + if (contentModelConfig?.modelToDomOption) { + modelToDomOptions.push(contentModelConfig.modelToDomOption); + } + }); return { physicalRoot: contentDiv, @@ -31,12 +47,17 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti corePlugins.domEvent, corePlugins.selection, corePlugins.entity, - ...(options.plugins ?? []).filter(x => !!x), + ...plugins, corePlugins.undo, corePlugins.contextMenu, corePlugins.lifecycle, ], - environment: createEditorEnvironment(contentDiv, options), + environment: createEditorEnvironment( + contentDiv, + options, + domToModelOptions, + modelToDomOptions + ), darkColorHandler: createDarkColorHandler( contentDiv, options.getDarkColor ?? getDarkColorFallback, @@ -53,15 +74,17 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti function createEditorEnvironment( contentDiv: HTMLElement, - options: EditorOptions + options: EditorOptions, + domToModelOptionsFromPlugins: (DomToModelOption | undefined)[], + modelToDomOptionsFromPlugins: (ModelToDomOption | undefined)[] ): EditorEnvironment { const navigator = contentDiv.ownerDocument.defaultView?.navigator; const userAgent = navigator?.userAgent ?? ''; const appVersion = navigator?.appVersion ?? ''; return { - domToModelSettings: createDomToModelSettings(options), - modelToDomSettings: createModelToDomSettings(options), + domToModelSettings: createDomToModelSettings(options, domToModelOptionsFromPlugins), + modelToDomSettings: createModelToDomSettings(options, modelToDomOptionsFromPlugins), isMac: appVersion.indexOf('Mac') != -1, isAndroid: /android/i.test(userAgent), isSafari: diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 52bd64b886f..7ff37067f36 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -19,7 +19,8 @@ import type { * @param options The editor options */ export function createDomToModelSettings( - options: EditorOptions + options: EditorOptions, + additionalOptions: (DomToModelOption | undefined)[] ): ContentModelSettings { const builtIn: DomToModelOption = { processorOverride: { @@ -31,7 +32,7 @@ export function createDomToModelSettings( return { builtIn, customized, - calculated: createDomToModelConfig([builtIn, customized]), + calculated: createDomToModelConfig([builtIn, customized, ...additionalOptions]), }; } @@ -41,7 +42,8 @@ export function createDomToModelSettings( * @param options The editor options */ export function createModelToDomSettings( - options: EditorOptions + options: EditorOptions, + additionalOptions: (ModelToDomOption | undefined)[] ): ContentModelSettings { const builtIn: ModelToDomOption = { metadataAppliers: { @@ -54,6 +56,6 @@ export function createModelToDomSettings( return { builtIn, customized, - calculated: createModelToDomConfig([builtIn, customized]), + calculated: createModelToDomConfig([builtIn, customized, ...additionalOptions]), }; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 6cccb46a8e4..4699cf04b9c 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -3,12 +3,16 @@ import { canRegenerateImage } from './utils/canRegenerateImage'; import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; import { createImageWrapper } from './utils/createImageWrapper'; import { Cropper } from './Cropper/cropperContext'; +import { EditableImageFormat } from './types/EditableImageFormat'; +import { findEditingImage } from './utils/findEditingImage'; import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; -import { getSelectedImageMetadata } from './utils/updateImageEditInfo'; +import { getImageMetadata, getSelectedImageMetadata } from './utils/updateImageEditInfo'; +import { getSelectedImage } from './utils/getSelectedImage'; import { ImageEditElementClass } from './types/ImageEditElementClass'; import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; +import { setIsEditing } from './utils/setIsEditing'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; import { @@ -24,10 +28,13 @@ import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { EditorPlugin, + FormatApplier, + FormatParser, IEditor, ImageEditOperation, ImageEditor, ImageMetadataFormat, + MouseUpEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -38,10 +45,11 @@ const DefaultOptions: Partial = { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: 'resizeAndRotate', }; const IMAGE_EDIT_CHANGE_SOURCE = 'ImageEdit'; +const LEFT_MOUSE_BUTTON = 0; /** * ImageEdit plugin handles the following image editing features: @@ -67,6 +75,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private croppers: HTMLDivElement[] = []; private zoomScale: number = 1; private disposer: (() => void) | null = null; + private isEditing = false; constructor(protected options: ImageEditOptions = DefaultOptions) {} @@ -118,7 +127,93 @@ 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 'mouseUp': + this.mouseUpHandler(this.editor, event); + break; + } + } + + getContentModelConfig() { + return { + domToModelOption: { + additionalFormatParsers: { + image: [this.editingFormatParser], + }, + }, + modelToDomOption: { + additionalFormatAppliers: { + image: [this.editingFormatApplier], + }, + }, + }; + } + + private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { + const selection = editor.getDOMSelection(); + if ( + (event.isClicking && + selection && + selection?.type == 'image' && + event.rawEvent.button == LEFT_MOUSE_BUTTON) || + this.isEditing + ) { + editor.formatContentModel(model => { + const previousSelectedImage = findEditingImage(model); + const editingImage = getSelectedImage(model); + const format = editingImage?.image.format as EditableImageFormat; + + let result = false; + if (previousSelectedImage?.image != editingImage?.image) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + this.isEditing && + previousSelectedImage && + previousSelectedImage.image !== editingImage?.image && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + } + ); + + setIsEditing(previousSelectedImage, false); + this.cleanInfo(); + + return true; + } + this.isEditing = false; + + if (editingImage && !format.isEditing && selection?.type == 'image') { + setIsEditing(editingImage, true); + this.isEditing = true; + this.imageEditInfo = getImageMetadata(editingImage.image, selection.image); + result = true; + } + } + + return result; + }); + } + } private startEditing( editor: IEditor, @@ -129,9 +224,21 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (!imageSpan || (imageSpan && !isElementOfType(imageSpan, 'span'))) { return; } - this.imageEditInfo = getSelectedImageMetadata(editor, image); + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); + } + this.createWrapper(editor, image, imageSpan, this.imageEditInfo, apiOperation); + } + + private createWrapper( + editor: IEditor, + image: HTMLImageElement, + imageSpan: HTMLSpanElement, + imageEditInfo: ImageMetadataFormat, + apiOperation?: ImageEditOperation + ) { this.lastSrc = image.getAttribute('src'); - this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); + this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, imageEditInfo); const { resizers, rotators, @@ -144,7 +251,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image, imageSpan, this.options, - this.imageEditInfo, + imageEditInfo, this.imageHTMLOptions, apiOperation || this.options.onSelectState ); @@ -161,95 +268,96 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editor.setEditorStyle('imageEdit', `outline-style:none!important;`, [ `span:has(>img#${this.selectedImage.id})`, ]); + return shadowSpan; } public startRotateAndResize( editor: IEditor, image: HTMLImageElement, - apiOperation?: 'resize' | 'rotate' + imageSpan: HTMLSpanElement ) { - 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 = [ - ...getDropAndDragHelpers( - this.wrapper, + if (this.imageEditInfo) { + this.createWrapper(editor, image, imageSpan, this.imageEditInfo, 'resizeAndRotate'); + + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + 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 + ); + this.wasImageResized = true; + } + }, + this.zoomScale + ), + ...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, + this.rotators + ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } + }, + this.zoomScale + ), + ]; + + updateWrapper( 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 - ); - this.wasImageResized = true; - } - }, - this.zoomScale - ), - ...getDropAndDragHelpers( + this.selectedImage, + this.clonedImage, 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, - this.rotators - ); - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad - ); - } - }, - this.zoomScale - ), - ]; - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.resizers - ); + this.resizers + ); - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad - ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } } } @@ -437,6 +545,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ); image.isSelected = shouldSelectImage; image.isSelectedAsImageSelection = shouldSelectAsImageSelection; + (image.format as EditableImageFormat).isEditing = false; + this.isEditing = false; } }); return true; @@ -514,6 +624,35 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } } + private editingFormatParser: FormatParser = (format, image) => { + const parent = image.parentNode; + if ( + this.isEditing && + parent && + isNodeOfType(parent, 'ELEMENT_NODE') && + isElementOfType(parent, 'span') && + parent.shadowRoot + ) { + format.isEditing = true; + } + }; + + private editingFormatApplier: FormatApplier = (format, image, context) => { + const parent = image.parentNode; + if ( + this.editor && + format.isEditing && + this.imageEditInfo && + isElementOfType(image, 'img') && + parent && + isNodeOfType(parent, 'ELEMENT_NODE') && + isElementOfType(parent, 'span') && + !parent.shadowRoot + ) { + this.startRotateAndResize(this.editor, image, parent); + } + }; + //EXPOSED FOR TEST ONLY public getWrapper() { return this.wrapper; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts new file mode 100644 index 00000000000..00a274eb0b1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts @@ -0,0 +1,13 @@ +import type { ContentModelImageFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export type EditingFormat = { + isEditing?: boolean; +}; + +/** + * @internal + */ +export type EditableImageFormat = ContentModelImageFormat & EditingFormat; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts new file mode 100644 index 00000000000..c767ff662ef --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts @@ -0,0 +1,12 @@ +import type { + ReadonlyContentModelImage, + ReadonlyContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export interface ImageAndParagraph { + image: ReadonlyContentModelImage; + paragraph: ReadonlyContentModelParagraph; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts index af259f85fdb..93091207b48 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts @@ -1,7 +1,7 @@ import { checkEditInfoState } from './checkEditInfoState'; import { generateDataURL } from './generateDataURL'; import { getGeneratedImageSize } from './generateImageSize'; -import { getSelectedImageMetadata, updateImageEditInfo } from './updateImageEditInfo'; +import { getImageMetadata, updateImageEditInfo } from './updateImageEditInfo'; import type { ContentModelImage, IEditor, @@ -28,7 +28,7 @@ export function applyChange( editingImage?: HTMLImageElement ) { let newSrc = ''; - const initEditInfo = getSelectedImageMetadata(editor, editingImage ?? image) ?? undefined; + const initEditInfo = getImageMetadata(contentModelImage, editingImage ?? image) ?? undefined; const state = checkEditInfoState(editInfo, initEditInfo); switch (state) { @@ -64,7 +64,7 @@ export function applyChange( if (newSrc == editInfo.src) { // If newSrc is the same with original one, it means there is only size change, but no rotation, no cropping, // so we don't need to keep edit info, we can delete it - updateImageEditInfo(contentModelImage, null); + // updateImageEditInfo(contentModelImage, null); } else { // Otherwise, save the new edit info to the image so that next time when we edit the same image, we know // the edit info 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..a64fef36c8a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -38,11 +38,11 @@ export function createImageWrapper( const doc = editor.getDocument(); let rotators: HTMLDivElement[] = []; - if (!options.disableRotate && operation === 'rotate') { + if (!options.disableRotate && (operation === 'rotate' || operation === 'resizeAndRotate')) { rotators = createImageRotator(doc, htmlOptions); } let resizers: HTMLDivElement[] = []; - if (operation === 'resize') { + if (operation === 'resize' || operation === 'resizeAndRotate') { resizers = createImageResizer(doc); } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts new file mode 100644 index 00000000000..c47f810ff05 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -0,0 +1,50 @@ +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { EditableImageFormat } from '../types/EditableImageFormat'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; + +/** + * @internal + */ +export function findEditingImage(group: ReadonlyContentModelBlockGroup): ImageAndParagraph | null { + for (let i = 0; i < group.blocks.length; i++) { + const block = group.blocks[i]; + + switch (block.blockType) { + case 'BlockGroup': + const result = findEditingImage(block); + + if (result) { + return result; + } + break; + + case 'Paragraph': + for (let j = 0; j < block.segments.length; j++) { + const segment = block.segments[j]; + + switch (segment.segmentType) { + case 'Image': + if ((segment.format as EditableImageFormat).isEditing) { + return { + paragraph: block, + image: segment, + }; + } + break; + + case 'General': + const result = findEditingImage(segment); + + if (result) { + return result; + } + break; + } + } + + break; + } + } + + return null; +} 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..66a06097818 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts @@ -1,6 +1,6 @@ import { getSelectedSegments } from 'roosterjs-content-model-dom'; import type { - ReadonlyContentModelImage, + ContentModelImage, ShallowMutableContentModelDocument, } from 'roosterjs-content-model-types'; @@ -9,8 +9,12 @@ import type { */ export function getSelectedContentModelImage( model: ShallowMutableContentModelDocument -): ReadonlyContentModelImage | null { - const selectedSegments = getSelectedSegments(model, false /*includeFormatHolder*/); +): ContentModelImage | null { + const selectedSegments = getSelectedSegments( + model, + false /*includeFormatHolder*/, + true /* mutate */ + ); if (selectedSegments.length == 1 && selectedSegments[0].segmentType == 'Image') { return selectedSegments[0]; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts new file mode 100644 index 00000000000..bf0fff2c0cf --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts @@ -0,0 +1,16 @@ +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom/lib'; +import { ImageAndParagraph } from '../types/ImageAndParagraph'; +import { ReadonlyContentModelDocument } from 'roosterjs-content-model-types/lib'; + +export function getSelectedImage(model: ReadonlyContentModelDocument): ImageAndParagraph | null { + const selections = getSelectedSegmentsAndParagraphs(model, false); + + if (selections.length == 1 && selections[0][0].segmentType == 'Image' && selections[0][1]) { + return { + image: selections[0][0], + paragraph: selections[0][1], + }; + } else { + return null; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts new file mode 100644 index 00000000000..3ad31c78350 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts @@ -0,0 +1,9 @@ +import { EditableImageFormat } from '../types/EditableImageFormat'; +import { ImageAndParagraph } from '../types/ImageAndParagraph'; +import { mutateSegment } from 'roosterjs-content-model-dom'; + +export function setIsEditing(imageAndParagraph: ImageAndParagraph, isEditing: boolean) { + mutateSegment(imageAndParagraph.paragraph, imageAndParagraph.image, image => { + (image.format as EditableImageFormat).isEditing = isEditing; + }); +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts index 7edf511774d..a9360e5bf11 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts @@ -4,6 +4,7 @@ import type { ContentModelImage, IEditor, ImageMetadataFormat, + ReadonlyContentModelImage, } from 'roosterjs-content-model-types'; /** @@ -39,6 +40,14 @@ function getInitialEditInfo(image: HTMLImageElement): ImageMetadataFormat { }; } +export function getImageMetadata( + contentModelImage: ReadonlyContentModelImage, + image: HTMLImageElement +): ImageMetadataFormat { + console.log(contentModelImage.dataset); + return { ...getInitialEditInfo(image), ...contentModelImage.dataset }; +} + /** * @internal * @returns diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts index 187003aa9dc..34fe1ecb454 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts @@ -1,6 +1,16 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { PluginEvent } from '../event/PluginEvent'; import type { IEditor } from './IEditor'; +/** + * Configuration for content model of a plugin + */ +export interface PluginContentModelConfig { + domToModelOption?: DomToModelOption; + modelToDomOption?: ModelToDomOption; +} + /** * Interface of an editor plugin */ @@ -42,4 +52,9 @@ export interface EditorPlugin { * @param event The event to handle: */ onPluginEvent?: (event: PluginEvent) => void; + + /** + * @returns The content model configuration for this plugin + */ + getContentModelConfig?: () => PluginContentModelConfig; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index a0fbcc00d7f..da39c73ac10 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -357,7 +357,7 @@ export { Announce, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; -export { EditorPlugin } from './editor/EditorPlugin'; +export { EditorPlugin, PluginContentModelConfig } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts index 127127c849d..357975332b9 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts @@ -20,7 +20,12 @@ export type ImageEditOperation = /** * Flip an image */ - | 'flip'; + | 'flip' + + /** + * Resize and rotate an image + */ + | 'resizeAndRotate'; /** * Define the common operation of an image editor From 77c8a6599fd7f57d87467c5634db2b08278921fc Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 14 Jun 2024 11:53:36 -0300 Subject: [PATCH 02/49] only trigger anchor links --- .../lib/autoFormat/link/createLink.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index 21efd02a57a..cda56ab09de 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,4 +1,4 @@ -import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; +import { addLink, ChangeSource, isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; import type { IEditor, LinkData } from 'roosterjs-content-model-types'; @@ -30,7 +30,11 @@ export function createLink(editor: IEditor) { { changeSource: ChangeSource.AutoLink, onNodeCreated: (_modelElement, node) => { - if (!anchorNode) { + if ( + !anchorNode && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'a') + ) { anchorNode = node; } }, From 468553fe48ba064c68072d9228a83da4196046bd Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 14 Jun 2024 13:48:45 -0300 Subject: [PATCH 03/49] wip --- .../setDOMSelection/setDOMSelection.ts | 7 +++ .../lib/corePlugin/cache/areSameSelection.ts | 47 +++++++++++++--- .../corePlugin/selection/SelectionPlugin.ts | 49 +++------------- .../lib/imageEdit/ImageEditPlugin.ts | 56 +++++++++++++------ 4 files changed, 93 insertions(+), 66 deletions(-) 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..a2bdd01a340 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; 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..41fbb3a1e31 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -26,8 +26,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'; @@ -167,16 +165,19 @@ class SelectionPlugin implements PluginWithState { // Image selection if ( - rawEvent.button === MouseRightButton && + rawEvent.button === MouseLeftButton && (image = this.getClickingImage(rawEvent) ?? this.getContainedTargetImage(rawEvent, selection)) && image.isContentEditable ) { - this.selectImageWithRange(image, rawEvent); - return; - } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeOrAfterElement(editor, selection.image); + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null + ); return; } @@ -278,39 +279,7 @@ class SelectionPlugin implements PluginWithState { } }; - private selectImageWithRange(image: HTMLImageElement, event: Event) { - const range = image.ownerDocument.createRange(); - range.selectNode(image); - - const domSelection = this.editor?.getDOMSelection(); - if (domSelection?.type == 'image' && image == domSelection.image) { - event.preventDefault(); - } else { - this.setDOMSelection( - { - type: 'range', - isReverted: false, - range, - }, - null - ); - } - } - 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); - } - this.detachMouseEvent(); } @@ -329,8 +298,6 @@ class SelectionPlugin implements PluginWithState { if (key === 'Escape') { this.selectBeforeOrAfterElement(editor, selection.image); rawEvent.stopPropagation(); - } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeOrAfterElement(editor, selection.image); } } break; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 4699cf04b9c..b05f200b443 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -179,21 +179,21 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { imageEditInfo && clonedImage ) { - mutateSegment( - previousSelectedImage.paragraph, - previousSelectedImage.image, - image => { - applyChange( - editor, - selectedImage, - image, - imageEditInfo, - lastSrc, - this.wasImageResized || this.isCropMode, - clonedImage - ); - } - ); + // mutateSegment( + // previousSelectedImage.paragraph, + // previousSelectedImage.image, + // image => { + // applyChange( + // editor, + // selectedImage, + // image, + // imageEditInfo, + // lastSrc, + // this.wasImageResized || this.isCropMode, + // clonedImage + // ); + // } + // ); setIsEditing(previousSelectedImage, false); this.cleanInfo(); @@ -649,7 +649,31 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { isElementOfType(parent, 'span') && !parent.shadowRoot ) { - this.startRotateAndResize(this.editor, image, parent); + const shadowRoot = parent.attachShadow({ mode: 'open' }); + + // TODO + const win = image.ownerDocument.defaultView ?? window; + const tempImg = image.cloneNode(); + const div = win.document.createElement('div'); + const innerDiv = win.document.createElement('div'); + + div.style.display = 'inline-block'; + div.appendChild(tempImg); + div.appendChild(innerDiv); + + innerDiv.textContent = 'Editing'; + + shadowRoot.appendChild(div); + + // Hide selection + // https://stackoverflow.com/questions/47625017/override-styles-in-a-shadow-root-element + const sheet: any = new win.CSSStyleSheet(); + + sheet.replaceSync('*::selection { background-color: transparent !important; }'); + + (shadowRoot as any).adoptedStyleSheets.push(sheet); + + // this.startRotateAndResize(this.editor, image, parent); } }; From d8854e7863168c0216f81eb06ad015540a02e555 Mon Sep 17 00:00:00 2001 From: vhuseinova-msft <98852890+vhuseinova-msft@users.noreply.github.com> Date: Fri, 14 Jun 2024 10:04:37 -0700 Subject: [PATCH 04/49] Support dark mode for WatermarkPlugin placeholder styles (#2702) --- .../lib/watermark/WatermarkPlugin.ts | 37 +++++- .../test/watermark/WatermarkPluginTest.ts | 113 ++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts index b09eef7ee89..c64f3cfe3a8 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts @@ -1,4 +1,4 @@ -import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { ChangeSource, getObjectKeys } from 'roosterjs-content-model-dom'; import { isModelEmptyFast } from './isModelEmptyFast'; import type { WatermarkFormat } from './WatermarkFormat'; import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; @@ -17,6 +17,7 @@ export class WatermarkPlugin implements EditorPlugin { private editor: IEditor | null = null; private format: WatermarkFormat; private isShowing = false; + private darkTextColor: string | null = null; /** * Create an instance of Watermark plugin @@ -68,6 +69,25 @@ export class WatermarkPlugin implements EditorPlugin { ) { // When input text, editor must not be empty, so we can do hide watermark now without checking content model this.showHide(editor, false /*isEmpty*/); + } else if ( + event.eventType == 'contentChanged' && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode) && + this.isShowing + ) { + // When the placeholder is shown and user switches the mode, we need to update watermark style + if ( + event.source == ChangeSource.SwitchToDarkMode && + !this.darkTextColor && + this.format.textColor + ) { + // Get the dark color only once when dark mode is enabled for the first time + this.darkTextColor = editor + .getColorManager() + .getDarkColor(this.format.textColor, undefined, 'text'); + } + + this.applyWatermarkStyle(editor); } else if ( event.eventType == 'editorReady' || event.eventType == 'contentChanged' || @@ -93,17 +113,24 @@ export class WatermarkPlugin implements EditorPlugin { } protected show(editor: IEditor) { + this.applyWatermarkStyle(editor); + this.isShowing = true; + } + + private applyWatermarkStyle(editor: IEditor) { let rule = `position: absolute; pointer-events: none; content: "${this.watermark}";`; + const format = { + ...this.format, + textColor: editor.isDarkMode() ? this.darkTextColor : this.format.textColor, + }; getObjectKeys(styleMap).forEach(x => { - if (this.format[x]) { - rule += `${styleMap[x]}: ${this.format[x]}!important;`; + if (format[x]) { + rule += `${styleMap[x]}: ${format[x]}!important;`; } }); editor.setEditorStyle(WATERMARK_CONTENT_KEY, rule, 'before'); - - this.isShowing = true; } protected hide(editor: IEditor) { diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts index 66fe12c49ab..41a9fdeab82 100644 --- a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -1,4 +1,5 @@ import * as isModelEmptyFast from '../../lib/watermark/isModelEmptyFast'; +import { ChangeSource } from 'roosterjs-content-model-dom'; import { IEditor } from 'roosterjs-content-model-types'; import { WatermarkPlugin } from '../../lib/watermark/WatermarkPlugin'; @@ -7,6 +8,7 @@ describe('WatermarkPlugin', () => { let formatContentModelSpy: jasmine.Spy; let isModelEmptyFastSpy: jasmine.Spy; let setEditorStyleSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; const mockedModel = 'Model' as any; @@ -24,6 +26,7 @@ describe('WatermarkPlugin', () => { editor = { formatContentModel: formatContentModelSpy, setEditorStyle: setEditorStyleSpy, + isDarkMode: () => false, } as any; }); @@ -143,3 +146,113 @@ describe('WatermarkPlugin', () => { expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); }); }); + +describe('WatermarkPlugin dark mode', () => { + let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let isModelEmptyFastSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; + let getDarkColorSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; + const DEFAULT_DARK_COLOR_SUFFIX_COLOR = 'DarkColorMock-'; + + const mockedModel = 'Model' as any; + + beforeEach(() => { + isModelEmptyFastSpy = spyOn(isModelEmptyFast, 'isModelEmptyFast'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + getDarkColorSpy = jasmine.createSpy('getDarkColor'); + isDarkModeSpy = jasmine.createSpy('isDarkMode'); + + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + const result = callback(mockedModel); + + expect(result).toBeFalse(); + }); + + editor = { + formatContentModel: formatContentModelSpy, + setEditorStyle: setEditorStyleSpy, + isDarkMode: isDarkModeSpy, + getColorManager: () => ({ + getDarkColor: getDarkColorSpy.and.callFake((color: string) => { + return `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`; + }), + }), + } as any; + }); + + it('Has format, empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(true); + const textColor = 'red'; + const plugin = new WatermarkPlugin('test', { + fontFamily: 'Arial', + fontSize: '20pt', + textColor: textColor, + }); + const darkModeStyles = `position: absolute; pointer-events: none; content: "test";font-family: Arial!important;font-size: 20pt!important;color: ${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${textColor}!important;`; + const lightModeStyles = `position: absolute; pointer-events: none; content: "test";font-family: Arial!important;font-size: 20pt!important;color: red!important;`; + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + lightModeStyles, + 'before' + ); + expect(getDarkColorSpy).not.toHaveBeenCalled(); + isDarkModeSpy.and.returnValue(true); + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: ChangeSource.SwitchToDarkMode, + rawEvent: {}, + } as any); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(getDarkColorSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + darkModeStyles, + 'before' + ); + + isDarkModeSpy.and.returnValue(false); + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: ChangeSource.SwitchToLightMode, + rawEvent: {}, + } as any); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(getDarkColorSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + lightModeStyles, + 'before' + ); + + isDarkModeSpy.and.returnValue(true); + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: ChangeSource.SwitchToDarkMode, + rawEvent: {}, + } as any); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(getDarkColorSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + darkModeStyles, + 'before' + ); + }); +}); From 33e32701f0775aca37cf652eaae1aa6389dfa2bd Mon Sep 17 00:00:00 2001 From: vhuseinova-msft <98852890+vhuseinova-msft@users.noreply.github.com> Date: Fri, 14 Jun 2024 10:48:45 -0700 Subject: [PATCH 05/49] Removed unused variable (#2704) --- .../test/watermark/WatermarkPluginTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts index 41a9fdeab82..220c26ca261 100644 --- a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -8,7 +8,6 @@ describe('WatermarkPlugin', () => { let formatContentModelSpy: jasmine.Spy; let isModelEmptyFastSpy: jasmine.Spy; let setEditorStyleSpy: jasmine.Spy; - let isDarkModeSpy: jasmine.Spy; const mockedModel = 'Model' as any; From 68f10c685e0107447407d2eaea0655fd2e302347 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 14 Jun 2024 15:04:35 -0300 Subject: [PATCH 06/49] wip --- .../lib/corePlugin/selection/SelectionPlugin.ts | 5 +++++ .../lib/imageEdit/ImageEditPlugin.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) 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 41fbb3a1e31..2caeb1809e7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -163,6 +163,11 @@ class SelectionPlugin implements PluginWithState { const selection = editor.getDOMSelection(); let image: HTMLImageElement | null; + // Table selection + if (selection?.type == 'image' && rawEvent.button == MouseLeftButton) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + // Image selection if ( rawEvent.button === MouseLeftButton && diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index b05f200b443..d444fe58b95 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -169,15 +169,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { let result = false; if (previousSelectedImage?.image != editingImage?.image) { - const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + // const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( this.isEditing && previousSelectedImage && - previousSelectedImage.image !== editingImage?.image && - lastSrc && - selectedImage && - imageEditInfo && - clonedImage + previousSelectedImage.image !== editingImage?.image + // lastSrc && + // selectedImage && + // imageEditInfo && + // clonedImage ) { // mutateSegment( // previousSelectedImage.paragraph, From 19173e619bab1f801e0183ce264bcaa36df9a903 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 14 Jun 2024 15:22:10 -0300 Subject: [PATCH 07/49] fix --- .../lib/autoFormat/link/createLink.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index cda56ab09de..65993fffcc4 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,16 +1,18 @@ -import { addLink, ChangeSource, isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; -import type { IEditor, LinkData } from 'roosterjs-content-model-types'; +import type { ContentModelLink, IEditor, LinkData } from 'roosterjs-content-model-types'; /** * @internal */ export function createLink(editor: IEditor) { let anchorNode: Node | null = null; + const links: ContentModelLink[] = []; formatTextSegmentBeforeSelectionMarker( editor, (_model, linkSegment, _paragraph) => { if (linkSegment.link) { + links.push(linkSegment.link); return true; } let linkData: LinkData | null = null; @@ -22,6 +24,9 @@ export function createLink(editor: IEditor) { }, dataset: {}, }); + if (linkSegment.link) { + links.push(linkSegment.link); + } return true; } @@ -29,12 +34,8 @@ export function createLink(editor: IEditor) { }, { changeSource: ChangeSource.AutoLink, - onNodeCreated: (_modelElement, node) => { - if ( - !anchorNode && - isNodeOfType(node, 'ELEMENT_NODE') && - isElementOfType(node, 'a') - ) { + onNodeCreated: (modelElement, node) => { + if (!anchorNode && links.indexOf(modelElement as ContentModelLink) >= 0) { anchorNode = node; } }, From c0a41613c859ef319d8b8a630c4975f4400e6136 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 14 Jun 2024 17:30:18 -0300 Subject: [PATCH 08/49] crop --- .../corePlugin/selection/SelectionPlugin.ts | 13 +- .../lib/imageEdit/ImageEditPlugin.ts | 164 +++++++++--------- .../lib/imageEdit/utils/applyChange.ts | 9 +- .../imageEdit/utils/updateImageEditInfo.ts | 20 +-- 4 files changed, 105 insertions(+), 101 deletions(-) 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 2caeb1809e7..55133b7e5d3 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -163,12 +163,11 @@ class SelectionPlugin implements PluginWithState { const selection = editor.getDOMSelection(); let image: HTMLImageElement | null; - // Table selection + // Image selection + if (selection?.type == 'image' && rawEvent.button == MouseLeftButton) { this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); } - - // Image selection if ( rawEvent.button === MouseLeftButton && (image = @@ -303,6 +302,8 @@ class SelectionPlugin implements PluginWithState { if (key === 'Escape') { this.selectBeforeOrAfterElement(editor, selection.image); rawEvent.stopPropagation(); + } else if (key !== 'Delete' && key !== 'Backspace') { + this.selectBeforeOrAfterElement(editor, selection.image); } } break; @@ -497,7 +498,11 @@ class SelectionPlugin implements PluginWithState { private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { const doc = editor.getDocument(); - const parent = element.parentNode; + let parent = element.parentNode; + if (isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot && parent.parentNode) { + element = parent; + parent = parent.parentNode; + } const index = parent && toArray(parent.childNodes).indexOf(element); if (parent && index !== null && index >= 0) { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index d444fe58b95..78e1dfd4598 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -7,8 +7,8 @@ import { EditableImageFormat } from './types/EditableImageFormat'; import { findEditingImage } from './utils/findEditingImage'; import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; -import { getImageMetadata, getSelectedImageMetadata } from './utils/updateImageEditInfo'; 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'; @@ -18,6 +18,7 @@ import { updateWrapper } from './utils/updateWrapper'; import { getSelectedSegmentsAndParagraphs, isElementOfType, + isModifierKey, isNodeOfType, mutateSegment, unwrap, @@ -27,6 +28,7 @@ import type { DragAndDropContext } from './types/DragAndDropContext'; import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { + DOMSelection, EditorPlugin, FormatApplier, FormatParser, @@ -34,6 +36,7 @@ import type { ImageEditOperation, ImageEditor, ImageMetadataFormat, + KeyDownEvent, MouseUpEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -135,6 +138,9 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { case 'mouseUp': this.mouseUpHandler(this.editor, event); break; + case 'keyDown': + this.keyDownHandler(this.editor, event); + break; } } @@ -162,57 +168,79 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { event.rawEvent.button == LEFT_MOUSE_BUTTON) || this.isEditing ) { - editor.formatContentModel(model => { - const previousSelectedImage = findEditingImage(model); - const editingImage = getSelectedImage(model); - const format = editingImage?.image.format as EditableImageFormat; - - let result = false; - if (previousSelectedImage?.image != editingImage?.image) { - // const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; - if ( - this.isEditing && - previousSelectedImage && - previousSelectedImage.image !== editingImage?.image - // lastSrc && - // selectedImage && - // imageEditInfo && - // clonedImage - ) { - // mutateSegment( - // previousSelectedImage.paragraph, - // previousSelectedImage.image, - // image => { - // applyChange( - // editor, - // selectedImage, - // image, - // imageEditInfo, - // lastSrc, - // this.wasImageResized || this.isCropMode, - // clonedImage - // ); - // } - // ); - - setIsEditing(previousSelectedImage, false); - this.cleanInfo(); + this.selectionChangeHandler(editor, selection); + } + } - return true; - } - this.isEditing = false; + private keyDownHandler(editor: IEditor, event: KeyDownEvent) { + if (this.isEditing) { + const selection = editor.getDOMSelection(); + if (!isModifierKey(event.rawEvent)) { + this.selectionChangeHandler(editor, selection); + } else if (selection?.type == 'image') { + this.formatImageWithContentModel( + editor, + true /* shouldSelect*/, + true /* shouldSelectAsImageSelection*/ + ); + } + } + } - if (editingImage && !format.isEditing && selection?.type == 'image') { - setIsEditing(editingImage, true); - this.isEditing = true; - this.imageEditInfo = getImageMetadata(editingImage.image, selection.image); - result = true; - } + private selectionChangeHandler(editor: IEditor, selection: DOMSelection | null) { + editor.formatContentModel(model => { + const previousSelectedImage = findEditingImage(model); + const editingImage = getSelectedImage(model); + const format = editingImage?.image.format as EditableImageFormat; + + let result = false; + if (previousSelectedImage?.image != editingImage?.image) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + this.isEditing && + previousSelectedImage && + previousSelectedImage.image !== editingImage?.image && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + } + ); + + setIsEditing(previousSelectedImage, false); + this.cleanInfo(); + + return true; } + this.isEditing = false; - return result; - }); - } + if (editingImage && !format.isEditing && selection?.type == 'image') { + setIsEditing(editingImage, true); + this.isEditing = true; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + this.imageEditInfo = updateImageEditInfo(image, selection.image); + }); + + result = true; + } + } + + return result; + }); } private startEditing( @@ -390,7 +418,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } public isOperationAllowed(operation: ImageEditOperation): boolean { - return operation === 'resize' || operation === 'rotate' || operation === 'flip'; + return ( + operation === 'resize' || + operation === 'rotate' || + operation === 'flip' || + operation === 'crop' + ); } public canRegenerateImage(image: HTMLImageElement): boolean { @@ -402,12 +435,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (!this.editor || !selection || selection.type !== 'image') { return; } - let image = selection.image; - if (this.wrapper && this.selectedImage && this.shadowSpan) { - image = this.removeImageWrapper() ?? image; - } - - this.startEditing(this.editor, image, 'crop'); + this.startEditing(this.editor, selection.image, 'crop'); if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { return; } @@ -649,31 +677,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { isElementOfType(parent, 'span') && !parent.shadowRoot ) { - const shadowRoot = parent.attachShadow({ mode: 'open' }); - - // TODO - const win = image.ownerDocument.defaultView ?? window; - const tempImg = image.cloneNode(); - const div = win.document.createElement('div'); - const innerDiv = win.document.createElement('div'); - - div.style.display = 'inline-block'; - div.appendChild(tempImg); - div.appendChild(innerDiv); - - innerDiv.textContent = 'Editing'; - - shadowRoot.appendChild(div); - - // Hide selection - // https://stackoverflow.com/questions/47625017/override-styles-in-a-shadow-root-element - const sheet: any = new win.CSSStyleSheet(); - - sheet.replaceSync('*::selection { background-color: transparent !important; }'); - - (shadowRoot as any).adoptedStyleSheets.push(sheet); - - // this.startRotateAndResize(this.editor, image, parent); + this.startRotateAndResize(this.editor, image, parent); } }; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts index 93091207b48..27554ef7faa 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts @@ -1,7 +1,7 @@ import { checkEditInfoState } from './checkEditInfoState'; import { generateDataURL } from './generateDataURL'; import { getGeneratedImageSize } from './generateImageSize'; -import { getImageMetadata, updateImageEditInfo } from './updateImageEditInfo'; +import { updateImageEditInfo } from './updateImageEditInfo'; import type { ContentModelImage, IEditor, @@ -28,7 +28,8 @@ export function applyChange( editingImage?: HTMLImageElement ) { let newSrc = ''; - const initEditInfo = getImageMetadata(contentModelImage, editingImage ?? image) ?? undefined; + const imageEditing = editingImage ?? image; + const initEditInfo = updateImageEditInfo(contentModelImage, imageEditing) ?? undefined; const state = checkEditInfoState(editInfo, initEditInfo); switch (state) { @@ -64,11 +65,11 @@ export function applyChange( if (newSrc == editInfo.src) { // If newSrc is the same with original one, it means there is only size change, but no rotation, no cropping, // so we don't need to keep edit info, we can delete it - // updateImageEditInfo(contentModelImage, null); + updateImageEditInfo(contentModelImage, imageEditing, null); } else { // Otherwise, save the new edit info to the image so that next time when we edit the same image, we know // the edit info - updateImageEditInfo(contentModelImage, editInfo); + updateImageEditInfo(contentModelImage, imageEditing, editInfo); } // Write back the change to image, and set its new size diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts index a9360e5bf11..9251672c794 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts @@ -4,7 +4,6 @@ import type { ContentModelImage, IEditor, ImageMetadataFormat, - ReadonlyContentModelImage, } from 'roosterjs-content-model-types'; /** @@ -12,9 +11,10 @@ import type { */ export function updateImageEditInfo( contentModelImage: ContentModelImage, - newImageMetadata?: ImageMetadataFormat | null -) { - updateImageMetadata( + image: HTMLImageElement, + newImageMetadata?: ImageMetadataFormat | null | undefined +): ImageMetadataFormat { + const contentModelMetadata = updateImageMetadata( contentModelImage, newImageMetadata !== undefined ? format => { @@ -23,6 +23,7 @@ export function updateImageEditInfo( } : undefined ); + return { ...getInitialEditInfo(image), ...contentModelMetadata }; } function getInitialEditInfo(image: HTMLImageElement): ImageMetadataFormat { @@ -40,14 +41,6 @@ function getInitialEditInfo(image: HTMLImageElement): ImageMetadataFormat { }; } -export function getImageMetadata( - contentModelImage: ReadonlyContentModelImage, - image: HTMLImageElement -): ImageMetadataFormat { - console.log(contentModelImage.dataset); - return { ...getInitialEditInfo(image), ...contentModelImage.dataset }; -} - /** * @internal * @returns @@ -60,7 +53,8 @@ export function getSelectedImageMetadata( editor.formatContentModel(model => { const selectedImage = getSelectedContentModelImage(model); if (selectedImage) { - imageMetadata = { ...imageMetadata, ...selectedImage.dataset }; + imageMetadata = updateImageEditInfo(selectedImage, image); + return true; } return false; }); From 81b29c09fdfc0fcb3b67835480ac407043ca8c4f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 17 Jun 2024 12:04:11 -0700 Subject: [PATCH 09/49] Fix unstable test in tableMoverTest (#2708) --- .../test/tableEdit/tableMoverTest.ts | 235 +++++++++--------- 1 file changed, 119 insertions(+), 116 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts index 8650338381d..481444eedb3 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -18,119 +18,122 @@ describe('Table Mover Tests', () => { let targetId = 'tableSelectionTestId'; let tableEdit: TableEditPlugin; let node: HTMLDivElement; - const cmTable: ContentModelTable = { - blockType: 'Table', - rows: [ - { - height: 20, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'a1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'z1', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - { - height: 20, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'a2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'z2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], + + function createCmTable(): ContentModelTable { + return { + blockType: 'Table', + rows: [ + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + id: `${targetId}`, }, - ], - format: { - id: `${targetId}`, - }, - widths: [10, 10], - dataset: {}, - }; + widths: [10, 10], + dataset: {}, + }; + } beforeEach(() => { document.body.innerHTML = ''; @@ -143,7 +146,7 @@ describe('Table Mover Tests', () => { plugins: [tableEdit], initialModel: { blockGroupType: 'Document', - blocks: [{ ...cmTable }], + blocks: [createCmTable()], format: {}, }, }; @@ -529,7 +532,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; @@ -619,7 +622,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; @@ -701,7 +704,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; From a57a49d3ab96f9a43926857c6e552494ee928c92 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 17 Jun 2024 16:24:29 -0300 Subject: [PATCH 10/49] WIP --- .../lib/imageEdit/ImageEditPlugin.ts | 136 ++++++++++-------- .../imageEdit/types/EditableImageFormat.ts | 9 +- .../lib/imageEdit/utils/getSelectedImage.ts | 9 +- .../lib/imageEdit/utils/setIsEditing.ts | 7 +- .../lib/index.ts | 1 + 5 files changed, 91 insertions(+), 71 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 78e1dfd4598..566b61ec9e1 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -3,7 +3,6 @@ import { canRegenerateImage } from './utils/canRegenerateImage'; import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; import { createImageWrapper } from './utils/createImageWrapper'; import { Cropper } from './Cropper/cropperContext'; -import { EditableImageFormat } from './types/EditableImageFormat'; import { findEditingImage } from './utils/findEditingImage'; import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; @@ -15,6 +14,7 @@ import { Rotator } from './Rotator/rotatorContext'; import { setIsEditing } from './utils/setIsEditing'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; +import type { EditableImageFormat } from './types/EditableImageFormat'; import { getSelectedSegmentsAndParagraphs, isElementOfType, @@ -227,6 +227,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return true; } this.isEditing = false; + this.isCropMode = false; if (editingImage && !format.isEditing && selection?.type == 'image') { setIsEditing(editingImage, true); @@ -255,18 +256,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (!this.imageEditInfo) { this.imageEditInfo = getSelectedImageMetadata(editor, image); } - this.createWrapper(editor, image, imageSpan, this.imageEditInfo, apiOperation); - } - - private createWrapper( - editor: IEditor, - image: HTMLImageElement, - imageSpan: HTMLSpanElement, - imageEditInfo: ImageMetadataFormat, - apiOperation?: ImageEditOperation - ) { this.lastSrc = image.getAttribute('src'); - this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, imageEditInfo); + this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); const { resizers, rotators, @@ -279,7 +270,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image, imageSpan, this.options, - imageEditInfo, + this.imageEditInfo, this.imageHTMLOptions, apiOperation || this.options.onSelectState ); @@ -296,7 +287,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editor.setEditorStyle('imageEdit', `outline-style:none!important;`, [ `span:has(>img#${this.selectedImage.id})`, ]); - return shadowSpan; } public startRotateAndResize( @@ -305,7 +295,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { imageSpan: HTMLSpanElement ) { if (this.imageEditInfo) { - this.createWrapper(editor, image, imageSpan, this.imageEditInfo, 'resizeAndRotate'); + this.startEditing(editor, image, 'resizeAndRotate'); if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { this.dndHelpers = [ @@ -430,54 +420,77 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return canRegenerateImage(image); } - public cropImage() { - const selection = this.editor?.getDOMSelection(); - if (!this.editor || !selection || selection.type !== 'image') { - return; + private startCropMode(editor: IEditor, image: HTMLImageElement, imageSpan: HTMLSpanElement) { + 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, + this.croppers + ); + this.isCropMode = true; + } + }, + this.zoomScale + ), + ]; + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined, + this.croppers + ); + } } - this.startEditing(this.editor, selection.image, 'crop'); - if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { + } + + public cropImage() { + if (!this.editor) { return; } - 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, - this.croppers - ); + this.editor.focus(); + const selection = this.editor.getDOMSelection(); + if (selection?.type == 'image') { + const image = selection.image; + const imageSpan = image.parentElement; + if (imageSpan && imageSpan && isElementOfType(imageSpan, 'span')) { + this.editor.formatContentModel(model => { + const editingImage = getSelectedImage(model); + if (editingImage && editingImage.image && this.editor) { + setIsEditing(editingImage, true); + mutateSegment(editingImage.paragraph, editingImage.image, image => { + this.imageEditInfo = updateImageEditInfo(image, selection.image); + }); + this.isEditing = true; this.isCropMode = true; + return true; } - }, - this.zoomScale - ), - ]; - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined, - this.croppers - ); + return false; + }); + } + } } private editImage( @@ -575,6 +588,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image.isSelectedAsImageSelection = shouldSelectAsImageSelection; (image.format as EditableImageFormat).isEditing = false; this.isEditing = false; + this.isCropMode = false; } }); return true; @@ -677,7 +691,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { isElementOfType(parent, 'span') && !parent.shadowRoot ) { - this.startRotateAndResize(this.editor, image, parent); + if (this.isCropMode) { + this.startCropMode(this.editor, image, parent); + } else { + this.startRotateAndResize(this.editor, image, parent); + } } }; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts index 00a274eb0b1..5838df9bc87 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts @@ -1,13 +1,8 @@ import type { ContentModelImageFormat } from 'roosterjs-content-model-types'; /** - * @internal + * Type for editable image format */ -export type EditingFormat = { +export type EditableImageFormat = ContentModelImageFormat & { isEditing?: boolean; }; - -/** - * @internal - */ -export type EditableImageFormat = ContentModelImageFormat & EditingFormat; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts index bf0fff2c0cf..c517106f2b8 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts @@ -1,7 +1,10 @@ -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom/lib'; -import { ImageAndParagraph } from '../types/ImageAndParagraph'; -import { ReadonlyContentModelDocument } from 'roosterjs-content-model-types/lib'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import type { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; +/** + * @internal + */ export function getSelectedImage(model: ReadonlyContentModelDocument): ImageAndParagraph | null { const selections = getSelectedSegmentsAndParagraphs(model, false); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts index 3ad31c78350..239b4f53f8b 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts @@ -1,7 +1,10 @@ -import { EditableImageFormat } from '../types/EditableImageFormat'; -import { ImageAndParagraph } from '../types/ImageAndParagraph'; import { mutateSegment } from 'roosterjs-content-model-dom'; +import type { EditableImageFormat } from '../types/EditableImageFormat'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; +/** + * @internal + */ export function setIsEditing(imageAndParagraph: ImageAndParagraph, isEditing: boolean) { mutateSegment(imageAndParagraph.paragraph, imageAndParagraph.image, image => { (image.format as EditableImageFormat).isEditing = isEditing; diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 50920741895..9c2afa20fb1 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -36,3 +36,4 @@ export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './pick export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; export { ImageEditPlugin } from './imageEdit/ImageEditPlugin'; export { ImageEditOptions } from './imageEdit/types/ImageEditOptions'; +export { EditableImageFormat } from './imageEdit/types/EditableImageFormat'; From 8f7e125a79ff44c83f5ae4ea9816392e6dd49cb3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 17 Jun 2024 13:27:23 -0700 Subject: [PATCH 11/49] Temporarily disable unstable test cases (#2710) --- .../test/tableEdit/tableEditorTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts index 7cbe0623633..40c4c230a79 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts @@ -15,7 +15,7 @@ import { } from '../../lib/tableEdit/editors/features/CellResizer'; describe('TableEditor', () => { - describe('disableFeatures', () => { + xdescribe('disableFeatures', () => { const insideTheOffset = 5; let editor: IEditor; let table: HTMLTableElement; From 5c3984791ef99f6bd4c1745ce5837e122f66d6bd Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 17 Jun 2024 15:16:31 -0700 Subject: [PATCH 12/49] Improve getDOMInsertPointRect (#2705) * Improve getDOMInsertPointRect * fix build --- .../selection/getDOMInsertPointRect.ts | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts index b4fb3795f55..b302cc7e284 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts @@ -8,19 +8,18 @@ import type { DOMInsertPoint, Rect } from 'roosterjs-content-model-types'; * @param pos The input DOM insert point */ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect | null { - let { node, offset } = pos; const range = doc.createRange(); - range.setStart(node, offset); - - // 1) try to get rect using range.getBoundingClientRect() - let rect = normalizeRect(range.getBoundingClientRect()); + return ( + tryGetRectFromPos(pos, range) ?? // 1. try get from the pos directly using getBoundingClientRect or getClientRects + tryGetRectFromPos((pos = normalizeInsertPoint(pos)), range) ?? // 2. try get normalized pos, this can work when insert point is inside text node + tryGetRectFromNode(pos.node) // 3. fallback to node rect using getBoundingClientRect + ); +} - if (rect) { - return rect; - } +function normalizeInsertPoint(pos: DOMInsertPoint) { + let { node, offset } = pos; - // 2) try to get rect using range.getClientRects while (node.lastChild) { if (offset == node.childNodes.length) { node = node.lastChild; @@ -31,34 +30,28 @@ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect } } - const rects = range.getClientRects && range.getClientRects(); - rect = rects && rects.length == 1 ? normalizeRect(rects[0]) : null; - if (rect) { - return rect; - } + return { node, offset }; +} - // 3) if node is text node, try inserting a SPAN and get the rect of SPAN for others - if (isNodeOfType(node, 'TEXT_NODE')) { - const span = node.ownerDocument.createElement('span'); +function tryGetRectFromPos(pos: DOMInsertPoint, range: Range): Rect | null { + const { node, offset } = pos; - span.textContent = '\u200b'; - range.insertNode(span); - rect = normalizeRect(span.getBoundingClientRect()); - span.parentNode?.removeChild(span); + range.setStart(node, offset); + range.setEnd(node, offset); - if (rect) { - return rect; - } - } + const rect = normalizeRect(range.getBoundingClientRect()); - // 4) try getBoundingClientRect on element - if (isNodeOfType(node, 'ELEMENT_NODE') && node.getBoundingClientRect) { - rect = normalizeRect(node.getBoundingClientRect()); + if (rect) { + return rect; + } else { + const rects = range.getClientRects && range.getClientRects(); - if (rect) { - return rect; - } + return rects && rects.length == 1 ? normalizeRect(rects[0]) : null; } +} - return null; +function tryGetRectFromNode(node: Node) { + return isNodeOfType(node, 'ELEMENT_NODE') && node.getBoundingClientRect + ? normalizeRect(node.getBoundingClientRect()) + : null; } From e2742528d13ef62dacc1dcd89d0d8a4ec75931c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:02:37 -0700 Subject: [PATCH 13/49] Bump ws from 6.2.1 to 6.2.3 (#2712) Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.3. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/6.2.1...6.2.3) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index f216a2a5ad8..24afd2fd02b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7270,9 +7270,9 @@ wrappy@1: integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= ws@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + version "6.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee" + integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA== dependencies: async-limiter "~1.0.0" From 1996e17a018a704368aa4c0c4ce4ddc53ece7009 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 18 Jun 2024 12:15:29 -0700 Subject: [PATCH 14/49] Improve cache (#2706) * Improve cache * fix build * improve * add test --- .../createContentModel/createContentModel.ts | 5 +- .../setContentModel/setContentModel.ts | 10 +- .../setDOMSelection/addRangeToSelection.ts | 10 +- .../setDOMSelection/setDOMSelection.ts | 7 + .../lib/corePlugin/cache/CachePlugin.ts | 76 +++---- .../lib/corePlugin/cache/areSameSelection.ts | 36 ---- .../lib/corePlugin/cache/areSameSelections.ts | 82 ++++++++ .../corePlugin/cache/textMutationObserver.ts | 89 +++++--- ...pdateCachedSelection.ts => updateCache.ts} | 15 +- .../createContentModelTest.ts | 17 +- .../setContentModel/setContentModelTest.ts | 22 +- .../setDOMSelection/setDOMSelectionTest.ts | 129 ++++++++++++ .../test/corePlugin/cache/CachePluginTest.ts | 158 ++++++++++++++ ...ectionTest.ts => areSameSelectionsTest.ts} | 112 +++++++++- .../cache/textMutationObserverTest.ts | 195 ++++++------------ ...hedSelectionTest.ts => updateCacheTest.ts} | 18 +- .../lib/context/TextMutationObserver.ts | 8 +- 17 files changed, 717 insertions(+), 272 deletions(-) delete mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts create mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts rename packages/roosterjs-content-model-core/lib/corePlugin/cache/{updateCachedSelection.ts => updateCache.ts} (64%) rename packages/roosterjs-content-model-core/test/corePlugin/cache/{areSameSelectionTest.ts => areSameSelectionsTest.ts} (74%) rename packages/roosterjs-content-model-core/test/corePlugin/cache/{updateCachedSelectionTest.ts => updateCacheTest.ts} (75%) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index 31146042181..a58efe7e0b9 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -1,4 +1,4 @@ -import { updateCachedSelection } from '../../corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../corePlugin/cache/updateCache'; import { cloneModel, createDomToModelContext, @@ -47,8 +47,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv const model = domToContentModel(core.logicalRoot, domToModelContext); if (saveIndex) { - core.cache.cachedModel = model; - updateCachedSelection(core.cache, selection); + updateCache(core.cache, model, selection); } return model; 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 240b0329d95..19d3da8e411 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -1,4 +1,4 @@ -import { updateCachedSelection } from '../../corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../corePlugin/cache/updateCache'; import { contentModelToDom, createModelToDomContext, @@ -37,16 +37,16 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea ); if (!core.lifecycle.shadowEditFragment) { - updateCachedSelection(core.cache, selection || undefined); + // Clear pending mutations since we will use our latest model object to replace existing cache + core.cache.textMutationObserver?.flushMutations(true /*ignoreMutations*/); + + updateCache(core.cache, model, selection); if (!option?.ignoreSelection && selection) { core.api.setDOMSelection(core, selection); } else { core.selection.selection = selection; } - - // Clear pending mutations since we will use our latest model object to replace existing cache - core.cache.textMutationObserver?.flushMutations(model); } return selection; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts index 9eea7ba1a28..b0dc3607d3f 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts @@ -1,3 +1,5 @@ +import { areSameRanges } from '../../corePlugin/cache/areSameSelections'; + /** * @internal */ @@ -6,13 +8,7 @@ export function addRangeToSelection(doc: Document, range: Range, isReverted: boo if (selection) { const currentRange = selection.rangeCount > 0 && selection.getRangeAt(0); - if ( - currentRange && - currentRange.startContainer == range.startContainer && - currentRange.endContainer == range.endContainer && - currentRange.startOffset == range.startOffset && - currentRange.endOffset == range.endOffset - ) { + if (currentRange && areSameRanges(currentRange, range)) { return; } selection.removeAllRanges(); 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..c615fc24963 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 { areSameSelections } from '../../corePlugin/cache/areSameSelections'; 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 && areSameSelections(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; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index e7723a17d27..4932076f5b2 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -1,14 +1,14 @@ -import { areSameSelection } from './areSameSelection'; +import { areSameSelections } from './areSameSelections'; import { createTextMutationObserver } from './textMutationObserver'; import { DomIndexerImpl } from './domIndexerImpl'; -import { updateCachedSelection } from './updateCachedSelection'; +import { updateCache } from './updateCache'; +import type { Mutation } from './textMutationObserver'; import type { CachePluginState, IEditor, PluginEvent, PluginWithState, EditorOptions, - ContentModelDocument, } from 'roosterjs-content-model-types'; /** @@ -24,24 +24,15 @@ class CachePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ constructor(option: EditorOptions, contentDiv: HTMLDivElement) { - if (option.disableCache) { - this.state = {}; - } else { - const domIndexer = new DomIndexerImpl( - option.experimentalFeatures && - option.experimentalFeatures.indexOf('PersistCache') >= 0 - ); - - this.state = { - domIndexer: domIndexer, - textMutationObserver: createTextMutationObserver( - contentDiv, - domIndexer, - this.onMutation, - this.onSkipMutation - ), - }; - } + this.state = option.disableCache + ? {} + : { + domIndexer: new DomIndexerImpl( + option.experimentalFeatures && + option.experimentalFeatures.indexOf('PersistCache') >= 0 + ), + textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), + }; } /** @@ -115,8 +106,7 @@ class CachePlugin implements PluginWithState { const { contentModel, selection } = event; if (contentModel && this.state.domIndexer) { - this.state.cachedModel = contentModel; - updateCachedSelection(this.state, selection); + updateCache(this.state, contentModel, selection); } else { this.invalidateCache(); } @@ -125,23 +115,31 @@ class CachePlugin implements PluginWithState { } } - private onMutation = (isTextChangeOnly: boolean) => { + private onMutation = (mutation: Mutation) => { if (this.editor) { - if (isTextChangeOnly) { - this.updateCachedModel(this.editor, true /*forceUpdate*/); - } else { - this.invalidateCache(); + switch (mutation.type) { + case 'childList': + if ( + !this.state.domIndexer?.reconcileChildList( + mutation.addedNodes, + mutation.removedNodes + ) + ) { + this.invalidateCache(); + } + break; + + case 'text': + this.updateCachedModel(this.editor, true /*forceUpdate*/); + break; + + case 'unknown': + this.invalidateCache(); + break; } } }; - private onSkipMutation = (newModel: ContentModelDocument) => { - if (!this.editor?.isInShadowEdit()) { - this.state.cachedModel = newModel; - this.state.cachedSelection = undefined; - } - }; - private onNativeSelectionChange = () => { if (this.editor?.hasFocus()) { this.updateCachedModel(this.editor); @@ -156,6 +154,10 @@ class CachePlugin implements PluginWithState { } private updateCachedModel(editor: IEditor, forceUpdate?: boolean) { + if (editor.isInShadowEdit()) { + return; + } + const cachedSelection = this.state.cachedSelection; this.state.cachedSelection = undefined; // Clear it to force getDOMSelection() retrieve the latest selection range @@ -165,7 +167,7 @@ class CachePlugin implements PluginWithState { forceUpdate || !cachedSelection || !newRangeEx || - !areSameSelection(newRangeEx, cachedSelection); + !areSameSelections(newRangeEx, cachedSelection); if (isSelectionChanged) { if ( @@ -175,7 +177,7 @@ class CachePlugin implements PluginWithState { ) { this.invalidateCache(); } else { - updateCachedSelection(this.state, newRangeEx); + updateCache(this.state, model, newRangeEx); } } else { this.state.cachedSelection = cachedSelection; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts deleted file mode 100644 index d78f569be94..00000000000 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CacheSelection, DOMSelection } from 'roosterjs-content-model-types'; - -/** - * @internal - * Check if the given selections are the same - */ -export function areSameSelection(sel1: DOMSelection, sel2: CacheSelection): boolean { - if (sel1 == sel2) { - return true; - } - - switch (sel1.type) { - case 'image': - return sel2.type == 'image' && sel2.image == sel1.image; - - case 'table': - return ( - sel2.type == 'table' && - sel2.table == sel1.table && - sel2.firstColumn == sel1.firstColumn && - sel2.lastColumn == sel1.lastColumn && - sel2.firstRow == sel1.firstRow && - sel2.lastRow == sel1.lastRow - ); - - 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 - ); - } -} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts new file mode 100644 index 00000000000..7bb7d2414f1 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts @@ -0,0 +1,82 @@ +import type { + CacheSelection, + DOMSelection, + RangeSelection, + RangeSelectionForCache, + TableSelection, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Check if the given selections are the same + */ +export function areSameSelections( + sel1: DOMSelection, + sel2: DOMSelection | CacheSelection +): boolean { + if (sel1 == sel2) { + return true; + } + + switch (sel1.type) { + case 'image': + return sel2.type == 'image' && sel2.image == sel1.image; + + case 'table': + return sel2.type == 'table' && areSameTableSelections(sel1, sel2); + + case 'range': + default: + if (sel2.type == 'range') { + const range1 = sel1.range; + + if (isCacheSelection(sel2)) { + const { start, end } = sel2; + + return ( + range1.startContainer == start.node && + range1.endContainer == end.node && + range1.startOffset == start.offset && + range1.endOffset == end.offset + ); + } else { + return areSameRanges(range1, sel2.range); + } + } else { + return false; + } + } +} + +function areSame(o1: O, o2: O, keys: (keyof O)[]) { + return keys.every(k => o1[k] == o2[k]); +} + +const TableSelectionKeys: (keyof TableSelection)[] = [ + 'table', + 'firstColumn', + 'lastColumn', + 'firstRow', + 'lastRow', +]; +const RangeKeys: (keyof Range)[] = ['startContainer', 'endContainer', 'startOffset', 'endOffset']; + +/** + * @internal + */ +export function areSameTableSelections(t1: TableSelection, t2: TableSelection): boolean { + return areSame(t1, t2, TableSelectionKeys); +} + +/** + * @internal + */ +export function areSameRanges(r1: Range, r2: Range): boolean { + return areSame(r1, r2, RangeKeys); +} + +function isCacheSelection( + sel: RangeSelectionForCache | RangeSelection +): sel is RangeSelectionForCache { + return !!(sel as RangeSelectionForCache).start; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts index bcaae8cc3e5..4f833395646 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts @@ -1,17 +1,11 @@ -import type { - ContentModelDocument, - DomIndexer, - TextMutationObserver, -} from 'roosterjs-content-model-types'; +import type { TextMutationObserver } from 'roosterjs-content-model-types'; class TextMutationObserverImpl implements TextMutationObserver { private observer: MutationObserver; constructor( private contentDiv: HTMLDivElement, - private domIndexer: DomIndexer, - private onMutation: (isTextChangeOnly: boolean) => void, - private onSkipMutation: (newModel: ContentModelDocument) => void + private onMutation: (mutation: Mutation) => void ) { this.observer = new MutationObserver(this.onMutationInternal); } @@ -29,12 +23,10 @@ class TextMutationObserverImpl implements TextMutationObserver { this.observer.disconnect(); } - flushMutations(model: ContentModelDocument) { + flushMutations(ignoreMutations?: boolean) { const mutations = this.observer.takeRecords(); - if (model) { - this.onSkipMutation(model); - } else { + if (!ignoreMutations) { this.onMutationInternal(mutations); } } @@ -84,26 +76,77 @@ class TextMutationObserverImpl implements TextMutationObserver { } } - if (canHandle && (addedNodes.length > 0 || removedNodes.length > 0)) { - canHandle = this.domIndexer.reconcileChildList(addedNodes, removedNodes); - } + if (canHandle) { + if (addedNodes.length > 0 || removedNodes.length > 0) { + this.onMutation({ + type: 'childList', + addedNodes, + removedNodes, + }); + } - if (canHandle && reconcileText) { - this.onMutation(true /*textOnly*/); - } else if (!canHandle) { - this.onMutation(false /*textOnly*/); + if (reconcileText) { + this.onMutation({ type: 'text' }); + } + } else { + this.onMutation({ type: 'unknown' }); } }; } +/** + * @internal Type of mutations + */ +export type MutationType = + /** + * We found some change happened but we cannot handle it, so set mutation type as "unknown" + */ + | 'unknown' + /** + * Only text is changed + */ + | 'text' + /** + * Child list is changed + */ + | 'childList'; + +/** + * @internal + */ +export interface MutationBase { + type: T; +} + +/** + * @internal + */ +export interface UnknownMutation extends MutationBase<'unknown'> {} + +/** + * @internal + */ +export interface TextMutation extends MutationBase<'text'> {} + +/** + * @internal + */ +export interface ChildListMutation extends MutationBase<'childList'> { + addedNodes: Node[]; + removedNodes: Node[]; +} + +/** + * @internal + */ +export type Mutation = UnknownMutation | TextMutation | ChildListMutation; + /** * @internal */ export function createTextMutationObserver( contentDiv: HTMLDivElement, - domIndexer: DomIndexer, - onMutation: (isTextChangeOnly: boolean) => void, - onSkipMutation: (newModel: ContentModelDocument) => void + onMutation: (mutation: Mutation) => void ): TextMutationObserver { - return new TextMutationObserverImpl(contentDiv, domIndexer, onMutation, onSkipMutation); + return new TextMutationObserverImpl(contentDiv, onMutation); } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts similarity index 64% rename from packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts index 09275d14289..b119a23a193 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts @@ -1,12 +1,19 @@ -import type { CachePluginState, DOMSelection } from 'roosterjs-content-model-types'; +import type { + CachePluginState, + ContentModelDocument, + DOMSelection, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function updateCachedSelection( +export function updateCache( state: CachePluginState, - selection: DOMSelection | undefined + model: ContentModelDocument, + selection: DOMSelection | null | undefined ) { + state.cachedModel = model; + if (selection?.type == 'range') { const { range: { startContainer, startOffset, endContainer, endOffset }, @@ -25,6 +32,6 @@ export function updateCachedSelection( }, }; } else { - state.cachedSelection = selection; + state.cachedSelection = selection ?? undefined; } } diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index bb223138b34..cbc91567852 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -1,7 +1,7 @@ import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import * as updateCachedSelection from '../../../lib/corePlugin/cache/updateCachedSelection'; +import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; import { createContentModel } from '../../../lib/coreApi/createContentModel/createContentModel'; import { ContentModelDocument, @@ -326,7 +326,7 @@ describe('createContentModel and cache management', () => { let cloneModelSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let createEditorContextSpy: jasmine.Spy; - let updateCachedSelectionSpy: jasmine.Spy; + let updateCacheSpy: jasmine.Spy; const mockedSelection = 'SELECTION' as any; const mockedFragment = 'FRAGMENT' as any; @@ -345,7 +345,7 @@ describe('createContentModel and cache management', () => { flushMutationsSpy = jasmine.createSpy('flushMutations'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue(mockedSelection); createEditorContextSpy = jasmine.createSpy('createEditorContext'); - updateCachedSelectionSpy = spyOn(updateCachedSelection, 'updateCachedSelection'); + updateCacheSpy = spyOn(updateCache, 'updateCache'); textMutationObserver = { flushMutations: flushMutationsSpy } as any; @@ -385,14 +385,17 @@ describe('createContentModel and cache management', () => { } if (allowIndex && !useCache) { - expect(core.cache.cachedModel).toBe(mockedNewModel); - expect(updateCachedSelectionSpy).toHaveBeenCalled(); + expect(updateCacheSpy).toHaveBeenCalledWith( + core.cache, + mockedNewModel, + mockedSelection + ); } else if (hasCache) { expect(core.cache.cachedModel).toBe(mockedModel); - expect(updateCachedSelectionSpy).not.toHaveBeenCalled(); + expect(updateCacheSpy).not.toHaveBeenCalled(); } else { expect(core.cache.cachedModel).toBe(null!); - expect(updateCachedSelectionSpy).not.toHaveBeenCalled(); + expect(updateCacheSpy).not.toHaveBeenCalled(); } } 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 7b86cb7c2ab..201e90ae96c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -1,5 +1,6 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; import { EditorCore } from 'roosterjs-content-model-types'; import { setContentModel } from '../../../lib/coreApi/setContentModel/setContentModel'; @@ -19,6 +20,7 @@ describe('setContentModel', () => { let setDOMSelectionSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let flushMutationsSpy: jasmine.Spy; + let updateCacheSpy: jasmine.Spy; beforeEach(() => { contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); @@ -81,7 +83,7 @@ describe('setContentModel', () => { ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); expect(core.cache.cachedSelection).toBe(mockedRange); - expect(flushMutationsSpy).toHaveBeenCalledWith(mockedModel); + expect(flushMutationsSpy).toHaveBeenCalledWith(true); }); it('with default option, no shadow edit', () => { @@ -251,4 +253,22 @@ describe('setContentModel', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(null); }); + + it('Flush mutation before update cache', () => { + const mockedRange = { + type: 'image', + } as any; + + updateCacheSpy = spyOn(updateCache, 'updateCache'); + contentModelToDomSpy.and.returnValue(mockedRange); + + core.selection = { + selection: 'SELECTION' as any, + tableSelection: null, + }; + setContentModel(core, mockedModel); + + expect(flushMutationsSpy).toHaveBeenCalledBefore(updateCacheSpy); + expect(updateCacheSpy).toHaveBeenCalledBefore(setDOMSelectionSpy); + }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 0a8fab6898d..ebe6864382c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -1,4 +1,5 @@ import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; +import * as ensureImageHasSpanParent from '../../../lib/coreApi/setDOMSelection/ensureImageHasSpanParent'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../../lib/coreApi/setDOMSelection/setDOMSelection'; import { @@ -21,6 +22,7 @@ describe('setDOMSelection', () => { let mockedRange = 'RANGE' as any; let createElementSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); @@ -38,6 +40,7 @@ describe('setDOMSelection', () => { createElementSpy = jasmine.createSpy('createElement').and.returnValue({ appendChild: appendChildSpy, }); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy').and.returnValue(null); doc = { querySelectorAll: querySelectorAllSpy, @@ -59,6 +62,7 @@ describe('setDOMSelection', () => { api: { triggerEvent: triggerEventSpy, setEditorStyle: setEditorStyleSpy, + getDOMSelection: getDOMSelectionSpy, }, domHelper: { hasFocus: hasFocusSpy, @@ -919,6 +923,131 @@ describe('setDOMSelection', () => { ); }); }); + + describe('Same selection', () => { + beforeEach(() => { + querySelectorAllSpy.and.returnValue([]); + spyOn(ensureImageHasSpanParent, 'ensureImageHasSpanParent').and.callFake( + image => image + ); + }); + + function runTest( + originalSelection: DOMSelection | null, + newSelection: DOMSelection | null, + expectedCalled: boolean + ) { + getDOMSelectionSpy.and.returnValue(originalSelection); + + setDOMSelection(core, newSelection); + + if (expectedCalled) { + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'selectionChanged', + newSelection: null, + }, + true + ); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + null + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); + } else { + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + } + } + + it('From null selection', () => { + runTest(null, null, true); + }); + + it('From range selection, same', () => { + runTest( + { + type: 'range', + range: { + startContainer: 'C1', + startOffset: 'O1', + endContainer: 'C2', + endOffset: 'O2', + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: 'C1', + startOffset: 'O1', + endContainer: 'C2', + endOffset: 'O2', + } as any, + isReverted: false, + }, + false + ); + }); + + it('From image selection, same', () => { + let mockedImage: any; + + mockedImage = { + parentElement: { + ownerDocument: doc, + firstElementChild: mockedImage, + lastElementChild: mockedImage, + appendChild: appendChildSpy, + }, + ownerDocument: doc, + } as any; + + runTest( + { + type: 'image', + image: mockedImage, + }, + { + type: 'image', + image: mockedImage, + }, + false + ); + }); + + it('From table selection, same', () => { + runTest( + { + type: 'table', + table: 'T1' as any, + firstColumn: 0, + firstRow: 0, + lastColumn: 1, + lastRow: 1, + }, + { + type: 'table', + table: 'T1' as any, + firstColumn: 0, + firstRow: 0, + lastColumn: 1, + lastRow: 1, + }, + false + ); + }); + }); }); function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = false) { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts index f924e9eb31d..c67de4cec50 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts @@ -373,4 +373,162 @@ describe('CachePlugin', () => { expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); }); + + describe('onMutation', () => { + let onMutation: (mutation: textMutationObserver.Mutation) => void; + let startObservingSpy: jasmine.Spy; + let stopObservingSpy: jasmine.Spy; + let mockedObserver: any; + let reconcileChildListSpy: jasmine.Spy; + let mockedIndexer: DomIndexer; + + beforeEach(() => { + reconcileChildListSpy = jasmine.createSpy('reconcileChildList'); + startObservingSpy = jasmine.createSpy('startObserving'); + stopObservingSpy = jasmine.createSpy('stopObserving'); + + mockedObserver = { + startObserving: startObservingSpy, + stopObserving: stopObservingSpy, + } as any; + + spyOn(textMutationObserver, 'createTextMutationObserver').and.callFake( + (_: any, _onMutation: any) => { + onMutation = _onMutation; + return mockedObserver; + } + ); + + init({}); + + mockedIndexer = { + reconcileSelection: reconcileSelectionSpy, + reconcileChildList: reconcileChildListSpy, + } as any; + }); + + it('unknown', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + onMutation({ type: 'unknown' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('text, can reconcile', () => { + reconcileSelectionSpy.and.returnValue(true); + + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + onMutation({ type: 'text' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: 'MODEL' as any, + cachedSelection: mockedSelection, + }); + }); + + it('text, cannot reconcile', () => { + reconcileSelectionSpy.and.returnValue(false); + + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + onMutation({ type: 'text' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('childList, cannot reconcile', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + reconcileChildListSpy.and.returnValue(false); + + onMutation({ + type: 'childList', + addedNodes: 'ADDED' as any, + removedNodes: 'REMOVED' as any, + }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledWith('ADDED', 'REMOVED'); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('childList, can reconcile', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + reconcileChildListSpy.and.returnValue(true); + + onMutation({ + type: 'childList', + addedNodes: 'ADDED' as any, + removedNodes: 'REMOVED' as any, + }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledWith('ADDED', 'REMOVED'); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: 'MODEL' as any, + cachedSelection: 'SELECTION' as any, + }); + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts similarity index 74% rename from packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts index b1b9be70ca2..6fe1da82186 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts @@ -1,7 +1,7 @@ -import { areSameSelection } from '../../../lib/corePlugin/cache/areSameSelection'; +import { areSameSelections } from '../../../lib/corePlugin/cache/areSameSelections'; import { CacheSelection, DOMSelection } from 'roosterjs-content-model-types'; -describe('areSameSelection', () => { +describe('areSameSelections', () => { const startContainer = 'MockedStartContainer' as any; const endContainer = 'MockedEndContainer' as any; const startOffset = 1; @@ -9,8 +9,8 @@ describe('areSameSelection', () => { const table = 'MockedTable' as any; const image = 'MockedImage' as any; - function runTest(r1: DOMSelection, r2: CacheSelection, result: boolean) { - expect(areSameSelection(r1, r2)).toBe(result); + function runTest(r1: DOMSelection, r2: DOMSelection | CacheSelection, result: boolean) { + expect(areSameSelections(r1, r2)).toBe(result); } it('Same object', () => { @@ -256,6 +256,110 @@ describe('areSameSelection', () => { ); }); + it('different normal range - 5', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: 'Container 2' as any, + startOffset: startOffset, + endContainer: endContainer, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 6', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: startOffset, + endContainer: 'Container 2' as any, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 7', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: 3, + endContainer: endContainer, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 8', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: startOffset, + endContainer: endContainer, + endOffset: 4, + } as any, + isReverted: false, + }, + false + ); + }); + it('different table range - 1', () => { runTest( { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts index d784fdd1435..16835b04b7a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts @@ -1,17 +1,9 @@ import * as textMutationObserver from '../../../lib/corePlugin/cache/textMutationObserver'; -import { DomIndexer, TextMutationObserver } from 'roosterjs-content-model-types'; -import { DomIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; +import { TextMutationObserver } from 'roosterjs-content-model-types'; describe('TextMutationObserverImpl', () => { - let domIndexer: DomIndexer; - let onSkipMutation: jasmine.Spy; let observer: TextMutationObserver; - beforeEach(() => { - domIndexer = new DomIndexerImpl(); - onSkipMutation = jasmine.createSpy('onSkipMutation'); - }); - afterEach(() => { observer?.stopObserving(); }); @@ -19,39 +11,32 @@ describe('TextMutationObserverImpl', () => { it('init', () => { const div = document.createElement('div'); const onMutation = jasmine.createSpy('onMutation'); - textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + textMutationObserver.createTextMutationObserver(div, onMutation); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); }); - it('not text change', async () => { + it('no text change', async () => { const div = document.createElement('div'); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); - div.appendChild(document.createElement('br')); + const br = document.createElement('br'); + div.appendChild(br); await new Promise(resolve => { window.setTimeout(resolve, 10); }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [br], + removedNodes: [], + }); }); it('text change', async () => { @@ -61,12 +46,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -77,8 +57,7 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('text change in deeper node', async () => { @@ -91,12 +70,7 @@ describe('TextMutationObserverImpl', () => { const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -107,8 +81,7 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('text and non-text change', async () => { @@ -119,25 +92,26 @@ describe('TextMutationObserverImpl', () => { const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); text.nodeValue = '1'; - div.appendChild(document.createElement('br')); + + const br = document.createElement('br'); + div.appendChild(br); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(2); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [br], + removedNodes: [], + }); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('flush mutation', async () => { @@ -147,12 +121,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -163,8 +132,9 @@ describe('TextMutationObserverImpl', () => { window.setTimeout(resolve, 10); }); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ + type: 'text', + }); }); it('flush mutation without change', async () => { @@ -174,12 +144,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); observer.flushMutations(); @@ -189,7 +154,6 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); }); it('flush mutation with a new model', async () => { @@ -199,12 +163,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -218,7 +177,6 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).toHaveBeenCalledWith(newModel); }); it('flush mutation when type in new line - 1', async () => { @@ -229,28 +187,24 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div.replaceChild(text, br); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); }); it('flush mutation when type in new line - 2', async () => { @@ -261,12 +215,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -274,17 +223,19 @@ describe('TextMutationObserverImpl', () => { div.removeChild(br); text.nodeValue = 'test'; - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(2); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('flush mutation when type in new line, fail to reconcile', async () => { @@ -295,30 +246,24 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div.replaceChild(text, br); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); }); it('mutation happens in different root', async () => { @@ -333,31 +278,21 @@ describe('TextMutationObserverImpl', () => { div.appendChild(div2); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div1.removeChild(br); div2.appendChild(text); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).not.toHaveBeenCalled(); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ type: 'unknown' }); }); it('attribute change', async () => { @@ -367,29 +302,19 @@ describe('TextMutationObserverImpl', () => { div.appendChild(div1); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div1.id = 'div1'; - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).not.toHaveBeenCalled(); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ type: 'unknown' }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts similarity index 75% rename from packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts index eb42933c3f2..7ebfc132833 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts @@ -1,14 +1,17 @@ import { CachePluginState } from 'roosterjs-content-model-types'; -import { updateCachedSelection } from '../../../lib/corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../../lib/corePlugin/cache/updateCache'; + +describe('updateCache', () => { + const mockedModel = 'MODEL' as any; -describe('updateCachedSelection', () => { it('Update to undefined', () => { const state: CachePluginState = {}; - updateCachedSelection(state, undefined); + updateCache(state, mockedModel, undefined); expect(state).toEqual({ cachedSelection: undefined, + cachedModel: mockedModel, }); }); @@ -18,10 +21,11 @@ describe('updateCachedSelection', () => { type: 'table', } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: mockedSelection, + cachedModel: mockedModel, }); }); @@ -31,10 +35,11 @@ describe('updateCachedSelection', () => { type: 'image', } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: mockedSelection, + cachedModel: mockedModel, }); }); @@ -51,7 +56,7 @@ describe('updateCachedSelection', () => { isReverted: false, } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: { @@ -66,6 +71,7 @@ describe('updateCachedSelection', () => { }, isReverted: false, } as any, + cachedModel: mockedModel, }); }); }); diff --git a/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts b/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts index f24566d9a8f..187dae08b22 100644 --- a/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts +++ b/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts @@ -1,5 +1,3 @@ -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; - /** * A wrapper of MutationObserver to observe text change from editor */ @@ -15,7 +13,9 @@ export interface TextMutationObserver { stopObserving(): void; /** - * Flush all pending mutations that have not be handled in order to ignore them + * Flush all pending mutations and update cached model if need + * @param ignoreMutations When pass true, all mutations will be ignored and do not update content model. + * This should only be used when we already have a up-to-date content model and will set it as latest cache */ - flushMutations(newModel?: ContentModelDocument): void; + flushMutations(ignoreMutations?: boolean): void; } From 6620e39f533747343e830e3ffd8170a52e437227 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 18 Jun 2024 17:35:58 -0300 Subject: [PATCH 15/49] image crop --- .../corePlugin/selection/SelectionPlugin.ts | 8 +- .../setDOMSelection/setDOMSelectionTest.ts | 3 + .../selection/SelectionPluginTest.ts | 149 ++++-------------- .../test/editor/core/createEditorCoreTest.ts | 4 +- .../core/createEditorDefaultSettingsTest.ts | 22 ++- .../lib/imageEdit/ImageEditPlugin.ts | 25 +-- .../utils/getSelectedContentModelImage.ts | 23 --- .../imageEdit/utils/updateImageEditInfo.ts | 14 +- .../test/imageEdit/ImageEditPluginTest.ts | 51 +++++- .../imageEdit/utils/findEditingImageTest.ts | 107 +++++++++++++ ...elImageTest.ts => getSelectedImageTest.ts} | 86 ++++++---- .../test/imageEdit/utils/setIsEditingTest.ts | 22 +++ .../utils/updateImageEditInfoTest.ts | 5 +- 13 files changed, 314 insertions(+), 205 deletions(-) delete mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts create mode 100644 packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts rename packages/roosterjs-content-model-plugins/test/imageEdit/utils/{getSelectedContentModelImageTest.ts => getSelectedImageTest.ts} (51%) create mode 100644 packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts 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 55133b7e5d3..5a4f19b6bfe 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, @@ -140,7 +139,7 @@ class SelectionPlugin implements PluginWithState { break; case 'mouseUp': - this.onMouseUp(event); + this.onMouseUp(); break; case 'keyDown': @@ -164,10 +163,10 @@ class SelectionPlugin implements PluginWithState { let image: HTMLImageElement | null; // Image selection - if (selection?.type == 'image' && rawEvent.button == MouseLeftButton) { this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); } + if ( rawEvent.button === MouseLeftButton && (image = @@ -283,7 +282,7 @@ class SelectionPlugin implements PluginWithState { } }; - private onMouseUp(event: MouseUpEvent) { + private onMouseUp() { this.detachMouseEvent(); } @@ -523,7 +522,6 @@ class SelectionPlugin implements PluginWithState { private getClickingImage(event: UIEvent): HTMLImageElement | null { const target = event.target as Node; - return isNodeOfType(target, 'ELEMENT_NODE') && isElementOfType(target, 'img') ? target : null; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 0a8fab6898d..64adce1a7bb 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -21,6 +21,7 @@ describe('setDOMSelection', () => { let mockedRange = 'RANGE' as any; let createElementSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); @@ -38,6 +39,7 @@ describe('setDOMSelection', () => { createElementSpy = jasmine.createSpy('createElement').and.returnValue({ appendChild: appendChildSpy, }); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); doc = { querySelectorAll: querySelectorAllSpy, @@ -59,6 +61,7 @@ describe('setDOMSelection', () => { api: { triggerEvent: triggerEventSpy, setEditorStyle: setEditorStyleSpy, + getDOMSelection: getDOMSelectionSpy, }, domHelper: { hasFocus: hasFocusSpy, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 86a46a42508..3e8ec028685 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -338,6 +338,7 @@ describe('SelectionPlugin handle image selection', () => { let domHelperSpy: jasmine.Spy; let requestAnimationFrameSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; + let findClosestElementAncestor: jasmine.Spy; beforeEach(() => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); @@ -352,7 +353,10 @@ describe('SelectionPlugin handle image selection', () => { requestAnimationFrame: requestAnimationFrameSpy, }, }); - domHelperSpy = jasmine.createSpy('domHelperSpy'); + findClosestElementAncestor = jasmine.createSpy('findClosestElementAncestor'); + domHelperSpy = jasmine.createSpy('domHelperSpy').and.returnValue({ + findClosestElementAncestor: findClosestElementAncestor, + }); editor = { getDOMHelper: domHelperSpy, @@ -407,15 +411,12 @@ describe('SelectionPlugin handle image selection', () => { eventType: 'mouseDown', rawEvent: { target: node, + button: 0, } as any, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'range', - range: mockedRange, - isReverted: false, - }); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); it('Image selection, mouse down to div, no parent of image', () => { @@ -439,146 +440,66 @@ describe('SelectionPlugin handle image selection', () => { eventType: 'mouseDown', rawEvent: { target: node, + button: 0, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); - it('Image selection, mouse down to same image', () => { + it('Image selection, mouse down to a image', () => { const mockedImage = { parentNode: { childNodes: [] }, + isContentEditable: true, + nodeType: 1, + tagName: 'IMG', + dispatchEvent: jasmine.createSpy('dispatchEvent'), } as any; - getDOMSelectionSpy.and.returnValue({ - type: 'image', - image: mockedImage, - }); + getDOMSelectionSpy.and.returnValue(null); plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, + button: 0, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - }); - - it('Image selection, mouse down to same image right click', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - const range = document.createRange(); - range.selectNode(mockedImage); - - const preventDefaultSpy = jasmine.createSpy('preventDefault'); - - mockedImage.contentEditable = 'true'; - - getDOMSelectionSpy.and.returnValue({ + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'image', image: mockedImage, }); - - plugin.onPluginEvent!({ - eventType: 'mouseDown', - rawEvent: (>{ - target: mockedImage, - button: 2, - preventDefault: preventDefaultSpy, - }) as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - expect(preventDefaultSpy).toHaveBeenCalled(); }); - it('Image selection, mouse down to image right click', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - - mockedImage.contentEditable = 'true'; - plugin.onPluginEvent!({ - eventType: 'mouseDown', - rawEvent: { - target: mockedImage, - button: 2, - } as any, + it('Image selection, mouse down to same image', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + isContentEditable: true, + nodeType: 1, + tagName: 'IMG', + dispatchEvent: jasmine.createSpy('dispatchEvent'), + } as any; + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - }); - - it('Image selection, mouse down to div right click', () => { - const node = document.createElement('div'); - plugin.onPluginEvent!({ eventType: 'mouseDown', - rawEvent: { - target: node, - button: 2, - } as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - }); - - it('no selection, mouse up to image, is clicking, isEditable', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - const range = document.createRange(); - range.selectNode(mockedImage); - - mockedImage.contentEditable = 'true'; - - plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: true, rawEvent: { target: mockedImage, + button: 0, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'range', - range, - isReverted: false, - }); - }); - - it('no selection, mouse up to image, is clicking, not isEditable', () => { - const mockedImage = document.createElement('img'); - - mockedImage.contentEditable = 'false'; - - plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: true, - rawEvent: { - target: mockedImage, - } as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - }); - - it('no selection, mouse up to image, is not clicking, isEditable', () => { - const mockedImage = document.createElement('img'); - - mockedImage.contentEditable = 'true'; - - plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: false, - rawEvent: { - target: mockedImage, - } as any, + type: 'image', + image: mockedImage, }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); }); it('key down - ESCAPE, no selection', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index b4af3645965..e42951811ee 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -108,8 +108,8 @@ describe('createEditorCore', () => { options, contentDiv ); - expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options); - expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options); + expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options, []); + expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options, []); } it('No options', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index 2c9a69aab21..b6923e13cd9 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -20,7 +20,7 @@ describe('createDomToModelSettings', () => { }); it('No options', () => { - const settings = createDomToModelSettings({}); + const settings = createDomToModelSettings({}, []); expect(settings).toEqual({ builtIn: { @@ -43,9 +43,12 @@ describe('createDomToModelSettings', () => { it('Has options', () => { const defaultDomToModelOptions = 'MockedOptions' as any; - const settings = createDomToModelSettings({ - defaultDomToModelOptions: defaultDomToModelOptions, - }); + const settings = createDomToModelSettings( + { + defaultDomToModelOptions: defaultDomToModelOptions, + }, + [] + ); expect(settings).toEqual({ builtIn: { @@ -77,7 +80,7 @@ describe('createModelToDomSettings', () => { }); it('No options', () => { - const settings = createModelToDomSettings({}); + const settings = createModelToDomSettings({}, []); expect(settings).toEqual({ builtIn: { @@ -102,9 +105,12 @@ describe('createModelToDomSettings', () => { it('Has options', () => { const defaultModelToDomOptions = 'MockedOptions' as any; - const settings = createModelToDomSettings({ - defaultModelToDomOptions: defaultModelToDomOptions, - }); + const settings = createModelToDomSettings( + { + defaultModelToDomOptions: defaultModelToDomOptions, + }, + [] + ); expect(settings).toEqual({ builtIn: { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 566b61ec9e1..cabaf9a9dd1 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -65,7 +65,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { protected editor: IEditor | null = null; private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; - public wrapper: HTMLSpanElement | null = null; + protected wrapper: HTMLSpanElement | null = null; private imageEditInfo: ImageMetadataFormat | null = null; private imageHTMLOptions: ImageHtmlOptions | null = null; private dndHelpers: DragAndDropHelper[] = []; @@ -78,7 +78,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private croppers: HTMLDivElement[] = []; private zoomScale: number = 1; private disposer: (() => void) | null = null; - private isEditing = false; + //EXPOSED FOR TEST ONLY + protected isEditing = false; constructor(protected options: ImageEditOptions = DefaultOptions) {} @@ -117,6 +118,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { */ dispose() { this.editor = null; + this.isEditing = false; this.cleanInfo(); if (this.disposer) { this.disposer(); @@ -161,6 +163,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); + console.log('mouseUp', selection); if ( (event.isClicking && selection && @@ -174,7 +177,9 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private keyDownHandler(editor: IEditor, event: KeyDownEvent) { if (this.isEditing) { + console.log('keyDownHandler'); const selection = editor.getDOMSelection(); + console.log('keyDownHandler', selection); if (!isModifierKey(event.rawEvent)) { this.selectionChangeHandler(editor, selection); } else if (selection?.type == 'image') { @@ -289,11 +294,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ]); } - public startRotateAndResize( - editor: IEditor, - image: HTMLImageElement, - imageSpan: HTMLSpanElement - ) { + public startRotateAndResize(editor: IEditor, image: HTMLImageElement) { if (this.imageEditInfo) { this.startEditing(editor, image, 'resizeAndRotate'); @@ -420,7 +421,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { return canRegenerateImage(image); } - private startCropMode(editor: IEditor, image: HTMLImageElement, imageSpan: HTMLSpanElement) { + private startCropMode(editor: IEditor, image: HTMLImageElement) { if (this.imageEditInfo) { this.startEditing(editor, image, 'crop'); if (this.imageEditInfo && this.selectedImage && this.wrapper && this.clonedImage) { @@ -692,15 +693,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { !parent.shadowRoot ) { if (this.isCropMode) { - this.startCropMode(this.editor, image, parent); + this.startCropMode(this.editor, image); } else { - this.startRotateAndResize(this.editor, image, parent); + this.startRotateAndResize(this.editor, image); } } }; //EXPOSED FOR TEST ONLY - public getWrapper() { - return this.wrapper; + public get isEditingImage() { + return this.isEditing; } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts deleted file mode 100644 index 66a06097818..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getSelectedSegments } from 'roosterjs-content-model-dom'; -import type { - ContentModelImage, - ShallowMutableContentModelDocument, -} from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function getSelectedContentModelImage( - model: ShallowMutableContentModelDocument -): ContentModelImage | null { - const selectedSegments = getSelectedSegments( - model, - false /*includeFormatHolder*/, - true /* mutate */ - ); - if (selectedSegments.length == 1 && selectedSegments[0].segmentType == 'Image') { - return selectedSegments[0]; - } - - return null; -} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts index 9251672c794..0831491d762 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts @@ -1,5 +1,6 @@ -import { getSelectedContentModelImage } from './getSelectedContentModelImage'; -import { updateImageMetadata } from 'roosterjs-content-model-dom'; +import { getSelectedImage } from './getSelectedImage'; +import { mutateSegment, updateImageMetadata } from 'roosterjs-content-model-dom'; + import type { ContentModelImage, IEditor, @@ -51,9 +52,12 @@ export function getSelectedImageMetadata( ): ImageMetadataFormat { let imageMetadata: ImageMetadataFormat = getInitialEditInfo(image); editor.formatContentModel(model => { - const selectedImage = getSelectedContentModelImage(model); - if (selectedImage) { - imageMetadata = updateImageEditInfo(selectedImage, image); + const selectedImage = getSelectedImage(model); + if (selectedImage?.image) { + mutateSegment(selectedImage.paragraph, selectedImage?.image, modelImage => { + imageMetadata = updateImageEditInfo(modelImage, image); + }); + return true; } return false; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 4b6c7dba2f5..4472b57b73a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -40,10 +40,55 @@ const model: ContentModelDocument = { }; describe('ImageEditPlugin', () => { - const plugin = new ImageEditPlugin(); - const editor = initEditor('image_edit', [plugin], model); + it('mouseUp', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + + expect(plugin.isEditingImage).toBeTruthy(); + plugin.dispose(); + }); + + it('keyDown', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'k', + } as any, + }); + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + + it('cropImage', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.cropImage(); + expect(plugin.isEditingImage).toBeTruthy(); + plugin.dispose(); + }); it('flip', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); const image = new Image(); image.src = 'test'; plugin.initialize(editor); @@ -54,6 +99,8 @@ describe('ImageEditPlugin', () => { }); it('rotate', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); const image = new Image(); image.src = 'test'; plugin.initialize(editor); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts new file mode 100644 index 00000000000..e03bddc3fa5 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts @@ -0,0 +1,107 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { EditableImageFormat } from '../../../lib/imageEdit/types/EditableImageFormat'; +import { findEditingImage } from '../../../lib/imageEdit/utils/findEditingImage'; + +describe('findEditingImage', () => { + it('no image', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + ], + format: {}, + }; + + const image = findEditingImage(model); + expect(image).toBeNull(); + }); + + it('editing image', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + isEditing: true, + } as EditableImageFormat, + dataset: {}, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + + const image = findEditingImage(model); + expect(image).toEqual({ + image: { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + isEditing: true, + } as EditableImageFormat, + dataset: {}, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + isEditing: true, + } as EditableImageFormat, + dataset: {}, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts similarity index 51% rename from packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts rename to packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts index c4c152e3ca5..29261a8f640 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts @@ -1,8 +1,8 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { getSelectedContentModelImage } from '../../../lib/imageEdit/utils/getSelectedContentModelImage'; +import { getSelectedImage } from '../../../lib/imageEdit/utils/getSelectedImage'; -describe('getSelectedContentModelImage', () => { - it('should return image model', () => { +describe('getSelectedImage', () => { + it('get selected image', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -38,24 +38,52 @@ describe('getSelectedContentModelImage', () => { textColor: '#000000', }, }; - const result = getSelectedContentModelImage(model); - expect(result).toEqual({ - segmentType: 'Image', - src: 'test', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1800px', + + const selections = getSelectedImage(model); + expect(selections).toEqual({ + image: { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, }, - dataset: {}, - isSelectedAsImageSelection: true, - isSelected: true, }); }); - it('should not return image model', () => { + it('no image selected', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -63,18 +91,13 @@ describe('getSelectedContentModelImage', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'Image', - src: 'test', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1800px', - }, - dataset: {}, - isSelectedAsImageSelection: false, - isSelected: false, + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: {}, }, ], format: {}, @@ -91,7 +114,8 @@ describe('getSelectedContentModelImage', () => { textColor: '#000000', }, }; - const result = getSelectedContentModelImage(model); - expect(result).toEqual(null); + + const selections = getSelectedImage(model); + expect(selections).toEqual(null); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts new file mode 100644 index 00000000000..9c8740da1a3 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts @@ -0,0 +1,22 @@ +import { createImage, createParagraph } from 'roosterjs-content-model-dom'; +import { ImageAndParagraph } from '../../../lib/imageEdit/types/ImageAndParagraph'; +import { setIsEditing } from '../../../lib/imageEdit/utils/setIsEditing'; + +describe('setIsEditing', () => { + function runTest(isEditing: boolean) { + const paragraph = createParagraph(); + const image = createImage('test'); + paragraph.segments.push(image); + const imageAndParagraph: ImageAndParagraph = { paragraph, image }; + setIsEditing(imageAndParagraph, isEditing); + expect((image.format as any).isEditing).toBe(isEditing); + } + + it('setIsEditing true', () => { + runTest(true); + }); + + it('setIsEditing false', () => { + runTest(false); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts index 90ce33e4961..03a333b7746 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts @@ -51,7 +51,7 @@ describe('updateImageEditInfo', () => { it('update image edit info', () => { const updateImageMetadataSpy = spyOn(updateImageMetadata, 'updateImageMetadata'); const contentModelImage = createImage('test'); - updateImageEditInfo(contentModelImage, { + updateImageEditInfo(contentModelImage, new Image(), { widthPx: 10, heightPx: 10, }); @@ -65,7 +65,7 @@ describe('getSelectedImageMetadata', () => { const image = new Image(10, 10); const metadata = getSelectedImageMetadata(editor, image); const expected = { - src: '', + src: 'test', widthPx: 0, heightPx: 0, naturalWidth: 0, @@ -75,7 +75,6 @@ describe('getSelectedImageMetadata', () => { topPercent: 0, bottomPercent: 0, angleRad: 0, - editingInfo: '{"src":"test"}', }; expect(metadata).toEqual(expected); }); From 22d9d9aadf363ef0adba10360d936f67fa814a88 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 18 Jun 2024 19:56:15 -0300 Subject: [PATCH 16/49] fixes --- .../lib/imageEdit/ImageEditPlugin.ts | 4 +- .../lib/imageEdit/utils/updateWrapper.ts | 4 +- .../test/imageEdit/ImageEditPluginTest.ts | 57 +++++++++++++++---- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index cabaf9a9dd1..6c4de0425d3 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -163,7 +163,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); - console.log('mouseUp', selection); + if ( (event.isClicking && selection && @@ -177,9 +177,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private keyDownHandler(editor: IEditor, event: KeyDownEvent) { if (this.isEditing) { - console.log('keyDownHandler'); const selection = editor.getDOMSelection(); - console.log('keyDownHandler', selection); if (!isModifierKey(event.rawEvent)) { this.selectionChangeHandler(editor, selection); } else if (selection?.type == 'image') { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts index 43a8b58980d..4911d5860ad 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts @@ -109,7 +109,7 @@ export function updateWrapper( setSize(cropOverlays[2], cropLeftPx, undefined, 0, 0, undefined, cropBottomPx); setSize(cropOverlays[3], 0, cropTopPx, undefined, 0, cropLeftPx, undefined); - if (angleRad) { + if (angleRad !== undefined) { updateHandleCursor(croppers, angleRad); } } @@ -132,7 +132,7 @@ export function updateWrapper( }) .filter(handle => !!handle) as HTMLDivElement[]; - if (angleRad) { + if (angleRad !== undefined) { updateHandleCursor(resizeHandles, angleRad); } diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 4472b57b73a..4dacdc8aa50 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -40,7 +40,7 @@ const model: ContentModelDocument = { }; describe('ImageEditPlugin', () => { - it('mouseUp', () => { + it('keyDown', () => { const plugin = new ImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); plugin.initialize(editor); @@ -51,12 +51,52 @@ describe('ImageEditPlugin', () => { button: 0, } as any, }); - - expect(plugin.isEditingImage).toBeTruthy(); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'k', + } as any, + }); + expect(plugin.isEditingImage).toBeFalsy(); plugin.dispose(); }); - it('keyDown', () => { + it('mouseUp', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; const plugin = new ImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); plugin.initialize(editor); @@ -67,13 +107,8 @@ describe('ImageEditPlugin', () => { button: 0, } as any, }); - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'k', - } as any, - }); - expect(plugin.isEditingImage).toBeFalsy(); + + expect(plugin.isEditingImage).toBeTruthy(); plugin.dispose(); }); From a7a0c6b6a042338218dcb922cb3678b471e578b8 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Jun 2024 13:59:57 -0300 Subject: [PATCH 17/49] fixes --- .../lib/imageEdit/ImageEditPlugin.ts | 8 ++-- .../lib/imageEdit/utils/createImageWrapper.ts | 4 +- .../test/imageEdit/ImageEditPluginTest.ts | 42 +++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 6c4de0425d3..2f8d75570ad 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -167,7 +167,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if ( (event.isClicking && selection && - selection?.type == 'image' && + selection.type == 'image' && event.rawEvent.button == LEFT_MOUSE_BUTTON) || this.isEditing ) { @@ -226,14 +226,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { setIsEditing(previousSelectedImage, false); this.cleanInfo(); - - return true; + result = true; } + this.isEditing = false; this.isCropMode = false; if (editingImage && !format.isEditing && selection?.type == 'image') { setIsEditing(editingImage, true); + this.isEditing = true; mutateSegment(editingImage.paragraph, editingImage.image, image => { this.imageEditInfo = updateImageEditInfo(image, selection.image); @@ -683,7 +684,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if ( this.editor && format.isEditing && - this.imageEditInfo && isElementOfType(image, 'img') && parent && isNodeOfType(parent, 'ELEMENT_NODE') && 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 a64fef36c8a..56a6592c163 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -93,9 +93,7 @@ const createWrapper = ( imageBox.appendChild(image); wrapper.setAttribute( 'style', - `max-width: 100%; position: relative; display: inline-flex; font-size: 24px; margin: 0px; transform: rotate(${ - editInfo.angleRad ?? 0 - }rad); text-align: left;` + `font-size: 24px; margin: 0px; transform: rotate(${editInfo.angleRad ?? 0}rad);` ); wrapper.style.display = editor.getEnvironment().isSafari ? 'inline-block' : 'inline-flex'; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 4dacdc8aa50..79ac20ac462 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -1,4 +1,5 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { EditableImageFormat } from '../../lib/imageEdit/types/EditableImageFormat'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; import { initEditor } from '../TestHelper'; @@ -41,6 +42,42 @@ const model: ContentModelDocument = { describe('ImageEditPlugin', () => { it('keyDown', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + isEditing: true, + } as EditableImageFormat, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; const plugin = new ImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); plugin.initialize(editor); @@ -59,6 +96,7 @@ describe('ImageEditPlugin', () => { }); expect(plugin.isEditingImage).toBeFalsy(); plugin.dispose(); + editor.dispose(); }); it('mouseUp', () => { @@ -110,6 +148,7 @@ describe('ImageEditPlugin', () => { expect(plugin.isEditingImage).toBeTruthy(); plugin.dispose(); + editor.dispose(); }); it('cropImage', () => { @@ -119,6 +158,7 @@ describe('ImageEditPlugin', () => { plugin.cropImage(); expect(plugin.isEditingImage).toBeTruthy(); plugin.dispose(); + editor.dispose(); }); it('flip', () => { @@ -131,6 +171,7 @@ describe('ImageEditPlugin', () => { const dataset = getSelectedImageMetadata(editor, image); expect(dataset).toBeTruthy(); plugin.dispose(); + editor.dispose(); }); it('rotate', () => { @@ -143,5 +184,6 @@ describe('ImageEditPlugin', () => { const dataset = getSelectedImageMetadata(editor, image); expect(dataset).toBeTruthy(); plugin.dispose(); + editor.dispose(); }); }); From 129c3c4cdcb94d0fabbe66fd589218f6d47449d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:06:11 -0700 Subject: [PATCH 18/49] Bump socket.io from 4.4.1 to 4.7.5 (#2715) Bumps [socket.io](https://github.com/socketio/socket.io) from 4.4.1 to 4.7.5. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/4.4.1...4.7.5) --- updated-dependencies: - dependency-name: socket.io dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 87 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/yarn.lock b/yarn.lock index 24afd2fd02b..536a974e869 100644 --- a/yarn.lock +++ b/yarn.lock @@ -573,10 +573,10 @@ dependencies: esquery "^1.4.0" -"@socket.io/base64-arraybuffer@~1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@socket.io/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#568d9beae00b0d835f4f8c53fd55714986492e61" - integrity sha512-dOlCBKnDw4iShaIsH/bxujKTM18+2TOAsYz+KSc11Am38H4q5Xw8Bbz97ZYdrVNM+um3p7w86Bvvmcn9q+5+eQ== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== "@types/color-convert@*": version "2.0.0" @@ -597,11 +597,6 @@ dependencies: "@types/color-convert" "*" -"@types/component-emitter@^1.2.10": - version "1.2.11" - resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.11.tgz#50d47d42b347253817a39709fef03ce66a108506" - integrity sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ== - "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" @@ -1813,7 +1808,7 @@ compare-versions@^3.6.0: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== -component-emitter@^1.2.1, component-emitter@~1.3.0: +component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2046,6 +2041,13 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3 dependencies: ms "2.1.2" +debug@~4.3.4: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2289,17 +2291,15 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -engine.io-parser@~5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.3.tgz#ca1f0d7b11e290b4bfda251803baea765ed89c09" - integrity sha512-BtQxwF27XUNnSafQLvDi0dQ8s3i6VgzSoQMJacpIcGNrlUdfHSKbgm3jmjCVvQluGzqwujQMPAoMai3oYSTurg== - dependencies: - "@socket.io/base64-arraybuffer" "~1.0.2" +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== -engine.io@~6.1.0: - version "6.1.2" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.1.2.tgz#e7b9d546d90c62246ffcba4d88594be980d3855a" - integrity sha512-v/7eGHxPvO2AWsksyx2PUsQvBafuvqs0jJJQ0FdmJG1b9qIvgSbqDRGwNhfk2XHaTTbTXiC4quRE8Q9nRjsrQQ== +engine.io@~6.5.2: + version "6.5.5" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.5.tgz#430b80d8840caab91a50e9e23cb551455195fc93" + integrity sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA== dependencies: "@types/cookie" "^0.4.1" "@types/cors" "^2.8.12" @@ -2309,8 +2309,8 @@ engine.io@~6.1.0: cookie "~0.4.1" cors "~2.8.5" debug "~4.3.1" - engine.io-parser "~5.0.0" - ws "~8.2.3" + engine.io-parser "~5.2.1" + ws "~8.17.1" enhanced-resolve@4.1.0: version "4.1.0" @@ -6173,31 +6173,34 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -socket.io-adapter@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.3.tgz#4d6111e4d42e9f7646e365b4f578269821f13486" - integrity sha512-Qd/iwn3VskrpNO60BeRyCyr8ZWw9CPZyitW4AQwmRZ8zCiyDiL+znRnWX6tDHXnWn1sJrM1+b6Mn6wEDJJ4aYQ== +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" -socket.io-parser@~4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.5.tgz#cb404382c32324cc962f27f3a44058cf6e0552df" - integrity sha512-sNjbT9dX63nqUFIOv95tTVm6elyIU4RvB1m8dOeZt+IgWwcWklFDOdmGcfo3zSiRsnR/3pJkjY5lfoGqEe4Eig== +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== dependencies: - "@types/component-emitter" "^1.2.10" - component-emitter "~1.3.0" + "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" socket.io@^4.2.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.4.1.tgz#cd6de29e277a161d176832bb24f64ee045c56ab8" - integrity sha512-s04vrBswdQBUmuWJuuNTmXUVJhP0cVky8bBDhdkf8y0Ptsu7fKU2LuLbts9g+pdmAdyMMn8F/9Mf1/wbtUN0fg== + version "4.7.5" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8" + integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== dependencies: accepts "~1.3.4" base64id "~2.0.0" + cors "~2.8.5" debug "~4.3.2" - engine.io "~6.1.0" - socket.io-adapter "~2.3.3" - socket.io-parser "~4.0.4" + engine.io "~6.5.2" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" sockjs-client@1.4.0: version "1.4.0" @@ -7276,10 +7279,10 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" -ws@~8.2.3: - version "8.2.3" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" - integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" From b92b7e5f68bde26cff53acd88efa7c8bbefc29c9 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Jun 2024 14:28:04 -0300 Subject: [PATCH 19/49] remove is clicking --- .../lib/imageEdit/ImageEditPlugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 2f8d75570ad..16c059ca4aa 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -165,8 +165,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { const selection = editor.getDOMSelection(); if ( - (event.isClicking && - selection && + (selection && selection.type == 'image' && event.rawEvent.button == LEFT_MOUSE_BUTTON) || this.isEditing From c1e3e86c3c907d5b1f6ca534bdf3768dd9150a7f Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Jun 2024 15:44:28 -0300 Subject: [PATCH 20/49] remove span from image --- .../ensureImageHasSpanParent.ts | 24 ------------------ .../setDOMSelection/setDOMSelection.ts | 18 ++++++------- .../setDOMSelection/setDOMSelectionTest.ts | 25 ++++++++----------- .../optimizers/removeUnnecessarySpan.ts | 11 +------- .../optimizers/removeUnnecessarySpanTest.ts | 6 ++--- .../lib/imageEdit/ImageEditPlugin.ts | 5 ---- .../lib/imageEdit/utils/createImageWrapper.ts | 7 ++++-- .../Rotator/updateRotateHandleTest.ts | 10 +------- .../imageEdit/utils/createImageWrapperTest.ts | 2 +- .../test/imageEdit/utils/updateWrapperTest.ts | 5 +--- 10 files changed, 31 insertions(+), 82 deletions(-) delete mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts deleted file mode 100644 index 7ede477d9a8..00000000000 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { isElementOfType, isNodeOfType, wrap } from 'roosterjs-content-model-dom'; - -/** - * @internal - * Ensure image is wrapped by a span element - * @param image - * @returns the image - */ -export function ensureImageHasSpanParent(image: HTMLImageElement): HTMLImageElement { - const parent = image.parentElement; - - if ( - parent && - isNodeOfType(parent, 'ELEMENT_NODE') && - isElementOfType(parent, 'span') && - parent.firstChild == image && - parent.lastChild == image - ) { - return image; - } - - wrap(image.ownerDocument, image, 'span'); - return image; -} 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 c615fc24963..c8570dd8ee9 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,6 +1,5 @@ import { addRangeToSelection } from './addRangeToSelection'; import { areSameSelections } from '../../corePlugin/cache/areSameSelections'; -import { ensureImageHasSpanParent } from './ensureImageHasSpanParent'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; import { findTableCellElement } from './findTableCellElement'; @@ -20,6 +19,7 @@ const TABLE_ID = 'table'; const CARET_CSS_RULE = 'caret-color: transparent'; const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important;'; const SELECTION_SELECTOR = '*::selection'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; /** * @internal @@ -45,12 +45,10 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC try { switch (selection?.type) { case 'image': - const image = ensureImageHasSpanParent(selection.image); + const image = selection.image; + + core.selection.selection = selection; - core.selection.selection = { - type: 'image', - image, - }; const imageSelectionColor = isDarkMode ? core.selection.imageSelectionBorderColorDark : core.selection.imageSelectionBorderColor; @@ -58,10 +56,10 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - `outline-style:solid!important; outline-color:${imageSelectionColor}!important;display: ${ - core.environment.isSafari ? '-webkit-inline-flex' : 'inline-flex' - };`, - [`span:has(>img#${ensureUniqueId(image, IMAGE_ID)})`] + `outline-style:auto!important; outline-color:${ + imageSelectionColor || DEFAULT_SELECTION_BORDER_COLOR + }!important;`, + [`#${ensureUniqueId(image, IMAGE_ID)}`] ); core.api.setEditorStyle( core, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index ebe6864382c..ec858920bc3 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -1,7 +1,7 @@ import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; -import * as ensureImageHasSpanParent from '../../../lib/coreApi/setDOMSelection/ensureImageHasSpanParent'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../../lib/coreApi/setDOMSelection/setDOMSelection'; + import { DEFAULT_SELECTION_BORDER_COLOR, DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, @@ -314,8 +314,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -374,8 +374,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:red!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:auto!important; outline-color:red!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -441,8 +441,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, '_DOMSelection', - 'outline-style:solid!important; outline-color:DarkColorMock-red!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:auto!important; outline-color:DarkColorMock-red!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, @@ -502,8 +502,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -563,8 +563,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0_0)'] + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -927,9 +927,6 @@ describe('setDOMSelection', () => { describe('Same selection', () => { beforeEach(() => { querySelectorAllSpy.and.returnValue([]); - spyOn(ensureImageHasSpanParent, 'ensureImageHasSpanParent').and.callFake( - image => image - ); }); function runTest( diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts index 3b0ccb11b88..ee048a395bb 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts @@ -8,8 +8,7 @@ export function removeUnnecessarySpan(root: Node) { if ( isNodeOfType(child, 'ELEMENT_NODE') && child.tagName == 'SPAN' && - child.attributes.length == 0 && - !isImageSpan(child) + child.attributes.length == 0 ) { const node = child; let refNode = child.nextSibling; @@ -27,11 +26,3 @@ export function removeUnnecessarySpan(root: Node) { } } } - -const isImageSpan = (child: HTMLElement) => { - return ( - isNodeOfType(child.firstChild, 'ELEMENT_NODE') && - child.firstChild.tagName == 'IMG' && - child.firstChild == child.lastChild - ); -}; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts index f2aab1c27e2..e3e1b42d363 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts @@ -54,12 +54,12 @@ describe('removeUnnecessarySpan', () => { expect(div.innerHTML).toBe('test1test2test3'); }); - it('Do not remove image span', () => { + it('Remove image span', () => { const div = document.createElement('div'); - div.innerHTML = ''; + div.innerHTML = ''; removeUnnecessarySpan(div); - expect(div.innerHTML).toBe(''); + expect(div.innerHTML).toBe(''); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 6cccb46a8e4..128d76a1113 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -125,10 +125,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image: HTMLImageElement, apiOperation?: ImageEditOperation ) { - const imageSpan = image.parentElement; - if (!imageSpan || (imageSpan && !isElementOfType(imageSpan, 'span'))) { - return; - } this.imageEditInfo = getSelectedImageMetadata(editor, image); this.lastSrc = image.getAttribute('src'); this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); @@ -142,7 +138,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } = createImageWrapper( editor, image, - imageSpan, this.options, this.imageEditInfo, this.imageHTMLOptions, 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..3fc0e5fe92c 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -1,6 +1,7 @@ import { createImageCropper } from '../Cropper/createImageCropper'; import { createImageResizer } from '../Resizer/createImageResizer'; import { createImageRotator } from '../Rotator/createImageRotator'; +import { wrap } from 'roosterjs-content-model-dom'; import type { IEditor, @@ -28,7 +29,6 @@ export interface WrapperElements { export function createImageWrapper( editor: IEditor, image: HTMLImageElement, - imageSpan: HTMLSpanElement, options: ImageEditOptions, editInfo: ImageMetadataFormat, htmlOptions: ImageHtmlOptions, @@ -60,6 +60,7 @@ export function createImageWrapper( rotators, croppers ); + const imageSpan = wrap(doc, image, 'span'); const shadowSpan = createShadowSpan(wrapper, imageSpan); return { wrapper, shadowSpan, imageClone, resizers, rotators, croppers }; } @@ -97,7 +98,9 @@ const createWrapper = ( editInfo.angleRad ?? 0 }rad); text-align: left;` ); - wrapper.style.display = editor.getEnvironment().isSafari ? 'inline-block' : 'inline-flex'; + wrapper.style.display = editor.getEnvironment().isSafari + ? '-webkit-inline-flex' + : 'inline-flex'; const border = createBorder(editor, options.borderColor); wrapper.appendChild(imageBox); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts index 98cbfaa7155..4deb0b7eb64 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts @@ -57,15 +57,7 @@ xdescribe('updateRotateHandlePosition', () => { bottomPercent: 0, angleRad: 0, }; - const { wrapper } = createImageWrapper( - editor, - image, - imageSpan, - {}, - imageInfo, - options, - 'rotate' - ); + const { wrapper } = createImageWrapper(editor, image, {}, imageInfo, options, 'rotate'); const rotateCenter = wrapper.querySelector('.r_rotateC')! as HTMLElement; const rotateHandle = wrapper.querySelector('.r_rotateH')! as HTMLElement; spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index b2cf97173e9..ff8382d7ae8 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -24,7 +24,7 @@ describe('createImageWrapper', () => { const result = createImageWrapper( editor, image, - imageSpan, + options, editInfo, htmlOptions, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts index df74a226e3b..65d99d4ddbc 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -32,15 +32,12 @@ describe('updateWrapper', () => { isSmallImage: false, }; const image = document.createElement('img'); - const imageSpan = document.createElement('span'); - imageSpan.appendChild(image); - document.body.appendChild(imageSpan); + document.body.appendChild(image); it('should update size', () => { const { wrapper, imageClone, resizers } = createImageWrapper( editor, image, - imageSpan, options, editInfo, htmlOptions, From 7713f9eb88780ffc8d7bebb2e5e4ef814df579d6 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 19 Jun 2024 19:41:20 -0300 Subject: [PATCH 21/49] add plugin config --- .../lib/editor/core/createEditorCore.ts | 33 ++++++++++++++++--- .../core/createEditorDefaultSettings.ts | 10 +++--- .../test/editor/core/createEditorCoreTest.ts | 4 +-- .../core/createEditorDefaultSettingsTest.ts | 22 ++++++++----- .../lib/editor/EditorPlugin.ts | 23 +++++++++++++ .../lib/index.ts | 2 +- 6 files changed, 74 insertions(+), 20 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 7dbea590e45..8e88c134555 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -9,6 +9,8 @@ import type { EditorCore, EditorCorePlugins, EditorOptions, + DomToModelOption, + ModelToDomOption, } from 'roosterjs-content-model-types'; /** @@ -18,6 +20,20 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); + const plugins = (options.plugins ?? []).filter(x => !!x); + const domToModelOptions: DomToModelOption[] = []; + const modelToDomOptions: ModelToDomOption[] = []; + + plugins.forEach(plugin => { + const contentModelConfig = plugin.getContentModelConfig?.(); + if (contentModelConfig?.domToModelOption) { + domToModelOptions.push(contentModelConfig.domToModelOption); + } + + if (contentModelConfig?.modelToDomOption) { + modelToDomOptions.push(contentModelConfig.modelToDomOption); + } + }); return { physicalRoot: contentDiv, @@ -31,12 +47,17 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti corePlugins.domEvent, corePlugins.selection, corePlugins.entity, - ...(options.plugins ?? []).filter(x => !!x), + ...plugins, corePlugins.undo, corePlugins.contextMenu, corePlugins.lifecycle, ], - environment: createEditorEnvironment(contentDiv, options), + environment: createEditorEnvironment( + contentDiv, + options, + domToModelOptions, + modelToDomOptions + ), darkColorHandler: createDarkColorHandler( contentDiv, options.getDarkColor ?? getDarkColorFallback, @@ -53,15 +74,17 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti function createEditorEnvironment( contentDiv: HTMLElement, - options: EditorOptions + options: EditorOptions, + domToModelOptionsFromPlugins: (DomToModelOption | undefined)[], + modelToDomOptionsFromPlugins: (ModelToDomOption | undefined)[] ): EditorEnvironment { const navigator = contentDiv.ownerDocument.defaultView?.navigator; const userAgent = navigator?.userAgent ?? ''; const appVersion = navigator?.appVersion ?? ''; return { - domToModelSettings: createDomToModelSettings(options), - modelToDomSettings: createModelToDomSettings(options), + domToModelSettings: createDomToModelSettings(options, domToModelOptionsFromPlugins), + modelToDomSettings: createModelToDomSettings(options, modelToDomOptionsFromPlugins), isMac: appVersion.indexOf('Mac') != -1, isAndroid: /android/i.test(userAgent), isSafari: diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 52bd64b886f..7ff37067f36 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -19,7 +19,8 @@ import type { * @param options The editor options */ export function createDomToModelSettings( - options: EditorOptions + options: EditorOptions, + additionalOptions: (DomToModelOption | undefined)[] ): ContentModelSettings { const builtIn: DomToModelOption = { processorOverride: { @@ -31,7 +32,7 @@ export function createDomToModelSettings( return { builtIn, customized, - calculated: createDomToModelConfig([builtIn, customized]), + calculated: createDomToModelConfig([builtIn, customized, ...additionalOptions]), }; } @@ -41,7 +42,8 @@ export function createDomToModelSettings( * @param options The editor options */ export function createModelToDomSettings( - options: EditorOptions + options: EditorOptions, + additionalOptions: (ModelToDomOption | undefined)[] ): ContentModelSettings { const builtIn: ModelToDomOption = { metadataAppliers: { @@ -54,6 +56,6 @@ export function createModelToDomSettings( return { builtIn, customized, - calculated: createModelToDomConfig([builtIn, customized]), + calculated: createModelToDomConfig([builtIn, customized, ...additionalOptions]), }; } diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index b4af3645965..e42951811ee 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -108,8 +108,8 @@ describe('createEditorCore', () => { options, contentDiv ); - expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options); - expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options); + expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options, []); + expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options, []); } it('No options', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index 2c9a69aab21..b6923e13cd9 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -20,7 +20,7 @@ describe('createDomToModelSettings', () => { }); it('No options', () => { - const settings = createDomToModelSettings({}); + const settings = createDomToModelSettings({}, []); expect(settings).toEqual({ builtIn: { @@ -43,9 +43,12 @@ describe('createDomToModelSettings', () => { it('Has options', () => { const defaultDomToModelOptions = 'MockedOptions' as any; - const settings = createDomToModelSettings({ - defaultDomToModelOptions: defaultDomToModelOptions, - }); + const settings = createDomToModelSettings( + { + defaultDomToModelOptions: defaultDomToModelOptions, + }, + [] + ); expect(settings).toEqual({ builtIn: { @@ -77,7 +80,7 @@ describe('createModelToDomSettings', () => { }); it('No options', () => { - const settings = createModelToDomSettings({}); + const settings = createModelToDomSettings({}, []); expect(settings).toEqual({ builtIn: { @@ -102,9 +105,12 @@ describe('createModelToDomSettings', () => { it('Has options', () => { const defaultModelToDomOptions = 'MockedOptions' as any; - const settings = createModelToDomSettings({ - defaultModelToDomOptions: defaultModelToDomOptions, - }); + const settings = createModelToDomSettings( + { + defaultModelToDomOptions: defaultModelToDomOptions, + }, + [] + ); expect(settings).toEqual({ builtIn: { diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts index 187003aa9dc..dd2b35c058c 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts @@ -1,6 +1,23 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { PluginEvent } from '../event/PluginEvent'; import type { IEditor } from './IEditor'; +/** + * Configuration for content model of a plugin + */ +export interface PluginContentModelConfig { + /** + * The option for additional format parses + */ + domToModelOption?: DomToModelOption; + + /** + * The option for additional format appliers + */ + modelToDomOption?: ModelToDomOption; +} + /** * Interface of an editor plugin */ @@ -42,4 +59,10 @@ export interface EditorPlugin { * @param event The event to handle: */ onPluginEvent?: (event: PluginEvent) => void; + + /** + * This configuration will add additional format parses and applier to the editor + * @returns The content model configuration for this plugin + */ + getContentModelConfig?: () => PluginContentModelConfig; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index a0fbcc00d7f..da39c73ac10 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -357,7 +357,7 @@ export { Announce, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; -export { EditorPlugin } from './editor/EditorPlugin'; +export { EditorPlugin, PluginContentModelConfig } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; From 238b4eba4b4b213017c98cd6c1601c8ec05fb602 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 20 Jun 2024 13:36:14 -0300 Subject: [PATCH 22/49] fixes image edit --- .../corePlugin/selection/SelectionPlugin.ts | 7 +- .../lib/imageEdit/ImageEditPlugin.ts | 187 +++++++----------- .../lib/imageEdit/types/ImageEditOptions.ts | 2 +- .../lib/imageEdit/utils/applyChange.ts | 6 - .../lib/parameter/ImageEditor.ts | 7 +- 5 files changed, 75 insertions(+), 134 deletions(-) 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 5a4f19b6bfe..05f0d57c3c4 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -497,11 +497,7 @@ class SelectionPlugin implements PluginWithState { private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { const doc = editor.getDocument(); - let parent = element.parentNode; - if (isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot && parent.parentNode) { - element = parent; - parent = parent.parentNode; - } + const parent = element.parentNode; const index = parent && toArray(parent.childNodes).indexOf(element); if (parent && index !== null && index >= 0) { @@ -522,6 +518,7 @@ class SelectionPlugin implements PluginWithState { private getClickingImage(event: UIEvent): HTMLImageElement | null { const target = event.target as Node; + return isNodeOfType(target, 'ELEMENT_NODE') && isElementOfType(target, 'img') ? target : null; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 486496b78e7..2d058475ff4 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -16,11 +16,11 @@ import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; import type { EditableImageFormat } from './types/EditableImageFormat'; import { - getSelectedSegmentsAndParagraphs, isElementOfType, isModifierKey, isNodeOfType, mutateSegment, + toArray, unwrap, } from 'roosterjs-content-model-dom'; import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; @@ -48,10 +48,9 @@ const DefaultOptions: Partial = { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resizeAndRotate', + onSelectState: ['resize', 'rotate'], }; -const IMAGE_EDIT_CHANGE_SOURCE = 'ImageEdit'; const LEFT_MOUSE_BUTTON = 0; /** @@ -101,10 +100,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.disposer = editor.attachDomEvent({ blur: { beforeDispatch: () => { - this.formatImageWithContentModel( + this.applyFormatWithContentModel( editor, - true /* shouldSelectImage */, - true /* shouldSelectAsImageSelection*/ + editor.getDOMSelection(), + true /* shouldSelectImage */ ); }, }, @@ -163,37 +162,65 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); - if ( (selection && selection.type == 'image' && event.rawEvent.button == LEFT_MOUSE_BUTTON) || this.isEditing ) { - this.selectionChangeHandler(editor, selection); + this.applyFormatWithContentModel(editor, selection, false /* shouldSelectImage */); + } + } + + //Sometimes the cursor can be inside the editing image and inside shadow dom, then the cursor need to moved out of shadow dom + private selectBeforeEditingImage(editor: IEditor, element: HTMLElement) { + let parent = element.parentNode; + if (parent && isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot) { + element = parent; + parent = parent.parentNode; + } + const index = parent && toArray(parent.childNodes).indexOf(element); + if (index !== null && index >= 0 && parent) { + const doc = editor.getDocument(); + const range = doc.createRange(); + range.setStart(parent, index); + range.collapse(); + editor.setDOMSelection({ + type: 'range', + range, + isReverted: false, + }); } } private keyDownHandler(editor: IEditor, event: KeyDownEvent) { if (this.isEditing) { - const selection = editor.getDOMSelection(); - if (!isModifierKey(event.rawEvent)) { - this.selectionChangeHandler(editor, selection); - } else if (selection?.type == 'image') { - this.formatImageWithContentModel( + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } else { + const selection = editor.getDOMSelection(); + const isImageSelection = selection?.type == 'image'; + if (isImageSelection) { + this.selectBeforeEditingImage(editor, selection.image); + } + this.applyFormatWithContentModel( editor, - true /* shouldSelect*/, - true /* shouldSelectAsImageSelection*/ + selection, + isModifierKey(event.rawEvent) && isImageSelection //if it's a modifier key over a image, the image should select the image ); } } } - private selectionChangeHandler(editor: IEditor, selection: DOMSelection | null) { + private applyFormatWithContentModel( + editor: IEditor, + selection: DOMSelection | null, + shouldSelectImage: boolean + ) { editor.formatContentModel(model => { const previousSelectedImage = findEditingImage(model); const editingImage = getSelectedImage(model); - const format = editingImage?.image.format as EditableImageFormat; + const format = editingImage?.image.format as EditableImageFormat | undefined; let result = false; if (previousSelectedImage?.image != editingImage?.image) { @@ -220,6 +247,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wasImageResized || this.isCropMode, clonedImage ); + image.isSelected = shouldSelectImage; + image.isSelectedAsImageSelection = shouldSelectImage; } ); @@ -231,7 +260,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.isEditing = false; this.isCropMode = false; - if (editingImage && !format.isEditing && selection?.type == 'image') { + if ( + editingImage && + (!format || (format && !format.isEditing)) && + selection?.type == 'image' + ) { setIsEditing(editingImage, true); this.isEditing = true; @@ -467,36 +500,33 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.editor.focus(); const selection = this.editor.getDOMSelection(); if (selection?.type == 'image') { - const image = selection.image; - const imageSpan = image.parentElement; - if (imageSpan && imageSpan && isElementOfType(imageSpan, 'span')) { - this.editor.formatContentModel(model => { - const editingImage = getSelectedImage(model); - if (editingImage && editingImage.image && this.editor) { - setIsEditing(editingImage, true); - mutateSegment(editingImage.paragraph, editingImage.image, image => { - this.imageEditInfo = updateImageEditInfo(image, selection.image); - }); - this.isEditing = true; - this.isCropMode = true; - return true; - } - return false; - }); - } + this.editor.formatContentModel(model => { + const editingImage = getSelectedImage(model); + if (editingImage && editingImage.image && this.editor) { + setIsEditing(editingImage, true); + mutateSegment(editingImage.paragraph, editingImage.image, image => { + this.imageEditInfo = updateImageEditInfo(image, selection.image); + }); + this.isEditing = true; + this.isCropMode = true; + return true; + } + return false; + }); } } private editImage( editor: IEditor, image: HTMLImageElement, - apiOperation: ImageEditOperation, + apiOperation: ImageEditOperation[], + selection: DOMSelection | null, operation: (imageEditInfo: ImageMetadataFormat) => void ) { if (this.wrapper && this.selectedImage && this.shadowSpan) { image = this.removeImageWrapper() ?? image; } - this.startEditing(editor, image, [apiOperation]); + this.startEditing(editor, image, apiOperation); if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { return; } @@ -511,11 +541,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.formatImageWithContentModel( - editor, - true /* shouldSelect*/, - true /* shouldSelectAsImageSelection*/ - ); + this.applyFormatWithContentModel(editor, selection, true /* shouldSelect*/); } private cleanInfo() { @@ -536,70 +562,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.croppers = []; } - private formatImageWithContentModel( - editor: IEditor, - shouldSelectImage: boolean, - shouldSelectAsImageSelection: boolean - ) { - if ( - this.lastSrc && - this.selectedImage && - this.imageEditInfo && - this.clonedImage && - this.shadowSpan - ) { - editor.formatContentModel( - model => { - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false - ); - if (!selectedSegmentsAndParagraphs[0]) { - return false; - } - - const segment = selectedSegmentsAndParagraphs[0][0]; - const paragraph = selectedSegmentsAndParagraphs[0][1]; - - if (paragraph && segment.segmentType == 'Image') { - mutateSegment(paragraph, segment, image => { - if ( - this.lastSrc && - this.selectedImage && - this.imageEditInfo && - this.clonedImage - ) { - applyChange( - editor, - this.selectedImage, - image, - this.imageEditInfo, - this.lastSrc, - this.wasImageResized || this.isCropMode, - this.clonedImage - ); - image.isSelected = shouldSelectImage; - image.isSelectedAsImageSelection = shouldSelectAsImageSelection; - (image.format as EditableImageFormat).isEditing = false; - this.isEditing = false; - this.isCropMode = false; - } - }); - return true; - } - - return false; - }, - { - changeSource: IMAGE_EDIT_CHANGE_SOURCE, - onNodeCreated: () => { - this.cleanInfo(); - }, - } - ); - } - } - private removeImageWrapper() { let image: HTMLImageElement | null = null; if (this.shadowSpan && this.shadowSpan.parentElement) { @@ -625,7 +587,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, 'flip', imageEditInfo => { + this.editImage(this.editor, image, ['flip'], selection, imageEditInfo => { const angleRad = imageEditInfo.angleRad || 0; const isInVerticalPostion = (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || @@ -654,7 +616,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, 'rotate', imageEditInfo => { + this.editImage(this.editor, image, [], selection, imageEditInfo => { imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; }); } @@ -662,13 +624,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private editingFormatParser: FormatParser = (format, image) => { const parent = image.parentNode; - if ( - this.isEditing && - parent && - isNodeOfType(parent, 'ELEMENT_NODE') && - isElementOfType(parent, 'span') && - parent.shadowRoot - ) { + if (this.isEditing && parent && isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot) { format.isEditing = true; } }; @@ -681,7 +637,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { isElementOfType(image, 'img') && parent && isNodeOfType(parent, 'ELEMENT_NODE') && - isElementOfType(parent, 'span') && !parent.shadowRoot ) { if (this.isCropMode) { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts index 9aec93b20a1..b0dd532b7a1 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts @@ -61,5 +61,5 @@ export interface ImageEditOptions { * Which operations will be executed when image is selected * @default resizeAndRotate */ - onSelectState?: ImageEditOperation; + onSelectState?: ImageEditOperation[]; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts index 27554ef7faa..afcacbcaaf0 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts @@ -83,11 +83,5 @@ export function applyChange( if (wasResizedOrCropped || state == 'FullyChanged') { contentModelImage.format.width = generatedImageSize.targetWidth + 'px'; contentModelImage.format.height = generatedImageSize.targetHeight + 'px'; - - // Remove width/height style so that it won't affect the image size, since style width/height has higher priority - image.style.removeProperty('width'); - image.style.removeProperty('height'); - image.style.removeProperty('max-width'); - image.style.removeProperty('max-height'); } } diff --git a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts index 357975332b9..127127c849d 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts @@ -20,12 +20,7 @@ export type ImageEditOperation = /** * Flip an image */ - | 'flip' - - /** - * Resize and rotate an image - */ - | 'resizeAndRotate'; + | 'flip'; /** * Define the common operation of an image editor From 9ead25a8423507637e93ab9932a0c0ec15b543de Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 20 Jun 2024 13:45:34 -0300 Subject: [PATCH 23/49] fix test --- .../imageEdit/Rotator/updateRotateHandleTest.ts | 2 +- .../test/imageEdit/utils/createImageWrapperTest.ts | 14 +++++++------- .../imageEdit/utils/getHTMLImageOptionsTest.ts | 4 ++-- .../test/imageEdit/utils/updateWrapperTest.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts index 4deb0b7eb64..94d4691bc95 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts @@ -57,7 +57,7 @@ xdescribe('updateRotateHandlePosition', () => { bottomPercent: 0, angleRad: 0, }; - const { wrapper } = createImageWrapper(editor, image, {}, imageInfo, options, 'rotate'); + const { wrapper } = createImageWrapper(editor, image, {}, imageInfo, options, ['rotate']); const rotateCenter = wrapper.querySelector('.r_rotateC')! as HTMLElement; const rotateHandle = wrapper.querySelector('.r_rotateH')! as HTMLElement; spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index ff8382d7ae8..33ccfa11cee 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -18,7 +18,7 @@ describe('createImageWrapper', () => { options: ImageEditOptions, editInfo: ImageMetadataFormat, htmlOptions: ImageHtmlOptions, - operation: ImageEditOperation | undefined, + operation: ImageEditOperation[], expectResult: WrapperElements ) { const result = createImageWrapper( @@ -45,7 +45,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -69,7 +69,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'resize', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['resize'], { wrapper, shadowSpan, imageClone, @@ -92,7 +92,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'rotate', + onSelectState: ['rotate'], }; const editInfo = { src: 'test', @@ -116,7 +116,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'rotate', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['rotate'], { wrapper: wrapper, shadowSpan: shadowSpan, imageClone: imageClone, @@ -139,7 +139,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -171,7 +171,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'crop', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['crop'], { wrapper, shadowSpan, imageClone, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts index 55381e09dac..0388b02ae3e 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts @@ -29,7 +29,7 @@ describe('getHTMLImageOptions', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }, { src: 'test', @@ -61,7 +61,7 @@ describe('getHTMLImageOptions', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }, { src: 'test', diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts index 65d99d4ddbc..9575c9c8b5b 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -12,7 +12,7 @@ describe('updateWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -41,7 +41,7 @@ describe('updateWrapper', () => { options, editInfo, htmlOptions, - 'resize' + ['resize'] ); editInfo.heightPx = 12; updateWrapper(editInfo, options, image, imageClone, wrapper, resizers); From e6792ec97989b2f075a1a3f2945427524ed5d713 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 20 Jun 2024 14:32:24 -0300 Subject: [PATCH 24/49] test --- .../test/imageEdit/utils/getDropAndDragHelpersTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts index 74f61b59aec..8a7fd5ccfe1 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts @@ -23,7 +23,7 @@ describe('getDropAndDragHelpers', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', From be7889d8ce3ec707715bb8eb6fa108fbc41fd53f Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 20 Jun 2024 11:53:13 -0600 Subject: [PATCH 25/49] Using Tab key on table selects the whole next cell (#2718) * make tab select whole cel * simplify and comment --- .../corePlugin/selection/SelectionPlugin.ts | 32 ++++++++++++++----- .../selection/SelectionPluginTest.ts | 21 ++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) 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..8f45a195a81 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -411,6 +411,7 @@ class SelectionPlugin implements PluginWithState { } let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + let tabMove = false; const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; if (lastCo && tableSel.table == table) { @@ -465,7 +466,13 @@ class SelectionPlugin implements PluginWithState { const cell = parsedTable[row][col]; if (typeof cell != 'string') { - this.setRangeSelectionInTable(cell, 0, this.editor); + tabMove = true; + this.setRangeSelectionInTable( + cell, + 0, + this.editor, + true /* selectAll */ + ); lastCo.row = row; lastCo.col = col; break; @@ -486,20 +493,29 @@ class SelectionPlugin implements PluginWithState { } } - if (!collapsed && lastCo) { + if (!collapsed && lastCo && !tabMove) { this.state.tableSelection = tableSel; this.updateTableSelection(lastCo); } } } - private setRangeSelectionInTable(cell: Node, nodeOffset: number, editor: IEditor) { - // Get deepest editable position in the cell - const { node, offset } = normalizePos(cell, nodeOffset); - + private setRangeSelectionInTable( + cell: Node, + nodeOffset: number, + editor: IEditor, + selectAll?: boolean + ) { const range = editor.getDocument().createRange(); - range.setStart(node, offset); - range.collapse(true /*toStart*/); + if (selectAll) { + range.selectNodeContents(cell); + } else { + // Get deepest editable position in the cell + const { node, offset } = normalizePos(cell, nodeOffset); + + range.setStart(node, offset); + range.collapse(true /* toStart */); + } this.setDOMSelection( { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 86a46a42508..49b4b8ecd8a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1436,11 +1436,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1469,7 +1469,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td2); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); @@ -1506,11 +1507,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1540,7 +1541,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td1); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); @@ -1577,11 +1579,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1610,7 +1612,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td3, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td3); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); From f6ded9333fc7a72b808b00997580cb4f743ee8bc Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 20 Jun 2024 19:45:01 -0300 Subject: [PATCH 26/49] owa test --- .../lib/imageEdit/ImageEditPlugin.ts | 211 ++++++++---------- .../imageEdit/types/EditableImageFormat.ts | 8 - .../lib/imageEdit/utils/findEditingImage.ts | 3 +- .../lib/imageEdit/utils/setIsEditing.ts | 12 - .../lib/index.ts | 1 - .../test/imageEdit/utils/setIsEditingTest.ts | 22 -- 6 files changed, 97 insertions(+), 160 deletions(-) delete mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts delete mode 100644 packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts delete mode 100644 packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 2d058475ff4..3177bed128a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -11,10 +11,9 @@ import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateIma import { ImageEditElementClass } from './types/ImageEditElementClass'; import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; -import { setIsEditing } from './utils/setIsEditing'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; -import type { EditableImageFormat } from './types/EditableImageFormat'; + import { isElementOfType, isModifierKey, @@ -28,10 +27,9 @@ import type { DragAndDropContext } from './types/DragAndDropContext'; import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { + ContentModelImage, DOMSelection, EditorPlugin, - FormatApplier, - FormatParser, IEditor, ImageEditOperation, ImageEditor, @@ -103,6 +101,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.applyFormatWithContentModel( editor, editor.getDOMSelection(), + this.isCropMode, true /* shouldSelectImage */ ); }, @@ -145,21 +144,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } } - getContentModelConfig() { - return { - domToModelOption: { - additionalFormatParsers: { - image: [this.editingFormatParser], - }, - }, - modelToDomOption: { - additionalFormatAppliers: { - image: [this.editingFormatApplier], - }, - }, - }; - } - private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); if ( @@ -168,7 +152,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { event.rawEvent.button == LEFT_MOUSE_BUTTON) || this.isEditing ) { - this.applyFormatWithContentModel(editor, selection, false /* shouldSelectImage */); + this.applyFormatWithContentModel( + editor, + selection, + this.isCropMode, + false /* shouldSelectImage */ + ); } } @@ -206,6 +195,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.applyFormatWithContentModel( editor, selection, + this.isCropMode, isModifierKey(event.rawEvent) && isImageSelection //if it's a modifier key over a image, the image should select the image ); } @@ -215,69 +205,88 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private applyFormatWithContentModel( editor: IEditor, selection: DOMSelection | null, + isCropMode: boolean, shouldSelectImage: boolean ) { - editor.formatContentModel(model => { - const previousSelectedImage = findEditingImage(model); - const editingImage = getSelectedImage(model); - const format = editingImage?.image.format as EditableImageFormat | undefined; - - let result = false; - if (previousSelectedImage?.image != editingImage?.image) { - const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; - if ( - this.isEditing && - previousSelectedImage && - previousSelectedImage.image !== editingImage?.image && - lastSrc && - selectedImage && - imageEditInfo && - clonedImage - ) { - mutateSegment( - previousSelectedImage.paragraph, - previousSelectedImage.image, - image => { - applyChange( - editor, - selectedImage, - image, - imageEditInfo, - lastSrc, - this.wasImageResized || this.isCropMode, - clonedImage - ); - image.isSelected = shouldSelectImage; - image.isSelectedAsImageSelection = shouldSelectImage; - } - ); - - setIsEditing(previousSelectedImage, false); - this.cleanInfo(); - result = true; - } + let editingImageModel: ContentModelImage | undefined; + editor.formatContentModel( + model => { + const previousSelectedImage = findEditingImage(model); + const editingImage = getSelectedImage(model); - this.isEditing = false; - this.isCropMode = false; + let result = false; + if (previousSelectedImage?.image != editingImage?.image) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + this.isEditing && + previousSelectedImage && + previousSelectedImage.image !== editingImage?.image && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + delete image.dataset.isEditing; + image.isSelected = shouldSelectImage; + image.isSelectedAsImageSelection = shouldSelectImage; + } + ); + this.cleanInfo(); + result = true; + } - if ( - editingImage && - (!format || (format && !format.isEditing)) && - selection?.type == 'image' - ) { - setIsEditing(editingImage, true); + this.isEditing = false; + this.isCropMode = false; - this.isEditing = true; - mutateSegment(editingImage.paragraph, editingImage.image, image => { - this.imageEditInfo = updateImageEditInfo(image, selection.image); - }); + if (editingImage && selection?.type == 'image') { + this.isEditing = true; + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.dataset.isEditing = 'true'; + }); - result = true; + result = true; + } } - } - return result; - }); + return result; + }, + { + onNodeCreated: (model, node) => { + if ( + editingImageModel && + editingImageModel == model && + editingImageModel.dataset.isEditing && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node); + } else { + this.startRotateAndResize(editor, node); + } + } + }, + }, + { + tryGetFromCache: true, + } + ); } private startEditing( @@ -323,7 +332,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { public startRotateAndResize(editor: IEditor, image: HTMLImageElement) { if (this.imageEditInfo) { this.startEditing(editor, image, ['resize', 'rotate']); - if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { this.dndHelpers = [ ...getDropAndDragHelpers( @@ -500,19 +508,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.editor.focus(); const selection = this.editor.getDOMSelection(); if (selection?.type == 'image') { - this.editor.formatContentModel(model => { - const editingImage = getSelectedImage(model); - if (editingImage && editingImage.image && this.editor) { - setIsEditing(editingImage, true); - mutateSegment(editingImage.paragraph, editingImage.image, image => { - this.imageEditInfo = updateImageEditInfo(image, selection.image); - }); - this.isEditing = true; - this.isCropMode = true; - return true; - } - return false; - }); + this.applyFormatWithContentModel( + this.editor, + selection, + true /* isCropMode */, + true /* shouldSelectImage */ + ); } } @@ -541,7 +542,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.applyFormatWithContentModel(editor, selection, true /* shouldSelect*/); + this.applyFormatWithContentModel( + editor, + selection, + false /* isCrop */, + true /* shouldSelect*/ + ); } private cleanInfo() { @@ -622,31 +628,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } } - private editingFormatParser: FormatParser = (format, image) => { - const parent = image.parentNode; - if (this.isEditing && parent && isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot) { - format.isEditing = true; - } - }; - - private editingFormatApplier: FormatApplier = (format, image, context) => { - const parent = image.parentNode; - if ( - this.editor && - format.isEditing && - isElementOfType(image, 'img') && - parent && - isNodeOfType(parent, 'ELEMENT_NODE') && - !parent.shadowRoot - ) { - if (this.isCropMode) { - this.startCropMode(this.editor, image); - } else { - this.startRotateAndResize(this.editor, image); - } - } - }; - //EXPOSED FOR TEST ONLY public get isEditingImage() { return this.isEditing; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts deleted file mode 100644 index 5838df9bc87..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/EditableImageFormat.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ContentModelImageFormat } from 'roosterjs-content-model-types'; - -/** - * Type for editable image format - */ -export type EditableImageFormat = ContentModelImageFormat & { - isEditing?: boolean; -}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index c47f810ff05..d6bfbc52c0a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -1,5 +1,4 @@ import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; -import type { EditableImageFormat } from '../types/EditableImageFormat'; import type { ImageAndParagraph } from '../types/ImageAndParagraph'; /** @@ -24,7 +23,7 @@ export function findEditingImage(group: ReadonlyContentModelBlockGroup): ImageAn switch (segment.segmentType) { case 'Image': - if ((segment.format as EditableImageFormat).isEditing) { + if (segment.dataset.isEditing) { return { paragraph: block, image: segment, diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts deleted file mode 100644 index 239b4f53f8b..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/setIsEditing.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mutateSegment } from 'roosterjs-content-model-dom'; -import type { EditableImageFormat } from '../types/EditableImageFormat'; -import type { ImageAndParagraph } from '../types/ImageAndParagraph'; - -/** - * @internal - */ -export function setIsEditing(imageAndParagraph: ImageAndParagraph, isEditing: boolean) { - mutateSegment(imageAndParagraph.paragraph, imageAndParagraph.image, image => { - (image.format as EditableImageFormat).isEditing = isEditing; - }); -} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 9c2afa20fb1..50920741895 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -36,4 +36,3 @@ export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './pick export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; export { ImageEditPlugin } from './imageEdit/ImageEditPlugin'; export { ImageEditOptions } from './imageEdit/types/ImageEditOptions'; -export { EditableImageFormat } from './imageEdit/types/EditableImageFormat'; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts deleted file mode 100644 index 9c8740da1a3..00000000000 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/setIsEditingTest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createImage, createParagraph } from 'roosterjs-content-model-dom'; -import { ImageAndParagraph } from '../../../lib/imageEdit/types/ImageAndParagraph'; -import { setIsEditing } from '../../../lib/imageEdit/utils/setIsEditing'; - -describe('setIsEditing', () => { - function runTest(isEditing: boolean) { - const paragraph = createParagraph(); - const image = createImage('test'); - paragraph.segments.push(image); - const imageAndParagraph: ImageAndParagraph = { paragraph, image }; - setIsEditing(imageAndParagraph, isEditing); - expect((image.format as any).isEditing).toBe(isEditing); - } - - it('setIsEditing true', () => { - runTest(true); - }); - - it('setIsEditing false', () => { - runTest(false); - }); -}); From 9af8335486d40b2f543d36284447e0551fa7a7c2 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 13:24:02 -0300 Subject: [PATCH 27/49] tests --- .../lib/imageEdit/ImageEditPlugin.ts | 24 +++++++++---------- .../lib/imageEdit/utils/findEditingImage.ts | 1 - .../test/imageEdit/ImageEditPluginTest.ts | 8 +++---- .../imageEdit/utils/findEditingImageTest.ts | 22 +++++++++-------- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 3177bed128a..881888ff002 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -49,7 +49,7 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; -const LEFT_MOUSE_BUTTON = 0; +//const LEFT_MOUSE_BUTTON = 0; /** * ImageEdit plugin handles the following image editing features: @@ -146,12 +146,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); - if ( - (selection && - selection.type == 'image' && - event.rawEvent.button == LEFT_MOUSE_BUTTON) || - this.isEditing - ) { + if ((selection && selection.type == 'image') || this.isEditing) { this.applyFormatWithContentModel( editor, selection, @@ -215,12 +210,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { const editingImage = getSelectedImage(model); let result = false; - if (previousSelectedImage?.image != editingImage?.image) { + if ( + shouldSelectImage || + previousSelectedImage?.image != editingImage?.image || + previousSelectedImage?.image.dataset.isEditing + ) { const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( this.isEditing && previousSelectedImage && - previousSelectedImage.image !== editingImage?.image && lastSrc && selectedImage && imageEditInfo && @@ -245,12 +243,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } ); this.cleanInfo(); + this.isEditing = false; + this.isCropMode = false; result = true; } - this.isEditing = false; - this.isCropMode = false; - if (editingImage && selection?.type == 'image') { this.isEditing = true; this.isCropMode = isCropMode; @@ -269,6 +266,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { { onNodeCreated: (model, node) => { if ( + !shouldSelectImage && editingImageModel && editingImageModel == model && editingImageModel.dataset.isEditing && @@ -512,7 +510,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.editor, selection, true /* isCropMode */, - true /* shouldSelectImage */ + false /* shouldSelectImage */ ); } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index d6bfbc52c0a..d4c2351dd75 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -20,7 +20,6 @@ export function findEditingImage(group: ReadonlyContentModelBlockGroup): ImageAn case 'Paragraph': for (let j = 0; j < block.segments.length; j++) { const segment = block.segments[j]; - switch (segment.segmentType) { case 'Image': if (segment.dataset.isEditing) { diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 79ac20ac462..66ac7bee670 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -1,5 +1,4 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { EditableImageFormat } from '../../lib/imageEdit/types/EditableImageFormat'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; import { initEditor } from '../TestHelper'; @@ -57,9 +56,10 @@ describe('ImageEditPlugin', () => { textColor: 'rgb(0, 0, 0)', id: 'image_0', maxWidth: '1800px', - isEditing: true, - } as EditableImageFormat, - dataset: {}, + }, + dataset: { + isEditing: 'true', + }, isSelectedAsImageSelection: true, isSelected: true, }, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts index e03bddc3fa5..c85e55b326c 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts @@ -1,5 +1,4 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { EditableImageFormat } from '../../../lib/imageEdit/types/EditableImageFormat'; import { findEditingImage } from '../../../lib/imageEdit/utils/findEditingImage'; describe('findEditingImage', () => { @@ -43,9 +42,10 @@ describe('findEditingImage', () => { textColor: 'rgb(0, 0, 0)', id: 'image_0', maxWidth: '1800px', - isEditing: true, - } as EditableImageFormat, - dataset: {}, + }, + dataset: { + isEditing: 'true', + }, }, ], format: {}, @@ -74,9 +74,10 @@ describe('findEditingImage', () => { textColor: 'rgb(0, 0, 0)', id: 'image_0', maxWidth: '1800px', - isEditing: true, - } as EditableImageFormat, - dataset: {}, + }, + dataset: { + isEditing: 'true', + }, }, paragraph: { blockType: 'Paragraph', @@ -90,9 +91,10 @@ describe('findEditingImage', () => { textColor: 'rgb(0, 0, 0)', id: 'image_0', maxWidth: '1800px', - isEditing: true, - } as EditableImageFormat, - dataset: {}, + }, + dataset: { + isEditing: 'true', + }, }, ], format: {}, From aa802ab72294f48d4da97bd8ed54b47a490c6246 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 14:25:12 -0300 Subject: [PATCH 28/49] test --- .../lib/imageEdit/ImageEditPlugin.ts | 3 +-- .../test/imageEdit/ImageEditPluginTest.ts | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 881888ff002..9b5323ae05a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -248,7 +248,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { result = true; } - if (editingImage && selection?.type == 'image') { + if (editingImage && selection?.type == 'image' && !shouldSelectImage) { this.isEditing = true; this.isCropMode = isCropMode; mutateSegment(editingImage.paragraph, editingImage.image, image => { @@ -266,7 +266,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { { onNodeCreated: (model, node) => { if ( - !shouldSelectImage && editingImageModel && editingImageModel == model && editingImageModel.dataset.isEditing && diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 66ac7bee670..f34860a7a95 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -96,7 +96,6 @@ describe('ImageEditPlugin', () => { }); expect(plugin.isEditingImage).toBeFalsy(); plugin.dispose(); - editor.dispose(); }); it('mouseUp', () => { @@ -148,7 +147,6 @@ describe('ImageEditPlugin', () => { expect(plugin.isEditingImage).toBeTruthy(); plugin.dispose(); - editor.dispose(); }); it('cropImage', () => { @@ -158,7 +156,6 @@ describe('ImageEditPlugin', () => { plugin.cropImage(); expect(plugin.isEditingImage).toBeTruthy(); plugin.dispose(); - editor.dispose(); }); it('flip', () => { @@ -171,7 +168,6 @@ describe('ImageEditPlugin', () => { const dataset = getSelectedImageMetadata(editor, image); expect(dataset).toBeTruthy(); plugin.dispose(); - editor.dispose(); }); it('rotate', () => { @@ -184,6 +180,5 @@ describe('ImageEditPlugin', () => { const dataset = getSelectedImageMetadata(editor, image); expect(dataset).toBeTruthy(); plugin.dispose(); - editor.dispose(); }); }); From fb13f5c7dcbcdea635eaeb2fb2bda186549196e0 Mon Sep 17 00:00:00 2001 From: Francis Meng Date: Fri, 21 Jun 2024 11:11:36 -0700 Subject: [PATCH 29/49] Expose splittext pi --- packages/roosterjs-content-model-api/lib/index.ts | 1 + .../lib/publicApi/utils}/splitTextSegment.ts | 7 ++++++- .../lib/autoFormat/hyphen/transformHyphen.ts | 2 +- .../lib/autoFormat/numbers/transformFraction.ts | 2 +- .../lib/autoFormat/numbers/transformOrdinals.ts | 2 +- .../lib/markdown/utils/setFormat.ts | 2 +- .../test/pluginUtils/splitTextSegmentTest.ts | 2 +- 7 files changed, 12 insertions(+), 6 deletions(-) rename packages/{roosterjs-content-model-plugins/lib/pluginUtils => roosterjs-content-model-api/lib/publicApi/utils}/splitTextSegment.ts (81%) diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 4209e3a1a5c..f9c82153f78 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -18,6 +18,7 @@ export { setTextColor } from './publicApi/segment/setTextColor'; export { changeFontSize } from './publicApi/segment/changeFontSize'; export { applySegmentFormat } from './publicApi/segment/applySegmentFormat'; export { changeCapitalization } from './publicApi/segment/changeCapitalization'; +export { splitTextSegment } from './publicApi/utils/splitTextSegment'; export { insertImage } from './publicApi/image/insertImage'; export { setListStyle } from './publicApi/list/setListStyle'; export { setListStartNumber } from './publicApi/list/setListStartNumber'; diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/splitTextSegment.ts similarity index 81% rename from packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts rename to packages/roosterjs-content-model-api/lib/publicApi/utils/splitTextSegment.ts index 4f7a034e8ae..b26623d43d2 100644 --- a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/splitTextSegment.ts @@ -5,7 +5,12 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Split given text segments from the given range + * @param textSegment segment to split + * @param parent parent paragraph the text segment exist in + * @param start starting point of the split + * @param end ending point of the split + * @returns text segment from the indicated split. */ export function splitTextSegment( textSegment: ContentModelText, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts index faf8a0bc51e..3d9e6907564 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts index 098fb556b7c..73012fdb174 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, 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 2bef05ac209..49da2c899be 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts index 9d03d41447c..875945c877f 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -1,5 +1,5 @@ import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelCodeFormat, diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts index 5d9363fc413..46e09a6ce2f 100644 --- a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts +++ b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts @@ -1,5 +1,5 @@ import { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; -import { splitTextSegment } from '../../lib/pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; describe('splitTextSegment', () => { function runTest( From f75792f8f96aa5cbaf4942b92616052b06cd3270 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 15:19:58 -0300 Subject: [PATCH 30/49] fix setDomSelection --- .../lib/coreApi/setDOMSelection/setDOMSelection.ts | 2 +- .../coreApi/setDOMSelection/setDOMSelectionTest.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 c8570dd8ee9..39cdd9828be 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -56,7 +56,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - `outline-style:auto!important; outline-color:${ + `outline-style:solid!important; outline-color:${ imageSelectionColor || DEFAULT_SELECTION_BORDER_COLOR }!important;`, [`#${ensureUniqueId(image, IMAGE_ID)}`] diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index ec858920bc3..5ecbc2ffb25 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -314,7 +314,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;', ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -374,7 +374,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:red!important;', + 'outline-style:solid!important; outline-color:red!important;', ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -441,7 +441,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, '_DOMSelection', - 'outline-style:auto!important; outline-color:DarkColorMock-red!important;', + 'outline-style:solid!important; outline-color:DarkColorMock-red!important;', ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -502,7 +502,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;', ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -563,7 +563,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;', ['#image_0_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( From 0059cef89085b1aaca658fa024933a8a03bb6891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 21 Jun 2024 17:34:37 -0300 Subject: [PATCH 31/49] fixes --- .../lib/imageEdit/ImageEditPlugin.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 9b5323ae05a..20a03b4cd29 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -49,8 +49,6 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; -//const LEFT_MOUSE_BUTTON = 0; - /** * ImageEdit plugin handles the following image editing features: * - Resize image @@ -98,12 +96,13 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.disposer = editor.attachDomEvent({ blur: { beforeDispatch: () => { - this.applyFormatWithContentModel( - editor, - editor.getDOMSelection(), - this.isCropMode, - true /* shouldSelectImage */ - ); + if (this.editor) { + this.applyFormatWithContentModel( + this.editor, + this.isCropMode, + true /* shouldSelectImage */ + ); + } }, }, }); @@ -149,7 +148,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if ((selection && selection.type == 'image') || this.isEditing) { this.applyFormatWithContentModel( editor, - selection, this.isCropMode, false /* shouldSelectImage */ ); @@ -189,7 +187,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } this.applyFormatWithContentModel( editor, - selection, this.isCropMode, isModifierKey(event.rawEvent) && isImageSelection //if it's a modifier key over a image, the image should select the image ); @@ -199,11 +196,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private applyFormatWithContentModel( editor: IEditor, - selection: DOMSelection | null, isCropMode: boolean, shouldSelectImage: boolean ) { let editingImageModel: ContentModelImage | undefined; + const selection = editor.getDOMSelection(); editor.formatContentModel( model => { const previousSelectedImage = findEditingImage(model); @@ -502,12 +499,13 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (!this.editor) { return; } - this.editor.focus(); + 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, - selection, true /* isCropMode */, false /* shouldSelectImage */ ); @@ -539,12 +537,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.applyFormatWithContentModel( - editor, - selection, - false /* isCrop */, - true /* shouldSelect*/ - ); + this.applyFormatWithContentModel(editor, false /* isCrop */, true /* shouldSelect*/); } private cleanInfo() { From a2aa16f9d419882b20ba513bc2a5e1b05e97d8d8 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 17:42:41 -0300 Subject: [PATCH 32/49] remove plugin --- .../lib/editor/core/createEditorCore.ts | 33 +++---------------- .../core/createEditorDefaultSettings.ts | 10 +++--- .../test/editor/core/createEditorCoreTest.ts | 4 +-- 3 files changed, 11 insertions(+), 36 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 8e88c134555..7dbea590e45 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -9,8 +9,6 @@ import type { EditorCore, EditorCorePlugins, EditorOptions, - DomToModelOption, - ModelToDomOption, } from 'roosterjs-content-model-types'; /** @@ -20,20 +18,6 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); - const plugins = (options.plugins ?? []).filter(x => !!x); - const domToModelOptions: DomToModelOption[] = []; - const modelToDomOptions: ModelToDomOption[] = []; - - plugins.forEach(plugin => { - const contentModelConfig = plugin.getContentModelConfig?.(); - if (contentModelConfig?.domToModelOption) { - domToModelOptions.push(contentModelConfig.domToModelOption); - } - - if (contentModelConfig?.modelToDomOption) { - modelToDomOptions.push(contentModelConfig.modelToDomOption); - } - }); return { physicalRoot: contentDiv, @@ -47,17 +31,12 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti corePlugins.domEvent, corePlugins.selection, corePlugins.entity, - ...plugins, + ...(options.plugins ?? []).filter(x => !!x), corePlugins.undo, corePlugins.contextMenu, corePlugins.lifecycle, ], - environment: createEditorEnvironment( - contentDiv, - options, - domToModelOptions, - modelToDomOptions - ), + environment: createEditorEnvironment(contentDiv, options), darkColorHandler: createDarkColorHandler( contentDiv, options.getDarkColor ?? getDarkColorFallback, @@ -74,17 +53,15 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti function createEditorEnvironment( contentDiv: HTMLElement, - options: EditorOptions, - domToModelOptionsFromPlugins: (DomToModelOption | undefined)[], - modelToDomOptionsFromPlugins: (ModelToDomOption | undefined)[] + options: EditorOptions ): EditorEnvironment { const navigator = contentDiv.ownerDocument.defaultView?.navigator; const userAgent = navigator?.userAgent ?? ''; const appVersion = navigator?.appVersion ?? ''; return { - domToModelSettings: createDomToModelSettings(options, domToModelOptionsFromPlugins), - modelToDomSettings: createModelToDomSettings(options, modelToDomOptionsFromPlugins), + domToModelSettings: createDomToModelSettings(options), + modelToDomSettings: createModelToDomSettings(options), isMac: appVersion.indexOf('Mac') != -1, isAndroid: /android/i.test(userAgent), isSafari: diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 7ff37067f36..52bd64b886f 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -19,8 +19,7 @@ import type { * @param options The editor options */ export function createDomToModelSettings( - options: EditorOptions, - additionalOptions: (DomToModelOption | undefined)[] + options: EditorOptions ): ContentModelSettings { const builtIn: DomToModelOption = { processorOverride: { @@ -32,7 +31,7 @@ export function createDomToModelSettings( return { builtIn, customized, - calculated: createDomToModelConfig([builtIn, customized, ...additionalOptions]), + calculated: createDomToModelConfig([builtIn, customized]), }; } @@ -42,8 +41,7 @@ export function createDomToModelSettings( * @param options The editor options */ export function createModelToDomSettings( - options: EditorOptions, - additionalOptions: (ModelToDomOption | undefined)[] + options: EditorOptions ): ContentModelSettings { const builtIn: ModelToDomOption = { metadataAppliers: { @@ -56,6 +54,6 @@ export function createModelToDomSettings( return { builtIn, customized, - calculated: createModelToDomConfig([builtIn, customized, ...additionalOptions]), + calculated: createModelToDomConfig([builtIn, customized]), }; } diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index e42951811ee..b4af3645965 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -108,8 +108,8 @@ describe('createEditorCore', () => { options, contentDiv ); - expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options, []); - expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options, []); + expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options); + expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options); } it('No options', () => { From f2ae07052813134f584f9e96f36ccc72f80fcf23 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 18:28:11 -0300 Subject: [PATCH 33/49] fix test --- .../core/createEditorDefaultSettingsTest.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index b6923e13cd9..2c9a69aab21 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -20,7 +20,7 @@ describe('createDomToModelSettings', () => { }); it('No options', () => { - const settings = createDomToModelSettings({}, []); + const settings = createDomToModelSettings({}); expect(settings).toEqual({ builtIn: { @@ -43,12 +43,9 @@ describe('createDomToModelSettings', () => { it('Has options', () => { const defaultDomToModelOptions = 'MockedOptions' as any; - const settings = createDomToModelSettings( - { - defaultDomToModelOptions: defaultDomToModelOptions, - }, - [] - ); + const settings = createDomToModelSettings({ + defaultDomToModelOptions: defaultDomToModelOptions, + }); expect(settings).toEqual({ builtIn: { @@ -80,7 +77,7 @@ describe('createModelToDomSettings', () => { }); it('No options', () => { - const settings = createModelToDomSettings({}, []); + const settings = createModelToDomSettings({}); expect(settings).toEqual({ builtIn: { @@ -105,12 +102,9 @@ describe('createModelToDomSettings', () => { it('Has options', () => { const defaultModelToDomOptions = 'MockedOptions' as any; - const settings = createModelToDomSettings( - { - defaultModelToDomOptions: defaultModelToDomOptions, - }, - [] - ); + const settings = createModelToDomSettings({ + defaultModelToDomOptions: defaultModelToDomOptions, + }); expect(settings).toEqual({ builtIn: { From f73b8fb50bb067f1501391b01f047f77699847ad Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 18:35:14 -0300 Subject: [PATCH 34/49] remove interface --- .../lib/editor/EditorPlugin.ts | 23 ------------------- .../lib/index.ts | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts index dd2b35c058c..187003aa9dc 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorPlugin.ts @@ -1,23 +1,6 @@ -import type { DomToModelOption } from '../context/DomToModelOption'; -import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { PluginEvent } from '../event/PluginEvent'; import type { IEditor } from './IEditor'; -/** - * Configuration for content model of a plugin - */ -export interface PluginContentModelConfig { - /** - * The option for additional format parses - */ - domToModelOption?: DomToModelOption; - - /** - * The option for additional format appliers - */ - modelToDomOption?: ModelToDomOption; -} - /** * Interface of an editor plugin */ @@ -59,10 +42,4 @@ export interface EditorPlugin { * @param event The event to handle: */ onPluginEvent?: (event: PluginEvent) => void; - - /** - * This configuration will add additional format parses and applier to the editor - * @returns The content model configuration for this plugin - */ - getContentModelConfig?: () => PluginContentModelConfig; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index da39c73ac10..a0fbcc00d7f 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -357,7 +357,7 @@ export { Announce, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; -export { EditorPlugin, PluginContentModelConfig } from './editor/EditorPlugin'; +export { EditorPlugin } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; From 907a4d7d2766c1adea15d283403b2ee6cec87a71 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 21 Jun 2024 19:44:28 -0300 Subject: [PATCH 35/49] focus node --- .../lib/corePlugin/selection/SelectionPlugin.ts | 2 +- .../test/corePlugin/selection/SelectionPluginTest.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 475d32c5664..edfe61b8b33 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -595,7 +595,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 (newSelection?.type == 'image' && selection) { + if (newSelection?.type == 'image' && selection && selection.focusNode) { if (selection && !isSingleImageInSelection(selection)) { const range = selection.getRangeAt(0); this.editor.setDOMSelection({ diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 5740644f8dd..70a9201a132 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -2456,6 +2456,9 @@ describe('SelectionPlugin selectionChange on image selected', () => { addEventListenerSpy = jasmine.createSpy('addEventListener'); getRangeAtSpy = jasmine.createSpy('getRangeAt'); getSelectionSpy = jasmine.createSpy('getSelection').and.returnValue({ + focusNode: { + nodeName: 'SPAN', + }, getRangeAt: getRangeAtSpy, }); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ @@ -2513,7 +2516,7 @@ describe('SelectionPlugin selectionChange on image selected', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: { startContainer: {} } as Range, - isReverted: false, + isReverted: true, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); From 81b54565a8f0b87a8e549a8a2b096b7f3df4e120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 24 Jun 2024 10:23:17 -0300 Subject: [PATCH 36/49] merge if statement --- .../corePlugin/selection/SelectionPlugin.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 edfe61b8b33..867b0790cfc 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -595,17 +595,20 @@ 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 (newSelection?.type == 'image' && selection && selection.focusNode) { - if (selection && !isSingleImageInSelection(selection)) { - const range = selection.getRangeAt(0); - this.editor.setDOMSelection({ - type: 'range', - range, - isReverted: - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset, - }); - } + if ( + newSelection?.type == 'image' && + selection && + selection.focusNode && + !isSingleImageInSelection(selection) + ) { + const range = selection.getRangeAt(0); + this.editor.setDOMSelection({ + type: 'range', + range, + isReverted: + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset, + }); } // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. From 307b9b37350cb71812ac2d2df4a414f16049bdc7 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 24 Jun 2024 14:55:57 -0300 Subject: [PATCH 37/49] fix context menu --- .../corePlugin/selection/SelectionPlugin.ts | 12 +- .../selection/SelectionPluginTest.ts | 32 +++++ .../lib/imageEdit/ImageEditPlugin.ts | 40 ++++-- .../test/imageEdit/ImageEditPluginTest.ts | 117 ++++++++++++++++++ 4 files changed, 187 insertions(+), 14 deletions(-) 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 475d32c5664..c401e370c02 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -25,6 +25,7 @@ import type { } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; +const MouseRightButton = 2; const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; @@ -163,12 +164,17 @@ class SelectionPlugin implements PluginWithState { let image: HTMLImageElement | null; // Image selection - if (selection?.type == 'image' && rawEvent.button == MouseLeftButton) { + if ( + selection?.type == 'image' && + (rawEvent.button == MouseLeftButton || + (rawEvent.button == MouseRightButton && + !this.getClickingImage(rawEvent) && + !this.getContainedTargetImage(rawEvent, selection))) + ) { this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); } if ( - rawEvent.button === MouseLeftButton && (image = this.getClickingImage(rawEvent) ?? this.getContainedTargetImage(rawEvent, selection)) && @@ -549,7 +555,7 @@ class SelectionPlugin implements PluginWithState { if (!this.isMac || !previousSelection || previousSelection.type !== 'image') { return null; } - + console.log('getContainedTargetImage', event); const target = event.target as Node; if ( isNodeOfType(target, 'ELEMENT_NODE') && diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 5740644f8dd..db895a770f9 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -419,6 +419,38 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); + it('Image selection, mouse down with right click to div', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + createRangeSpy.and.returnValue(mockedRange); + + const node = document.createElement('div'); + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + target: node, + button: 2, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + }); + it('Image selection, mouse down to div, no parent of image', () => { const mockedImage = { parentNode: { childNodes: [] }, diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 20a03b4cd29..fdfcbbd56a2 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -28,7 +28,6 @@ import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { ContentModelImage, - DOMSelection, EditorPlugin, IEditor, ImageEditOperation, @@ -49,6 +48,8 @@ const DefaultOptions: Partial = { onSelectState: ['resize', 'rotate'], }; +const MouseRightButton = 2; + /** * ImageEdit plugin handles the following image editing features: * - Resize image @@ -143,14 +144,31 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } } + private isImageSelection(target: Node) { + if (isNodeOfType(target, 'ELEMENT_NODE')) { + if (isElementOfType(target, 'img')) { + return true; + } + if ( + isElementOfType(target, 'span') && + target.firstElementChild && + isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(target.firstElementChild, 'img') + ) { + return true; + } + return false; + } + return false; + } + private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { const selection = editor.getDOMSelection(); if ((selection && selection.type == 'image') || this.isEditing) { - this.applyFormatWithContentModel( - editor, - this.isCropMode, - false /* shouldSelectImage */ - ); + const shouldSelectImage = + this.isImageSelection(event.rawEvent.target as Node) && + event.rawEvent.button === MouseRightButton; + this.applyFormatWithContentModel(editor, this.isCropMode, shouldSelectImage); } } @@ -240,11 +258,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } ); this.cleanInfo(); - this.isEditing = false; - this.isCropMode = false; result = true; } + this.isEditing = false; + this.isCropMode = false; + if (editingImage && selection?.type == 'image' && !shouldSelectImage) { this.isEditing = true; this.isCropMode = isCropMode; @@ -516,7 +535,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editor: IEditor, image: HTMLImageElement, apiOperation: ImageEditOperation[], - selection: DOMSelection | null, operation: (imageEditInfo: ImageMetadataFormat) => void ) { if (this.wrapper && this.selectedImage && this.shadowSpan) { @@ -583,7 +601,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, ['flip'], selection, imageEditInfo => { + this.editImage(this.editor, image, ['flip'], imageEditInfo => { const angleRad = imageEditInfo.angleRad || 0; const isInVerticalPostion = (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || @@ -612,7 +630,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, [], selection, imageEditInfo => { + this.editImage(this.editor, image, [], imageEditInfo => { imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; }); } diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index f34860a7a95..8ab5af40299 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -149,6 +149,123 @@ describe('ImageEditPlugin', () => { plugin.dispose(); }); + it('mouseUp - left click - remove selection', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: false, + isSelected: false, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + + it('mouseUp - right click - remove wrapper', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + target: { + tagName: 'IMG', + } as any, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 2, + target: { + tagName: 'IMG', + nodeType: 1, + } as any, + } as any, + }); + + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + it('cropImage', () => { const plugin = new ImageEditPlugin(); const editor = initEditor('image_edit', [plugin], model); From 9294930e329f9fb623faeab97c2f22d624c16276 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 24 Jun 2024 15:02:40 -0300 Subject: [PATCH 38/49] remove console.log --- .../lib/corePlugin/selection/SelectionPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c401e370c02..50193063eb4 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -555,7 +555,7 @@ class SelectionPlugin implements PluginWithState { if (!this.isMac || !previousSelection || previousSelection.type !== 'image') { return null; } - console.log('getContainedTargetImage', event); + const target = event.target as Node; if ( isNodeOfType(target, 'ELEMENT_NODE') && From 69b4c28c6d5990e1c386283ed87d5bd72d43dfe4 Mon Sep 17 00:00:00 2001 From: Francis Meng Date: Mon, 24 Jun 2024 15:50:35 -0700 Subject: [PATCH 39/49] fix build --- .../lib/markdown/utils/setFormat.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts index 875945c877f..3205c5ce8b0 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -1,6 +1,7 @@ -import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; -import { splitTextSegment } from 'roosterjs-content-model-api'; - +import { + formatTextSegmentBeforeSelectionMarker, + splitTextSegment, +} from 'roosterjs-content-model-api'; import type { ContentModelCodeFormat, ContentModelSegmentFormat, From bddd1d907855e860df45a67ae3bac6efa50fecf5 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Tue, 25 Jun 2024 08:59:35 -0600 Subject: [PATCH 40/49] Only dismiss the Table Mover if the end of the drag is not in the Table Mover div #2727 --- .../lib/tableEdit/editors/TableEditor.ts | 6 +- .../tableEdit/editors/features/TableMover.ts | 12 ++-- .../test/tableEdit/tableMoverTest.ts | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts index ed296dc3900..818f11056c5 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts @@ -379,8 +379,10 @@ export class TableEditor { this.editor.takeSnapshot(); } - private onEndTableMove = () => { - this.disposeTableMover(); + private onEndTableMove = (disposeHandler: boolean) => { + if (disposeHandler) { + this.disposeTableMover(); + } return this.onFinishEditing(); }; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts index 6cef76ca3a3..0585e056fac 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -44,7 +44,7 @@ export function createTableMover( isRTL: boolean, onFinishDragging: (table: HTMLTableElement) => void, onStart: () => void, - onEnd: () => void, + onEnd: (disposeHandler: boolean) => void, contentDiv?: EventTarget | null, anchorContainer?: HTMLElement, onTableEditorCreated?: OnTableEditorCreatedCallback, @@ -118,7 +118,7 @@ export interface TableMoverContext { div: HTMLElement; onFinishDragging: (table: HTMLTableElement) => void; onStart: () => void; - onEnd: () => void; + onEnd: (disposeHandler: boolean) => void; disableMovement?: boolean; } @@ -298,9 +298,9 @@ export function onDragEnd( setTableMoverCursor(editor, false); if (element == context.div) { - // Table mover was only clicked, select whole table + // Table mover was only clicked, select whole table and do not dismiss the handler element. selectWholeTable(table); - context.onEnd(); + context.onEnd(false /* disposeHandler */); return true; } else { // Check if table was dragged on itself, element is not in editor, or movement is disabled @@ -310,7 +310,7 @@ export function onDragEnd( disableMovement ) { editor.setDOMSelection(initValue?.initialSelection ?? null); - context.onEnd(); + context.onEnd(true /* disposeHandler */); return false; } @@ -376,7 +376,7 @@ export function onDragEnd( // No movement, restore initial selection editor.setDOMSelection(initValue?.initialSelection ?? null); } - context.onEnd(); + context.onEnd(true /* disposeHandler */); return insertionSuccess; } } diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts index 481444eedb3..a2da136c503 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -365,6 +365,70 @@ describe('Table Mover Tests', () => { expect(parseFloat(divRect.style.left)).toBeGreaterThan(0); }); + it('Do not dismiss the TableMover if only clicking the handler element', () => { + //Act + const table = document.createElement('table'); + const div = document.createElement('div'); + const onFinishDragging = jasmine.createSpy('onFinishDragging'); + const onStart = jasmine.createSpy('onStart'); + const onEnd = jasmine.createSpy('onEnd'); + + const context: TableMoverContext = { + table, + zoomScale: 1, + rect: null, + isRTL: true, + editor, + div, + onFinishDragging, + onStart, + onEnd, + disableMovement: false, + }; + + onDragEnd( + context, + { + target: div, + }, + undefined + ); + + expect(onEnd).toHaveBeenCalledWith(false); + }); + + it('Dismiss the TableMover if drag end did not end in the handler element', () => { + //Act + const table = document.createElement('table'); + const div = document.createElement('div'); + const onFinishDragging = jasmine.createSpy('onFinishDragging'); + const onStart = jasmine.createSpy('onStart'); + const onEnd = jasmine.createSpy('onEnd'); + + const context: TableMoverContext = { + table, + zoomScale: 1, + rect: null, + isRTL: true, + editor, + div, + onFinishDragging, + onStart, + onEnd, + disableMovement: false, + }; + + onDragEnd( + context, + { + target: table, + }, + undefined + ); + + expect(onEnd).toHaveBeenCalledWith(true); + }); + describe('Move - onDragEnd', () => { let target: HTMLTableElement; const nodeHeight = 300; From e0247fdf9607708cc6e3e115d36580ebab61264c Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 26 Jun 2024 12:29:04 -0300 Subject: [PATCH 41/49] fixes --- .../lib/imageEdit/ImageEditPlugin.ts | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index fdfcbbd56a2..2bc6ec437ad 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -98,11 +98,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { blur: { beforeDispatch: () => { if (this.editor) { - this.applyFormatWithContentModel( - this.editor, - this.isCropMode, - true /* shouldSelectImage */ - ); + // this.applyFormatWithContentModel( + // this.editor, + // this.isCropMode, + // true /* shouldSelectImage */ + // ); } }, }, @@ -145,21 +145,16 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private isImageSelection(target: Node) { - if (isNodeOfType(target, 'ELEMENT_NODE')) { - if (isElementOfType(target, 'img')) { - return true; - } - if ( - isElementOfType(target, 'span') && - target.firstElementChild && - isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && - isElementOfType(target.firstElementChild, 'img') - ) { - return true; - } - return false; - } - return false; + 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) { @@ -215,24 +210,28 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private applyFormatWithContentModel( editor: IEditor, isCropMode: boolean, - shouldSelectImage: boolean + shouldSelectImage: boolean, + isApiOperation?: boolean ) { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); editor.formatContentModel( model => { - const previousSelectedImage = findEditingImage(model); const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model); let result = false; if ( shouldSelectImage || previousSelectedImage?.image != editingImage?.image || - previousSelectedImage?.image.dataset.isEditing + previousSelectedImage?.image.dataset.isEditing || + isApiOperation ) { const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; if ( - this.isEditing && + (this.isEditing || isApiOperation) && previousSelectedImage && lastSrc && selectedImage && @@ -264,7 +263,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.isEditing = false; this.isCropMode = false; - if (editingImage && selection?.type == 'image' && !shouldSelectImage) { + if ( + editingImage && + selection?.type == 'image' && + !shouldSelectImage && + !isApiOperation + ) { this.isEditing = true; this.isCropMode = isCropMode; mutateSegment(editingImage.paragraph, editingImage.image, image => { @@ -282,6 +286,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { { onNodeCreated: (model, node) => { if ( + !isApiOperation && editingImageModel && editingImageModel == model && editingImageModel.dataset.isEditing && @@ -391,8 +396,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.options, this.selectedImage, this.clonedImage, - this.wrapper, - this.rotators + this.wrapper ); this.updateRotateHandleState( editor, @@ -537,9 +541,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { apiOperation: ImageEditOperation[], operation: (imageEditInfo: ImageMetadataFormat) => void ) { - if (this.wrapper && this.selectedImage && this.shadowSpan) { - image = this.removeImageWrapper() ?? image; - } this.startEditing(editor, image, apiOperation); if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { return; @@ -555,7 +556,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.applyFormatWithContentModel(editor, false /* isCrop */, true /* shouldSelect*/); + this.applyFormatWithContentModel( + editor, + false /* isCrop */, + true /* shouldSelect*/, + true /* isApiOperation */ + ); } private cleanInfo() { From d780d5c1c9786d5b7ec2c82534e3563e6039de93 Mon Sep 17 00:00:00 2001 From: Rain-Zheng <67583056+Rain-Zheng@users.noreply.github.com> Date: Thu, 27 Jun 2024 00:18:33 +0800 Subject: [PATCH 42/49] Add option for Tab Key handling in EditPlugin (#2729) EditPlugin overrides the default behavior of Tab key by increasing the margin-left value of current line. As a result, user can't navigate to next focusable element by pressing Tab key, so we need an option for disabling this behavior. --------- Co-authored-by: Bryan Valverde U --- .../lib/edit/EditPlugin.ts | 28 ++++++++++++++++++- .../lib/index.ts | 2 +- .../test/edit/EditPluginTest.ts | 17 +++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index add783a2ab1..8a688f07590 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -11,9 +11,26 @@ import type { PluginEvent, } from 'roosterjs-content-model-types'; +/** + * Options to customize the keyboard handling behavior of Edit plugin + */ +export type EditOptions = { + /** + * Whether to handle Tab key in keyboard. @default true + */ + handleTabKey?: boolean; +} + const BACKSPACE_KEY = 8; const DELETE_KEY = 46; +/** + * @internal + */ +const DefaultOptions: Partial = { + handleTabKey: true, +}; + /** * Edit plugins helps editor to do editing operation on top of content model. * This includes: @@ -28,6 +45,12 @@ export class EditPlugin implements EditorPlugin { private selectionAfterDelete: DOMSelection | null = null; private handleNormalEnter = false; + /** + * @param options An optional parameter that takes in an object of type EditOptions, which includes the following properties: + * handleTabKey: A boolean that enables or disables Tab key handling. Defaults to true. + */ + constructor(private options: EditOptions = DefaultOptions) {} + /** * Get name of this plugin */ @@ -98,6 +121,7 @@ export class EditPlugin implements EditorPlugin { willHandleEventExclusively(event: PluginEvent) { if ( this.editor && + this.options.handleTabKey && event.eventType == 'keyDown' && event.rawEvent.key == 'Tab' && !event.rawEvent.shiftKey @@ -148,7 +172,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Tab': - keyboardTab(editor, rawEvent); + if (this.options.handleTabKey) { + keyboardTab(editor, rawEvent); + } break; case 'Unidentified': if (editor.getEnvironment().isAndroid) { diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 50920741895..e00daab314d 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -2,7 +2,7 @@ export { TableEditPlugin } from './tableEdit/TableEditPlugin'; export { OnTableEditorCreatedCallback } from './tableEdit/OnTableEditorCreatedCallback'; export { TableEditFeatureName } from './tableEdit/editors/features/TableEditFeatureName'; export { PastePlugin } from './paste/PastePlugin'; -export { EditPlugin } from './edit/EditPlugin'; +export { EditPlugin, EditOptions } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; export { diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 9296ddbc17a..bbe1a0acb9b 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -123,6 +123,23 @@ describe('EditPlugin', () => { expect(keyboardEnterSpy).not.toHaveBeenCalled(); }); + it('Tab, Tab handling not enabled', () => { + plugin = new EditPlugin({ handleTabKey: false }); + const rawEvent = { key: 'Tab' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardTabSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + }); + it('Enter, normal enter not enabled', () => { plugin = new EditPlugin(); const rawEvent = { which: 13, key: 'Enter' } as any; From cc9acfa8bb8b6c7c7fb2fd7f2831e6c0d8b8ec14 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 26 Jun 2024 13:20:27 -0300 Subject: [PATCH 43/49] uncomment --- .../lib/imageEdit/ImageEditPlugin.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 2bc6ec437ad..e729ca92a14 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -98,11 +98,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { blur: { beforeDispatch: () => { if (this.editor) { - // this.applyFormatWithContentModel( - // this.editor, - // this.isCropMode, - // true /* shouldSelectImage */ - // ); + this.applyFormatWithContentModel( + this.editor, + this.isCropMode, + true /* shouldSelectImage */ + ); } }, }, From cb9f1b256fb9bc7f636e07a2731aac1127c932de Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 26 Jun 2024 13:53:22 -0300 Subject: [PATCH 44/49] check shift --- .../lib/imageEdit/ImageEditPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index e729ca92a14..ba856beb358 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -201,7 +201,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.applyFormatWithContentModel( editor, this.isCropMode, - isModifierKey(event.rawEvent) && isImageSelection //if it's a modifier key over a image, the image should select the image + (isModifierKey(event.rawEvent) || event.rawEvent.shiftKey) && isImageSelection //if it's a modifier key over a image, the image should select the image ); } } From b2d5f2f88ebfa37426c192ace54fd8bf9856b2e5 Mon Sep 17 00:00:00 2001 From: Francis Meng Date: Wed, 26 Jun 2024 13:51:59 -0700 Subject: [PATCH 45/49] Update --- packages/roosterjs-content-model-api/lib/index.ts | 2 +- .../lib/publicApi/{utils => segment}/splitTextSegment.ts | 0 .../test/publicApi/segment}/splitTextSegmentTest.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/roosterjs-content-model-api/lib/publicApi/{utils => segment}/splitTextSegment.ts (100%) rename packages/{roosterjs-content-model-plugins/test/pluginUtils => roosterjs-content-model-api/test/publicApi/segment}/splitTextSegmentTest.ts (96%) diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index f9c82153f78..ffcaca8e65a 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -18,7 +18,7 @@ export { setTextColor } from './publicApi/segment/setTextColor'; export { changeFontSize } from './publicApi/segment/changeFontSize'; export { applySegmentFormat } from './publicApi/segment/applySegmentFormat'; export { changeCapitalization } from './publicApi/segment/changeCapitalization'; -export { splitTextSegment } from './publicApi/utils/splitTextSegment'; +export { splitTextSegment } from './publicApi/segment/splitTextSegment'; export { insertImage } from './publicApi/image/insertImage'; export { setListStyle } from './publicApi/list/setListStyle'; export { setListStartNumber } from './publicApi/list/setListStartNumber'; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/splitTextSegment.ts b/packages/roosterjs-content-model-api/lib/publicApi/segment/splitTextSegment.ts similarity index 100% rename from packages/roosterjs-content-model-api/lib/publicApi/utils/splitTextSegment.ts rename to packages/roosterjs-content-model-api/lib/publicApi/segment/splitTextSegment.ts diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts similarity index 96% rename from packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts rename to packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts index 46e09a6ce2f..3d1b4779059 100644 --- a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts @@ -1,5 +1,5 @@ import { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; -import { splitTextSegment } from 'roosterjs-content-model-api'; +import { splitTextSegment } from '../../../lib/publicApi/segment/splitTextSegment'; describe('splitTextSegment', () => { function runTest( From a66addd2f94d5e8e788ab1ef96a481f5b3bbd43f Mon Sep 17 00:00:00 2001 From: Francis Meng Date: Wed, 26 Jun 2024 15:18:30 -0700 Subject: [PATCH 46/49] consolidate dependencies --- .../lib/autoFormat/link/createLinkAfterSpace.ts | 3 +-- .../lib/picker/getQueryString.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 91d293953ac..95898e30d08 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,5 +1,4 @@ -import { matchLink } from 'roosterjs-content-model-api'; -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { matchLink, splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts index c52ddc76e9e..5e156477737 100644 --- a/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts +++ b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, ShallowMutableContentModelParagraph, From c7a24abdf914352cf5861a1c104608bd54ef5d63 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Wed, 26 Jun 2024 20:08:10 -0600 Subject: [PATCH 47/49] Add Handle Tab Key setting to demo site #2730 --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 3 ++- .../sidePane/editorOptions/EditorOptionsPlugin.ts | 3 +++ .../controlsV2/sidePane/editorOptions/OptionState.ts | 8 +++++++- .../sidePane/editorOptions/OptionsPane.tsx | 1 + .../controlsV2/sidePane/editorOptions/Plugins.tsx | 12 +++++++++++- .../lib/edit/EditPlugin.ts | 5 +---- 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 94132a551d4..22296506b4d 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -496,10 +496,11 @@ export class MainPane extends React.Component<{}, MainPaneState> { autoFormatOptions, linkTitle, customReplacements, + editPluginOptions, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), - pluginList.edit && new EditPlugin(), + pluginList.edit && new EditPlugin(editPluginOptions), pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 53df2b411aa..a6d4c5af32a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -52,6 +52,9 @@ const initialState: OptionState = { strikethrough: true, codeFormat: {}, }, + editPluginOptions: { + handleTabKey: true, + }, customReplacements: emojiReplacements, experimentalFeatures: new Set(['PersistCache']), }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 8283c17f874..dbf2a967302 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,4 +1,9 @@ -import { AutoFormatOptions, CustomReplace, MarkdownOptions } from 'roosterjs-content-model-plugins'; +import { + AutoFormatOptions, + CustomReplace, + EditOptions, + MarkdownOptions, +} from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types'; @@ -31,6 +36,7 @@ export interface OptionState { autoFormatOptions: AutoFormatOptions; markdownOptions: MarkdownOptions; customReplacements: CustomReplace[]; + editPluginOptions: EditOptions; // Legacy plugin options defaultFormat: ContentModelSegmentFormat; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 6e775fa3a14..951aa42d49f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -140,6 +140,7 @@ export class OptionsPane extends React.Component { markdownOptions: { ...this.state.markdownOptions }, customReplacements: this.state.customReplacements, experimentalFeatures: this.state.experimentalFeatures, + editPluginOptions: { ...this.state.editPluginOptions }, }; if (callback) { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index be1b559a310..86ebfe53fdf 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -98,6 +98,7 @@ abstract class PluginsBase extends Re export class Plugins extends PluginsBase { private allowExcelNoBorderTable = React.createRef(); + private handleTabKey = React.createRef(); private listMenu = React.createRef(); private tableMenu = React.createRef(); private imageMenu = React.createRef(); @@ -167,7 +168,16 @@ export class Plugins extends PluginsBase { )} )} - {this.renderPluginItem('edit', 'Edit')} + {this.renderPluginItem( + 'edit', + 'Edit', + this.renderCheckBox( + 'Handle Tab Key', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey, + (state, value) => (state.editPluginOptions.handleTabKey = value) + ) + )} {this.renderPluginItem( 'paste', 'Paste', diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 8a688f07590..82bf6f54b52 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -19,14 +19,11 @@ export type EditOptions = { * Whether to handle Tab key in keyboard. @default true */ handleTabKey?: boolean; -} +}; const BACKSPACE_KEY = 8; const DELETE_KEY = 46; -/** - * @internal - */ const DefaultOptions: Partial = { handleTabKey: true, }; From a72d4f2a42942c263641de844bad6a050c7b4214 Mon Sep 17 00:00:00 2001 From: Roma Shah Date: Fri, 28 Jun 2024 14:42:47 -0700 Subject: [PATCH 48/49] Bump the main version to 9.7 --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index d89a4fc5514..d141a99fdb6 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.0", - "main": "9.6.0", + "main": "9.7.0", "legacyAdapter": "8.62.1", "overrides": { "roosterjs-content-model-plugins": "9.6.1" From afe0070f80ebba62d97eb13c8d42c5b6c74d0a25 Mon Sep 17 00:00:00 2001 From: Roma Shah Date: Fri, 28 Jun 2024 15:53:46 -0700 Subject: [PATCH 49/49] remove the overides --- versions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/versions.json b/versions.json index d141a99fdb6..ab18c15499d 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,5 @@ { "react": "9.0.0", "main": "9.7.0", - "legacyAdapter": "8.62.1", - "overrides": { - "roosterjs-content-model-plugins": "9.6.1" - } + "legacyAdapter": "8.62.1" }