From 73809cb301a57b0f9b928235f0f45d1509689b1c Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Mon, 30 Sep 2024 09:27:28 -0600 Subject: [PATCH 01/25] Add a ID to the TempDiv, to be able to identify on blur was caused for copy/cut #2813 --- .../lib/corePlugin/copyPaste/CopyPastePlugin.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index b0fd0c791d9..62ba6d1248e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -33,6 +33,8 @@ import type { ReadonlyTableSelectionContext, } from 'roosterjs-content-model-types'; +const TEMP_DIV_ID = 'roosterJS_copyCutTempDiv'; + /** * Copy and paste plugin for handling onCopy and onPaste event */ @@ -235,6 +237,7 @@ class CopyPastePlugin implements PluginWithState { div.childNodes.forEach(node => div.removeChild(node)); div.style.display = ''; + div.id = TEMP_DIV_ID; div.focus(); return div; From 1fa94594f8723d98d808ec8778a6d351c20e6fc5 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 30 Sep 2024 18:01:24 -0300 Subject: [PATCH 02/25] delete image range --- .../lib/imageEdit/ImageEditPlugin.ts | 5 +- .../test/imageEdit/ImageEditPluginTest.ts | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 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 2c9529e995a..b7da590e5da 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -191,10 +191,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { + const target = event.rawEvent.target as Node; if ( this.isEditing && - this.isImageSelection(event.rawEvent.target as Node) && - event.rawEvent.button !== MouseRightButton + event.rawEvent.button !== MouseRightButton && + !(target.contains(this.shadowSpan) || target.contains(this.wrapper)) ) { this.applyFormatWithContentModel( editor, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 1d8108ae389..0eb788dfff9 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -1,6 +1,7 @@ import * as findImage from '../../lib/imageEdit/utils/findEditingImage'; import * as getSelectedImage from '../../lib/imageEdit/utils/getSelectedImage'; import { ChangeSource, createImage, createParagraph } from 'roosterjs-content-model-dom'; +import { contains } from 'roosterjs-editor-dom'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; import { initEditor } from '../TestHelper'; @@ -390,6 +391,96 @@ describe('ImageEditPlugin', () => { plugin.dispose(); }); + it('mouseDown on non edit image', () => { + const mockedImage = { + getAttribute: getAttributeSpy, + }; + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: new Image(), + } as any, + }); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + plugin.dispose(); + }); + + it('mouseDown on text', () => { + const mockedImage = { + getAttribute: getAttributeSpy, + }; + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: { + button: 0, + target: new Image(), + } as any, + }); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: {} as any, + }); + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: new Text(), + } as any, + }); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + plugin.dispose(); + }); + + it('mouseDown on same line', () => { + const target = { + contains: () => true, + }; + const mockedImage = { + getAttribute: getAttributeSpy, + }; + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: { + button: 0, + target: new Image(), + } as any, + }); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: {} as any, + }); + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: target, + } as any, + }); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + plugin.dispose(); + }); + it('dragImage', () => { const mockedImage = { id: 'image_0', From 4c69cb90411568aad3457be4fe223c240b373c39 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 30 Sep 2024 18:46:40 -0300 Subject: [PATCH 03/25] remove selection --- .../lib/imageEdit/ImageEditPlugin.ts | 5 ++++- 1 file changed, 4 insertions(+), 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 b7da590e5da..d5ddd7321c5 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -195,7 +195,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if ( this.isEditing && event.rawEvent.button !== MouseRightButton && - !(target.contains(this.shadowSpan) || target.contains(this.wrapper)) + !( + this.shadowSpan !== event.rawEvent.target && + (target.contains(this.shadowSpan) || target.contains(this.wrapper)) + ) ) { this.applyFormatWithContentModel( editor, From 78e94ee473062486810c220ff0540a3643613f6e Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 30 Sep 2024 18:58:24 -0300 Subject: [PATCH 04/25] fix build --- .../test/imageEdit/ImageEditPluginTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 0eb788dfff9..d703f83fe1f 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -1,7 +1,6 @@ import * as findImage from '../../lib/imageEdit/utils/findEditingImage'; import * as getSelectedImage from '../../lib/imageEdit/utils/getSelectedImage'; import { ChangeSource, createImage, createParagraph } from 'roosterjs-content-model-dom'; -import { contains } from 'roosterjs-editor-dom'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; import { initEditor } from '../TestHelper'; From 764748bc9ac5fea0b2da699fa36d7649937f6a7e Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 1 Oct 2024 10:53:59 -0300 Subject: [PATCH 05/25] WIP --- .../lib/imageEdit/ImageEditPlugin.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index d5ddd7321c5..11af8345f1a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -30,6 +30,7 @@ import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { ContentModelImage, + DOMInsertPoint, EditorPlugin, IEditor, ImageEditOperation, @@ -191,19 +192,23 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - const target = event.rawEvent.target as Node; - if ( - this.isEditing && - event.rawEvent.button !== MouseRightButton && - !( - this.shadowSpan !== event.rawEvent.target && - (target.contains(this.shadowSpan) || target.contains(this.wrapper)) - ) - ) { + if (this.isEditing && event.rawEvent.button !== MouseRightButton) { + const target = event.rawEvent.target as Node; + const isClickOnImage = this.shadowSpan === target; + let insertPoint: DOMInsertPoint | undefined = undefined; + if ( + target.contains(this.shadowSpan) && + !isClickOnImage && + isNodeOfType(target, 'ELEMENT_NODE') + ) { + console.log('isClickOnImage', target.offsetLeft); + } this.applyFormatWithContentModel( editor, this.isCropMode, - this.shadowSpan === event.rawEvent.target + isClickOnImage, + false /* isApiOperation */, + insertPoint ); } } @@ -255,7 +260,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editor: IEditor, isCropMode: boolean, shouldSelectImage: boolean, - isApiOperation?: boolean + isApiOperation?: boolean, + newInsertPoint?: DOMInsertPoint ) { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); From 46e343f049030473af668a820cb56ee83dbd0a00 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 1 Oct 2024 11:56:05 -0300 Subject: [PATCH 06/25] clean info when delete image --- .../lib/imageEdit/ImageEditPlugin.ts | 16 +++++++-- .../test/imageEdit/ImageEditPluginTest.ts | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 2c9529e995a..fb3a13ef529 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -234,8 +234,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private keyDownHandler(editor: IEditor, event: KeyDownEvent) { if (this.isEditing) { - if (event.rawEvent.key === 'Escape') { - this.removeImageWrapper(); + if ( + event.rawEvent.key === 'Escape' || + event.rawEvent.key === 'Delete' || + event.rawEvent.key === 'Backspace' + ) { + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } + this.cleanInfo(); } else { this.applyFormatWithContentModel( editor, @@ -610,7 +617,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ); } - private cleanInfo() { + /** + * Exported for testing purpose only + */ + public cleanInfo() { this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); this.selectedImage = null; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 1d8108ae389..c9a6851b20d 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -180,6 +180,40 @@ describe('ImageEditPlugin', () => { plugin.dispose(); }); + it('keyDown - DELETE', () => { + const mockedImage = { + getAttribute: getAttributeSpy, + }; + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + const cleanInfoSpy = spyOn(plugin, 'cleanInfo'); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + const image = createImage(''); + const paragraph = createParagraph(); + paragraph.segments.push(image); + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: { + button: 0, + target: mockedImage, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'Delete', + target: mockedImage, + } as any, + }); + expect(cleanInfoSpy).toHaveBeenCalled(); + expect(cleanInfoSpy).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + plugin.dispose(); + }); + it('mouseUp', () => { const mockedImage = { getAttribute: getAttributeSpy, From 78ba62f36534b5792e588a877f02f9f15402bb07 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 1 Oct 2024 14:23:50 -0300 Subject: [PATCH 07/25] WIP --- .../lib/imageEdit/ImageEditPlugin.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 11af8345f1a..4265207f53f 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -14,7 +14,6 @@ import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; - import { ChangeSource, getSafeIdSelector, @@ -30,7 +29,6 @@ import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { ContentModelImage, - DOMInsertPoint, EditorPlugin, IEditor, ImageEditOperation, @@ -192,23 +190,19 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - if (this.isEditing && event.rawEvent.button !== MouseRightButton) { - const target = event.rawEvent.target as Node; - const isClickOnImage = this.shadowSpan === target; - let insertPoint: DOMInsertPoint | undefined = undefined; - if ( - target.contains(this.shadowSpan) && - !isClickOnImage && - isNodeOfType(target, 'ELEMENT_NODE') - ) { - console.log('isClickOnImage', target.offsetLeft); - } + const target = event.rawEvent.relatedTarget as Node; + const isClickOnImage = this.shadowSpan === target; + if ( + this.isEditing && + event.rawEvent.button !== MouseRightButton && + target?.contains(this.shadowSpan) && + !isClickOnImage + ) { this.applyFormatWithContentModel( editor, this.isCropMode, isClickOnImage, - false /* isApiOperation */, - insertPoint + false /* isApiOperation */ ); } } @@ -260,8 +254,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { editor: IEditor, isCropMode: boolean, shouldSelectImage: boolean, - isApiOperation?: boolean, - newInsertPoint?: DOMInsertPoint + isApiOperation?: boolean ) { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); From d9c9b037cc40949c727d35d8943b5bd99efd914f Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 1 Oct 2024 16:41:27 -0300 Subject: [PATCH 08/25] WIP --- .../lib/imageEdit/ImageEditPlugin.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 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 4265207f53f..5b6dd9b896a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -17,6 +17,7 @@ import { updateWrapper } from './utils/updateWrapper'; import { ChangeSource, getSafeIdSelector, + getSelectedParagraphs, isElementOfType, isNodeOfType, mutateBlock, @@ -190,12 +191,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - const target = event.rawEvent.relatedTarget as Node; + const target = event.rawEvent.target as Node; const isClickOnImage = this.shadowSpan === target; if ( this.isEditing && event.rawEvent.button !== MouseRightButton && - target?.contains(this.shadowSpan) && + !target?.contains(this.shadowSpan) && !isClickOnImage ) { this.applyFormatWithContentModel( @@ -297,6 +298,16 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image.isSelected = shouldSelectImage; image.isSelectedAsImageSelection = shouldSelectImage; delete image.dataset.isEditing; + + if (selection?.type == 'range' && !selection.range.collapsed) { + const selectedParagraphs = getSelectedParagraphs(model, true); + const isImageInRange = selectedParagraphs.some(paragraph => + paragraph.segments.includes(image) + ); + if (isImageInRange) { + image.isSelected = true; + } + } } ); From 9db6c8d682c18594b7fcb19029d6dbfae6a09919 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 4 Oct 2024 18:44:44 -0300 Subject: [PATCH 09/25] add unit tests --- .../lib/imageEdit/ImageEditPlugin.ts | 8 +- .../test/imageEdit/ImageEditPluginTest.ts | 1298 ++++++++++++++++- 2 files changed, 1261 insertions(+), 45 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 7f70bd861ec..7f3c9a65a03 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -258,7 +258,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } } - private applyFormatWithContentModel( + /** + * EXPOSED FOR TESTING PURPOSE ONLY + */ + protected applyFormatWithContentModel( editor: IEditor, isCropMode: boolean, shouldSelectImage: boolean, @@ -266,6 +269,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ) { let editingImageModel: ContentModelImage | undefined; const selection = editor.getDOMSelection(); + editor.formatContentModel( model => { const editingImage = getSelectedImage(model); @@ -273,6 +277,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ? editingImage : findEditingImage(model); let result = false; + if ( shouldSelectImage || previousSelectedImage?.image != editingImage?.image || @@ -328,6 +333,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.isEditing = false; this.isCropMode = false; + if ( editingImage && selection?.type == 'image' && diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 48d364728b3..a50f94fcf00 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -1,5 +1,7 @@ +import * as applyChange from '../../lib/imageEdit/utils/applyChange'; import * as findImage from '../../lib/imageEdit/utils/findEditingImage'; import * as getSelectedImage from '../../lib/imageEdit/utils/getSelectedImage'; +import * as normalizeImageSelection from '../../lib/imageEdit/utils/normalizeImageSelection'; import { ChangeSource, createImage, createParagraph } from 'roosterjs-content-model-dom'; import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; @@ -12,55 +14,54 @@ import { IEditor, } from 'roosterjs-content-model-types'; -const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - segments: [ - { - isSelected: true, - segmentType: 'SelectionMarker', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - }, - }, - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - isSelectedAsImageSelection: true, - segmentType: 'Image', - isSelected: true, - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1123px', +describe('ImageEditPlugin', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, }, - dataset: { - isEditing: 'true', + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1123px', + }, + dataset: { + isEditing: 'true', + }, }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', }, - ], - segmentFormat: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', + blockType: 'Paragraph', + format: {}, }, - blockType: 'Paragraph', - format: {}, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', }, - ], - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: '#000000', - }, -}; - -describe('ImageEditPlugin', () => { + }; let editor: IEditor; let mockedEnvironment: EditorEnvironment; let attachDomEventSpy: jasmine.Spy; @@ -612,3 +613,1212 @@ describe('ImageEditPlugin', () => { expect(editor.setEditorStyle).toHaveBeenCalledWith('imageEditCaretColor', null); }); }); + +class TestPlugin extends ImageEditPlugin { + public setIsEditing(isEditing: boolean) { + this.isEditing = isEditing; + } + + public setEditingInfo(image: HTMLImageElement) { + this.imageEditInfo = { + src: image.getAttribute('src') || '', + widthPx: image.clientWidth, + heightPx: image.clientHeight, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }; + } + + public applyFormatWithContentModel( + editor: IEditor, + isCropMode: boolean, + shouldSelectImage: boolean, + isApiOperation?: boolean + ) { + super.applyFormatWithContentModel(editor, isCropMode, shouldSelectImage, isApiOperation); + } +} + +interface TestOptions { + setImageStyle?: boolean; + removeImageStyle?: boolean; + shouldApplyChange?: boolean; + shouldNormalizeSelection?: boolean; + shouldCleanInfo?: boolean; +} + +describe('ImageEditPlugin - applyFormatWithContentModel', () => { + function runTest( + model: ContentModelDocument, + expectedModel: ContentModelDocument, + isEditing: boolean, + isCropMode: boolean, + shouldSelectImage: boolean, + isApiOperation: boolean, + moreOptions: Partial = {} + ) { + const plugin = new TestPlugin(); + let editor: IEditor | null = initEditor('image_edit', [plugin], model); + const setEditorStyleSpy = spyOn(editor, 'setEditorStyle'); + setEditorStyleSpy.and.callThrough(); + const cleanInfoSpy = spyOn(plugin, 'cleanInfo').and.callThrough(); + const applyChangeSpy = spyOn(applyChange, 'applyChange'); + const normalizeImageSelectionSpy = spyOn( + normalizeImageSelection, + 'normalizeImageSelection' + ); + plugin.initialize(editor); + const mockedImage = document.createElement('img'); + document.body.appendChild(mockedImage); + mockedImage.src = 'test'; + plugin.setEditingInfo(mockedImage); + plugin.startRotateAndResize(editor, mockedImage); + plugin.setIsEditing(isEditing); + plugin.applyFormatWithContentModel(editor, isCropMode, shouldSelectImage, isApiOperation); + const newModel = editor.getContentModelCopy('disconnected'); + + if (moreOptions.removeImageStyle !== undefined) { + expect(setEditorStyleSpy).toHaveBeenCalledWith('imageEdit', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith('imageEditCaretColor', null); + } + + if (moreOptions.setImageStyle !== undefined) { + expect(setEditorStyleSpy).toHaveBeenCalledWith( + 'imageEdit', + 'outline-style:none!important;', + [`span:has(>img)`] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + 'imageEditCaretColor', + 'caret-color: transparent;' + ); + } + + if (moreOptions.shouldApplyChange) { + expect(applyChangeSpy).toHaveBeenCalled(); + } else { + expect(applyChangeSpy).not.toHaveBeenCalled(); + } + + if (moreOptions.shouldNormalizeSelection) { + expect(normalizeImageSelectionSpy).toHaveBeenCalled(); + } else { + expect(normalizeImageSelectionSpy).not.toHaveBeenCalled(); + } + if (moreOptions.shouldCleanInfo) { + expect(cleanInfoSpy).toHaveBeenCalled(); + } else { + expect(cleanInfoSpy).not.toHaveBeenCalled(); + } + expect(newModel).toEqual(expectedModel); + plugin.dispose(); + editor.dispose(); + editor = null; + } + + it('image to text', () => { + const model1: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + alt: undefined, + title: undefined, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + + format: {}, + }; + runTest(model1, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + removeImageStyle: true, + }); + }); + + it('text to image', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + alt: undefined, + title: undefined, + isSelectedAsImageSelection: true, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + + format: {}, + }; + runTest(testModel, expectedModel, false, false, false, false, { + shouldApplyChange: false, + shouldCleanInfo: false, + setImageStyle: true, + }); + }); + + it('image to image', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + }, + { + src: 'test2', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + alt: undefined, + title: undefined, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + }, + { + src: 'test2', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: true, + isSelected: true, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + + format: {}, + }; + runTest(testModel, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + removeImageStyle: true, + setImageStyle: true, + }); + }); + + it('image in a table to text', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [122], + rows: [ + { + height: 24, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + isSelectedAsImageSelection: undefined, + segmentType: 'Image', + isSelected: undefined, + format: {}, + dataset: {}, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: { + width: '120px', + height: '22px', + }, + dataset: {}, + isSelected: undefined, + cachedElement: undefined, + }, + ], + format: {}, + cachedElement: undefined, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + cachedElement: undefined, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + removeImageStyle: true, + }); + }); + + it('text to image in a table ', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [122], + rows: [ + { + height: 24, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: {}, + dataset: { + isEditing: 'true', + }, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: { + width: '120px', + height: '22px', + }, + dataset: {}, + isSelected: undefined, + cachedElement: undefined, + }, + ], + format: {}, + cachedElement: undefined, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + cachedElement: undefined, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, false, false, false, false, { + shouldApplyChange: false, + shouldCleanInfo: false, + setImageStyle: true, + }); + }); + + it('image to image in a table ', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + }, + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [122], + rows: [ + { + height: 24, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: 'test', + isSelectedAsImageSelection: undefined, + segmentType: 'Image', + isSelected: undefined, + format: {}, + dataset: {}, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: { + width: '120px', + height: '22px', + }, + dataset: {}, + isSelected: undefined, + cachedElement: undefined, + }, + ], + format: {}, + cachedElement: undefined, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: {}, + cachedElement: undefined, + }, + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: true, + isSelected: true, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + setImageStyle: true, + removeImageStyle: true, + }); + }); + + it('image in a list to text', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + cachedElement: undefined, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + removeImageStyle: true, + }); + }); + + it('text to image in a list', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: true, + isSelected: true, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + cachedElement: undefined, + }, + { + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, false, false, false, false, { + shouldApplyChange: false, + shouldCleanInfo: false, + setImageStyle: true, + }); + }); + + it('image in a list to image', () => { + const testModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: {}, + isSelectedAsImageSelection: undefined, + isSelected: undefined, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + cachedElement: undefined, + }, + { + segments: [ + { + src: 'test', + segmentType: 'Image', + format: {}, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: true, + isSelected: true, + alt: undefined, + title: undefined, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }; + runTest(testModel, expectedModel, true, false, false, false, { + shouldApplyChange: true, + shouldCleanInfo: true, + setImageStyle: true, + removeImageStyle: true, + }); + }); +}); From c2dc635755a1800b81ace37a9e460bd7c932a982 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 4 Oct 2024 19:11:54 -0300 Subject: [PATCH 10/25] nit --- .../lib/imageEdit/ImageEditPlugin.ts | 10 +++------- .../test/imageEdit/ImageEditPluginTest.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 7f3c9a65a03..c3c1db860c5 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -191,19 +191,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private mouseDownHandler(editor: IEditor, event: MouseDownEvent) { - const target = event.rawEvent.target as Node; - const isClickOnImage = this.shadowSpan === target; if ( this.isEditing && - event.rawEvent.button !== MouseRightButton && - !target?.contains(this.shadowSpan) && - !isClickOnImage + this.isImageSelection(event.rawEvent.target as Node) && + event.rawEvent.button !== MouseRightButton ) { this.applyFormatWithContentModel( editor, this.isCropMode, - isClickOnImage, - false /* isApiOperation */ + this.shadowSpan === event.rawEvent.target ); } } diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index a50f94fcf00..d00cc2cde4a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -475,7 +475,7 @@ describe('ImageEditPlugin', () => { } as any, }); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); plugin.dispose(); }); From 2d90d5cd677c3f060f7ba146c8c7489af7f246c9 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 4 Oct 2024 21:10:32 -0700 Subject: [PATCH 11/25] Remove style "white-space" from empty paragraph (#2820) * Remove white-space style for empty paragraph * improve --- .../lib/modelApi/common/normalizeParagraph.ts | 14 +++++++++ .../modelApi/common/normalizeParagraphTest.ts | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index c970442bb8e..c86efc34dae 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -38,6 +38,8 @@ export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { mutateBlock(paragraph).segments.pop(); } } + + normalizeParagraphStyle(paragraph); } if (!isWhiteSpacePreserved(paragraph.format.whiteSpace)) { @@ -51,6 +53,18 @@ export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { moveUpSegmentFormat(paragraph); } +function normalizeParagraphStyle(paragraph: ReadonlyContentModelParagraph) { + // New paragraph should not have white-space style + if ( + paragraph.format.whiteSpace && + paragraph.segments.every( + seg => seg.segmentType == 'Br' || seg.segmentType == 'SelectionMarker' + ) + ) { + delete mutateBlock(paragraph).format.whiteSpace; + } +} + function removeEmptySegments(block: ReadonlyContentModelParagraph) { for (let j = block.segments.length - 1; j >= 0; j--) { if (isSegmentEmpty(block.segments[j])) { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index ca033384432..1de5d7c3e3b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -809,4 +809,34 @@ describe('Move up format', () => { cachedElement: mockedCache, }); }); + + it('Empty paragraph has white-space style', () => { + const para = createParagraph(false, { whiteSpace: 'pre-wrap' }); + const br = createBr(); + + para.segments.push(br); + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [br], + format: {}, + }); + }); + + it('Paragraph has content and white-space style', () => { + const para = createParagraph(false, { whiteSpace: 'pre-wrap' }); + const text = createText('test'); + + para.segments.push(text); + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [text], + format: { whiteSpace: 'pre-wrap' }, + }); + }); }); From 3415d5a3c05a6e2f9b0e532a6605dd2b8927454d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 4 Oct 2024 21:22:14 -0700 Subject: [PATCH 12/25] Fix #2804 (#2821) --- .../lib/corePlugin/cache/domIndexerImpl.ts | 12 ++++ .../corePlugin/cache/domIndexerImplTest.ts | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 3887b42dbbb..001b80a0fde 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -1,5 +1,7 @@ import { EmptySegmentFormat, + addCode, + addLink, createParagraph, createSelectionMarker, createText, @@ -448,6 +450,16 @@ export class DomIndexerImpl implements DomIndexer { const marker = createSelectionMarker(first.format); newSegments.push(marker); + if (startOffset < (textNode.nodeValue ?? '').length) { + if (first.link) { + addLink(marker, first.link); + } + + if (first.code) { + addCode(marker, first.code); + } + } + selectable = marker; endOffset = startOffset; } else if (endOffset > startOffset) { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index 378a7addb88..85c4310d0b6 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -12,6 +12,7 @@ import { import { CacheSelection, ContentModelDocument, + ContentModelLink, ContentModelSegment, DOMSelection, } from 'roosterjs-content-model-types'; @@ -934,6 +935,63 @@ describe('domIndexerImpl.reconcileSelection', () => { ], }); }); + + it('Existing text has link', () => { + const node = document.createTextNode('test') as any; + const newRangeEx: DOMSelection = { + type: 'range', + range: createRange(node, 2), + isReverted: false, + }; + const link: ContentModelLink = { + dataset: {}, + format: { + href: 'test', + }, + }; + const paragraph = createParagraph(); + const segment = createText('', {}, link); + + paragraph.segments.push(segment); + domIndexerImpl.onSegment(node, paragraph, [segment]); + + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); + + const segment1: ContentModelSegment = { + segmentType: 'Text', + text: 'te', + format: {}, + link, + }; + const segment2: ContentModelSegment = { + segmentType: 'Text', + text: 'st', + format: {}, + link, + }; + + expect(result).toBeTrue(); + expect(node.__roosterjsContentModel).toEqual({ + paragraph, + segments: [segment1, segment2], + }); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [ + segment1, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + link, + }, + segment2, + ], + }); + expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); + }); }); describe('domIndexerImpl.reconcileChildList', () => { From e9ec464b72ac16dfeb452dc8135203fcd363cfe1 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 8 Oct 2024 14:04:05 -0300 Subject: [PATCH 13/25] touch images --- .../lib/imageEdit/ImageEditPlugin.ts | 10 +++++++--- .../lib/imageEdit/utils/getDropAndDragHelpers.ts | 6 ++++-- .../test/imageEdit/utils/getDropAndDragHelpersTest.ts | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index c3c1db860c5..554941a44f2 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -420,6 +420,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { if (this.imageEditInfo) { this.startEditing(editor, image, ['resize', 'rotate']); if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + const isMobileOrTable = !!editor.getEnvironment().isMobileOrTablet; this.dndHelpers = [ ...getDropAndDragHelpers( this.wrapper, @@ -445,7 +446,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wasImageResized = true; } }, - this.zoomScale + this.zoomScale, + isMobileOrTable ), ...getDropAndDragHelpers( this.wrapper, @@ -476,7 +478,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ); } }, - this.zoomScale + this.zoomScale, + isMobileOrTable ), ]; @@ -571,7 +574,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.isCropMode = true; } }, - this.zoomScale + this.zoomScale, + !!editor.getEnvironment().isMobileOrTablet ), ]; updateWrapper( diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts index 4e99265fba1..ae4b83d29d4 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts @@ -16,7 +16,8 @@ export function getDropAndDragHelpers( elementClass: ImageEditElementClass, helper: DragAndDropHandler, updateWrapper: (context: DragAndDropContext, _handle: HTMLElement) => void, - zoomScale: number + zoomScale: number, + useTouch: boolean ): DragAndDropHelper[] { return getEditElements(wrapper, elementClass).map( element => @@ -31,7 +32,8 @@ export function getDropAndDragHelpers( }, updateWrapper, helper, - zoomScale + zoomScale, + useTouch ) ); } 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 8a7fd5ccfe1..cdb23cae1a3 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts @@ -54,7 +54,8 @@ describe('getDropAndDragHelpers', () => { elementClass, helper, () => {}, - 1 + 1, + false ); expect(JSON.stringify(result)).toEqual(JSON.stringify(expectResult)); } From 10ee478b6c8be172cb28b34f96648134edc2503c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Oct 2024 13:36:38 -0700 Subject: [PATCH 14/25] Do not treat image without src as empty image (#2823) --- .../roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts index ae399702e2d..93e356c6b13 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts @@ -56,9 +56,6 @@ export function isSegmentEmpty(segment: ReadonlyContentModelSegment): boolean { case 'Text': return !segment.text; - case 'Image': - return !segment.src; - default: return false; } From 7b0efbb94c5ad888daecf8eb72e756fd8376065c Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 14 Oct 2024 17:42:10 -0300 Subject: [PATCH 15/25] auto format when tab --- .../lib/autoFormat/AutoFormatPlugin.ts | 25 +++ .../autoFormat/list/keyboardListTrigger.ts | 1 + .../test/autoFormat/AutoFormatPluginTest.ts | 160 ++++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 81fb2695c0e..dfc33ad6199 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -208,6 +208,31 @@ export class AutoFormatPlugin implements EditorPlugin { unlink(editor, rawEvent); } break; + case 'Tab': + formatTextSegmentBeforeSelectionMarker( + editor, + (model, _previousSegment, paragraph, _markerFormat, context) => { + const { autoBullet, autoNumbering } = this.options; + let shouldList = false; + if (autoBullet || autoNumbering) { + shouldList = keyboardListTrigger( + model, + paragraph, + context, + autoBullet, + autoNumbering + ); + context.canUndoByBackspace = shouldList; + event.rawEvent.preventDefault(); + } + + return shouldList; + }, + { + changeSource: ChangeSource.AutoFormat, + apiName: 'autoToggleList', + } + ); } } } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts index a3d707c1f7a..8a97273d341 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -60,6 +60,7 @@ const triggerList = ( } ); }; + function setAnnounceData(model: ReadonlyContentModelDocument, context: FormatContentModelContext) { const [paragraphOrListItems] = getOperationalBlocks( model, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index e799ed4441b..45d5c236d56 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -167,6 +167,166 @@ describe('Content Model Auto Format Plugin Test', () => { }); }); + describe('onPluginEvent - [TAB] - keyboardListTrigger', () => { + function runTest( + event: KeyDownEvent, + testBullet: boolean, + expectResult: boolean, + options?: AutoFormatOptions + ) { + const plugin = new AutoFormatPlugin(options); + plugin.initialize(editor); + + plugin.onPluginEvent(event); + + const formatOptions = { + apiName: '', + }; + + const inputModel = (bullet: boolean): ContentModelDocument => ({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: bullet ? '*' : '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + _previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + const result = keyboardListTrigger( + inputModel(testBullet), + paragraph, + context, + options!.autoBullet, + options!.autoNumbering + ); + expect(result).toBe(expectResult); + const preventDefaultSpy = spyOn(event.rawEvent, 'preventDefault'); + if (result) { + expect(context.canUndoByBackspace).toBe(true); + expect(preventDefaultSpy).toHaveBeenCalled(); + } else { + expect(context.canUndoByBackspace).toBe(false); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + } + formatOptions.apiName = result ? 'autoToggleList' : ''; + return result; + } + ); + expect(options).toEqual({ + changeSource: 'AutoFormat', + apiName: formatOptions.apiName, + }); + }); + } + + it('[TAB] should trigger keyboardListTrigger bullet ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + handledByEditFeature: false, + } as any, + }; + runTest(event, true, true, { autoBullet: true, autoNumbering: false }); + }); + + it('[TAB] should trigger keyboardListTrigger numbering ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + handledByEditFeature: false, + } as any, + }; + runTest(event, false, true, { autoBullet: true, autoNumbering: true }); + }); + + it('[TAB] should not trigger keyboardListTrigger numbering ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + handledByEditFeature: false, + } as any, + }; + runTest(event, false, false, { autoBullet: true, autoNumbering: false }); + }); + + it('[TAB] should not trigger keyboardListTrigger bullet ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + handledByEditFeature: false, + } as any, + }; + runTest(event, true, false, { autoBullet: false, autoNumbering: false }); + }); + + it('[TAB] should not trigger keyboardListTrigger - not tab ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Ctrl', + defaultPrevented: false, + handledByEditFeature: false, + } as any, + }; + runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + }); + + it('[TAB] should not trigger keyboardListTrigger - default prevented ', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: true, + handledByEditFeature: false, + } as any, + }; + runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + }); + + it('[TAB] should not trigger keyboardListTrigger - handledByEditFeature', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + handledByEditFeature: true, + } as any, + }; + runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + }); + }); + describe('onPluginEvent - createLink', () => { let createLinkSpy: jasmine.Spy; From 8ea655fd97024d04a233f2ce2fe58a48cb01d0a7 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 14 Oct 2024 17:53:43 -0300 Subject: [PATCH 16/25] nit --- .../lib/autoFormat/list/keyboardListTrigger.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts index 8a97273d341..a3d707c1f7a 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -60,7 +60,6 @@ const triggerList = ( } ); }; - function setAnnounceData(model: ReadonlyContentModelDocument, context: FormatContentModelContext) { const [paragraphOrListItems] = getOperationalBlocks( model, From 35a9e157d3f614cc7ddfd10bf15af26064b622bd Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 15 Oct 2024 11:53:43 -0300 Subject: [PATCH 17/25] autoformat links --- .../autoFormat/numbers/transformOrdinals.ts | 36 +++- .../numbers/transformOrdinalsTest.ts | 174 ++++++++++++++++++ 2 files changed, 203 insertions(+), 7 deletions(-) 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 7726b5592b3..e00a9863316 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,5 @@ import { splitTextSegment } from 'roosterjs-content-model-api'; + import type { ContentModelText, FormatContentModelContext, @@ -14,6 +15,8 @@ const getOrdinal = (value: number) => { return ORDINALS[value] || 'th'; }; +const ORDINALS = ['st', 'nd', 'rd', 'th']; + /** * The two last characters of ordinal number (st, nd, rd, th) */ @@ -27,10 +30,30 @@ const ORDINAL_LENGTH = 2; context: FormatContentModelContext ): boolean { const value = previousSegment.text.split(' ').pop()?.trim(); + let shouldAddSuperScript = false; if (value) { - const ordinal = value.substring(value.length - ORDINAL_LENGTH); // This value is equal st, nd, rd, th - const numericValue = getNumericValue(value); //This is the numeric part. Ex: 10th, numeric value = 10 - if (numericValue && getOrdinal(numericValue) === ordinal) { + const isOrdinal = ORDINALS.indexOf(value) > -1; + if (isOrdinal) { + const index = paragraph.segments.indexOf(previousSegment); + const numberSegment = paragraph.segments[index - 1]; + let numericValue: number | null = null; + if ( + numberSegment && + numberSegment.segmentType == 'Text' && + (numericValue = getNumericValue(numberSegment.text, true /* checkFullText */)) && + getOrdinal(numericValue) === value + ) { + shouldAddSuperScript = true; + } + } else { + const ordinal = value.substring(value.length - ORDINAL_LENGTH); // This value is equal st, nd, rd, th + const numericValue = getNumericValue(value); //This is the numeric part. Ex: 10th, numeric value = + if (numericValue && getOrdinal(numericValue) === ordinal) { + shouldAddSuperScript = true; + } + } + + if (shouldAddSuperScript) { const ordinalSegment = splitTextSegment( previousSegment, paragraph, @@ -40,14 +63,13 @@ const ORDINAL_LENGTH = 2; ordinalSegment.format.superOrSubScriptSequence = 'super'; context.canUndoByBackspace = true; - return true; } } - return false; + return shouldAddSuperScript; } -function getNumericValue(text: string) { - const number = text.substring(0, text.length - ORDINAL_LENGTH); +function getNumericValue(text: string, checkFullText = false): number | null { + const number = checkFullText ? text : text.substring(0, text.length - ORDINAL_LENGTH); const isNumber = /^-?\d+$/.test(number); if (isNumber) { return parseInt(text); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts index f5ffeda194d..5a45d57cb7f 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts @@ -183,4 +183,178 @@ describe('transformOrdinals', () => { }; runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); }); + + it('link - 1st', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '1', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'st', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('link - 2nd', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '2', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'nd', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('link - 3rd', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '3', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'rd', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('link - 4th', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '4', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('link - 123th', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '123', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('link - 3th', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '3', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + it('link - 24e5th', () => { + const link: ContentModelText = { + segmentType: 'Text', + text: '24e5', + link: { + dataset: {}, + format: { + href: 'http://www.bing.com', + }, + }, + format: {}, + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [link, segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); }); From cc71ca36553f63c13b6fadd52a113953a2c8ee15 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 15 Oct 2024 11:59:45 -0300 Subject: [PATCH 18/25] build --- .../lib/autoFormat/numbers/transformOrdinals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e00a9863316..3985c363b34 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts @@ -68,7 +68,7 @@ const ORDINAL_LENGTH = 2; return shouldAddSuperScript; } -function getNumericValue(text: string, checkFullText = false): number | null { +function getNumericValue(text: string, checkFullText: boolean = false): number | null { const number = checkFullText ? text : text.substring(0, text.length - ORDINAL_LENGTH); const isNumber = /^-?\d+$/.test(number); if (isNumber) { From 78a0bf2bcbe1c5e893175df36c967edde226ecce Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 21 Oct 2024 12:04:17 -0700 Subject: [PATCH 19/25] Fix #2835 Ignore HTML align when there is CSS text-align (#2836) * Fix #2835 Ignore HTML align when there is CSS text-align * improve --- .../block/htmlAlignFormatHandler.ts | 15 ++++++----- .../test/endToEndTest.ts | 27 +++++++++++++++++++ .../block/htmlAlignFormatHandlerTest.ts | 9 +++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/htmlAlignFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/htmlAlignFormatHandler.ts index e10603fccf9..16222ac4aed 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/htmlAlignFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/htmlAlignFormatHandler.ts @@ -14,14 +14,17 @@ export const htmlAlignFormatHandler: FormatHandler< DirectionFormat & HtmlAlignFormat & TextAlignFormat > = { parse: (format, element, context, defaultStyle) => { - directionFormatHandler.parse(format, element, context, defaultStyle); + // When there is text-align in CSS style on the same element, we should ignore HTML align + if (!element.style.textAlign) { + directionFormatHandler.parse(format, element, context, defaultStyle); - const htmlAlign = element.getAttribute('align'); + const htmlAlign = element.getAttribute('align'); - if (htmlAlign) { - format.htmlAlign = calcAlign(htmlAlign, format.direction); - delete format.textAlign; - delete context.blockFormat.textAlign; + if (htmlAlign) { + format.htmlAlign = calcAlign(htmlAlign, format.direction); + delete format.textAlign; + delete context.blockFormat.textAlign; + } } }, apply: (format, element) => { diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index d282506eea8..1313bb9918e 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2199,4 +2199,31 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { '' ); }); + + it('HTML align together with CSS text-align', () => { + runTest( + '
test
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + textAlign: 'center', + }, + isImplicit: false, + }, + ], + }, + 'test', + '
test
' + ); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/block/htmlAlignFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/block/htmlAlignFormatHandlerTest.ts index ea54b7b1ebf..80197e08563 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/block/htmlAlignFormatHandlerTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/block/htmlAlignFormatHandlerTest.ts @@ -82,6 +82,15 @@ describe('htmlAlignFormatHandler.parse', () => { htmlAlign: 'start', }); }); + + it('Ignore HTML align when there is CSS text-align', () => { + div.setAttribute('align', 'left'); + div.style.textAlign = 'center'; + + htmlAlignFormatHandler.parse(format, div, context, {}); + + expect(format.htmlAlign).toBeUndefined(); + }); }); describe('htmlAlignFormatHandler.apply', () => { From e109e8ed8c4442adfd3bbaad814237b22246783e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 21 Oct 2024 16:25:51 -0700 Subject: [PATCH 20/25] Fix #2832 Support auto link when press Enter (#2837) * Fix #2832 * fix build --- .../roosterjs-content-model-api/lib/index.ts | 1 + .../lib/modelApi}/link/getLinkUrl.ts | 8 +- .../lib/modelApi/link/promoteLink.ts | 48 + .../test/modelApi}/link/getLinkUrlTest.ts | 4 +- .../test/modelApi/link/promoteLinkTest.ts} | 34 +- .../lib/autoFormat/AutoFormatPlugin.ts | 22 +- .../autoFormat/interface/AutoFormatOptions.ts | 2 +- .../lib/autoFormat/link/createLink.ts | 46 +- .../autoFormat/link/createLinkAfterSpace.ts | 42 - .../lib/edit/inputSteps/handleAutoLink.ts | 27 + .../lib/edit/keyboardEnter.ts | 5 +- .../lib/index.ts | 1 - .../test/autoFormat/AutoFormatPluginTest.ts | 1203 +++++++++++++---- .../test/autoFormat/link/createLinkTest.ts | 6 + .../lib/index.ts | 1 + .../lib/parameter}/AutoLinkOptions.ts | 0 16 files changed, 1069 insertions(+), 381 deletions(-) rename packages/{roosterjs-content-model-plugins/lib/autoFormat => roosterjs-content-model-api/lib/modelApi}/link/getLinkUrl.ts (69%) create mode 100644 packages/roosterjs-content-model-api/lib/modelApi/link/promoteLink.ts rename packages/{roosterjs-content-model-plugins/test/autoFormat => roosterjs-content-model-api/test/modelApi}/link/getLinkUrlTest.ts (90%) rename packages/{roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts => roosterjs-content-model-api/test/modelApi/link/promoteLinkTest.ts} (97%) delete mode 100644 packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleAutoLink.ts rename packages/{roosterjs-content-model-plugins/lib/autoFormat/interface => roosterjs-content-model-types/lib/parameter}/AutoLinkOptions.ts (100%) diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index ffcaca8e65a..9010a248c90 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -58,4 +58,5 @@ export { setModelListStartNumber } from './modelApi/list/setModelListStartNumber export { findListItemsInSameThread } from './modelApi/list/findListItemsInSameThread'; export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; +export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts b/packages/roosterjs-content-model-api/lib/modelApi/link/getLinkUrl.ts similarity index 69% rename from packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts rename to packages/roosterjs-content-model-api/lib/modelApi/link/getLinkUrl.ts index 8c242f790ff..e91289593d3 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/link/getLinkUrl.ts @@ -1,5 +1,5 @@ -import { matchLink } from 'roosterjs-content-model-api'; -import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; +import { matchLink } from './matchLink'; +import type { AutoLinkOptions } from 'roosterjs-content-model-types'; const COMMON_REGEX = `[\s]*[a-zA-Z0-9+][\s]*`; const TELEPHONE_REGEX = `(T|t)el:${COMMON_REGEX}`; @@ -8,8 +8,8 @@ const MAILTO_REGEX = `(M|m)ailto:${COMMON_REGEX}`; /** * @internal */ -export function getLinkUrl(text: string, autoLinkOptions: AutoLinkOptions): string | undefined { - const { autoLink, autoMailto, autoTel } = autoLinkOptions; +export function getLinkUrl(text: string, autoLinkOptions?: AutoLinkOptions): string | undefined { + const { autoLink, autoMailto, autoTel } = autoLinkOptions ?? {}; const linkMatch = autoLink ? matchLink(text)?.normalizedUrl : undefined; const telMatch = autoTel ? matchTel(text) : undefined; const mailtoMatch = autoMailto ? matchMailTo(text) : undefined; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/link/promoteLink.ts b/packages/roosterjs-content-model-api/lib/modelApi/link/promoteLink.ts new file mode 100644 index 00000000000..ea7434f8a1f --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/link/promoteLink.ts @@ -0,0 +1,48 @@ +import { getLinkUrl } from './getLinkUrl'; +import { splitTextSegment } from '../../publicApi/segment/splitTextSegment'; +import type { + AutoLinkOptions, + ContentModelText, + ShallowMutableContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * Promote the given text segment to a hyper link when the segment text is ending with a valid link format. + * When the whole text segment if of a link, promote the whole segment. + * When the text segment ends with a link format, split the segment and promote the second part + * When link is in middle of the text segment, no action. + * This is mainly used for doing auto link when there is a link before cursor + * @param segment The text segment to search link text from + * @param paragraph Parent paragraph of the segment + * @param options Options of auto link + * @returns If a link is promoted, return this segment. Otherwise return null + */ +export function promoteLink( + segment: ContentModelText, + paragraph: ShallowMutableContentModelParagraph, + autoLinkOptions: AutoLinkOptions +): ContentModelText | null { + const link = segment.text.split(' ').pop(); + const url = link?.trim(); + let linkUrl: string | undefined = undefined; + + if (url && link && (linkUrl = getLinkUrl(url, autoLinkOptions))) { + const linkSegment = splitTextSegment( + segment, + paragraph, + segment.text.length - link.trimLeft().length, + segment.text.trimRight().length + ); + linkSegment.link = { + format: { + href: linkUrl, + underline: true, + }, + dataset: {}, + }; + + return linkSegment; + } + + return null; +} diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts b/packages/roosterjs-content-model-api/test/modelApi/link/getLinkUrlTest.ts similarity index 90% rename from packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts rename to packages/roosterjs-content-model-api/test/modelApi/link/getLinkUrlTest.ts index 66d117a4d32..59fd719ec74 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/link/getLinkUrlTest.ts @@ -1,5 +1,5 @@ -import { AutoLinkOptions } from '../../../lib/autoFormat/interface/AutoLinkOptions'; -import { getLinkUrl } from '../../../lib/autoFormat/link/getLinkUrl'; +import { AutoLinkOptions } from 'roosterjs-content-model-types'; +import { getLinkUrl } from '../../../lib/modelApi/link/getLinkUrl'; describe('getLinkUrl', () => { function runTest(text: string, options: AutoLinkOptions, expectedResult: string | undefined) { diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-api/test/modelApi/link/promoteLinkTest.ts similarity index 97% rename from packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts rename to packages/roosterjs-content-model-api/test/modelApi/link/promoteLinkTest.ts index 0bf95c157d4..7bd73325e1d 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/link/promoteLinkTest.ts @@ -1,25 +1,23 @@ -import { createLinkAfterSpace } from '../../../lib/autoFormat/link/createLinkAfterSpace'; import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { promoteLink } from '../../../lib/modelApi/link/promoteLink'; import { ContentModelDocument, ContentModelParagraph, ContentModelText, - FormatContentModelContext, } from 'roosterjs-content-model-types'; -describe('createLinkAfterSpace', () => { +describe('promoteLink', () => { function runTest( previousSegment: ContentModelText, paragraph: ContentModelParagraph, - context: FormatContentModelContext, - expectedResult: boolean + expectedResult: ContentModelText | null ) { - const result = createLinkAfterSpace(previousSegment, paragraph, context, { + const result = promoteLink(previousSegment, paragraph, { autoLink: true, autoMailto: true, autoTel: true, }); - expect(result).toBe(expectedResult); + expect(result).toEqual(expectedResult); } it('with link', () => { @@ -33,7 +31,19 @@ describe('createLinkAfterSpace', () => { segments: [segment], format: {}, }; - runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + runTest(segment, paragraph, { + segmentType: 'Text', + text: 'http://bing.com', + isSelected: undefined, + format: {}, + link: { + format: { + href: 'http://bing.com', + underline: true, + }, + dataset: {}, + }, + }); }); it('No link', () => { @@ -47,7 +57,7 @@ describe('createLinkAfterSpace', () => { segments: [segment], format: {}, }; - runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + runTest(segment, paragraph, null); }); it('with text after link ', () => { @@ -61,7 +71,7 @@ describe('createLinkAfterSpace', () => { segments: [segment], format: {}, }; - runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + runTest(segment, paragraph, null); }); }); @@ -88,8 +98,8 @@ describe('formatTextSegmentBeforeSelectionMarker - createLinkAfterSpace', () => focus: () => {}, formatContentModel: formatWithContentModelSpy, } as any, - (_model, previousSegment, paragraph, _markerFormat, context) => { - return createLinkAfterSpace(previousSegment, paragraph, context, { + (_model, previousSegment, paragraph, _markerFormat) => { + return !!promoteLink(previousSegment, paragraph, { autoLink: true, autoMailto: true, autoTel: true, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index dfc33ad6199..40305ffcb1d 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -1,7 +1,6 @@ import { ChangeSource } from 'roosterjs-content-model-dom'; import { createLink } from './link/createLink'; -import { createLinkAfterSpace } from './link/createLinkAfterSpace'; -import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { formatTextSegmentBeforeSelectionMarker, promoteLink } from 'roosterjs-content-model-api'; import { keyboardListTrigger } from './list/keyboardListTrigger'; import { transformFraction } from './numbers/transformFraction'; import { transformHyphen } from './hyphen/transformHyphen'; @@ -144,16 +143,15 @@ export class AutoFormatPlugin implements EditorPlugin { } if (autoLink || autoTel || autoMailto) { - shouldLink = createLinkAfterSpace( - previousSegment, - paragraph, - context, - { - autoLink, - autoTel, - autoMailto, - } - ); + shouldLink = !!promoteLink(previousSegment, paragraph, { + autoLink, + autoTel, + autoMailto, + }); + + if (shouldLink) { + context.canUndoByBackspace = true; + } } if (autoHyphen) { diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts index 50682210fac..49b941092c2 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts @@ -1,4 +1,4 @@ -import type { AutoLinkOptions } from './AutoLinkOptions'; +import type { AutoLinkOptions } from 'roosterjs-content-model-types'; /** * Options to customize the Content Model Auto Format Plugin 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 fe29906835c..d16e1eeeea8 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,8 +1,11 @@ -import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; -import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; -import { getLinkUrl } from './getLinkUrl'; -import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; -import type { ContentModelLink, IEditor } from 'roosterjs-content-model-types'; +import { ChangeSource } from 'roosterjs-content-model-dom'; +import { formatTextSegmentBeforeSelectionMarker, promoteLink } from 'roosterjs-content-model-api'; +import type { + ContentModelLink, + IEditor, + ContentModelText, + AutoLinkOptions, +} from 'roosterjs-content-model-types'; /** * @internal @@ -10,29 +13,26 @@ import type { ContentModelLink, IEditor } from 'roosterjs-content-model-types'; export function createLink(editor: IEditor, autoLinkOptions: AutoLinkOptions) { let anchorNode: Node | null = null; const links: ContentModelLink[] = []; + formatTextSegmentBeforeSelectionMarker( editor, - (_model, linkSegment, _paragraph) => { - if (linkSegment.link) { - links.push(linkSegment.link); + (_model, segment, paragraph) => { + let promotedSegment: ContentModelText | null = null; + + if (segment.link) { + links.push(segment.link); + return true; - } - let linkUrl: string | undefined = undefined; - if (!linkSegment.link && (linkUrl = getLinkUrl(linkSegment.text, autoLinkOptions))) { - addLink(linkSegment, { - format: { - href: linkUrl, - underline: true, - }, - dataset: {}, - }); - if (linkSegment.link) { - links.push(linkSegment.link); - } + } else if ( + (promotedSegment = promoteLink(segment, paragraph, autoLinkOptions)) && + promotedSegment.link + ) { + links.push(promotedSegment.link); + return true; + } else { + return false; } - - return false; }, { changeSource: ChangeSource.AutoLink, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts deleted file mode 100644 index 737917e25ab..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getLinkUrl } from './getLinkUrl'; -import { splitTextSegment } from 'roosterjs-content-model-api'; -import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; -import type { - ContentModelText, - FormatContentModelContext, - ShallowMutableContentModelParagraph, -} from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function createLinkAfterSpace( - previousSegment: ContentModelText, - paragraph: ShallowMutableContentModelParagraph, - context: FormatContentModelContext, - autoLinkOptions: AutoLinkOptions -) { - const link = previousSegment.text.split(' ').pop(); - const url = link?.trim(); - let linkUrl: string | undefined = undefined; - if (url && link && (linkUrl = getLinkUrl(url, autoLinkOptions))) { - const linkSegment = splitTextSegment( - previousSegment, - paragraph, - previousSegment.text.length - link.trimLeft().length, - previousSegment.text.trimRight().length - ); - linkSegment.link = { - format: { - href: linkUrl, - underline: true, - }, - dataset: {}, - }; - - context.canUndoByBackspace = true; - - return true; - } - return false; -} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleAutoLink.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleAutoLink.ts new file mode 100644 index 00000000000..2892f972241 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleAutoLink.ts @@ -0,0 +1,27 @@ +import { promoteLink } from 'roosterjs-content-model-api'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const handleAutoLink: DeleteSelectionStep = context => { + const { deleteResult, insertPoint } = context; + + if (deleteResult == 'notDeleted' || deleteResult == 'nothingToDelete') { + const { marker, paragraph } = insertPoint; + const index = paragraph.segments.indexOf(marker); + const segBefore = index > 0 ? paragraph.segments[index - 1] : null; + + if ( + segBefore?.segmentType == 'Text' && + promoteLink(segBefore, paragraph, { + autoLink: true, + }) && + context.formatContext + ) { + context.formatContext.canUndoByBackspace = true; + } + + // Do not set deleteResult here since we haven't really start a new paragraph, we need other delete step to keep working on it + } +}; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index b8b2217563e..2bb32aa5fe5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -1,4 +1,5 @@ import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; +import { handleAutoLink } from './inputSteps/handleAutoLink'; import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; import { @@ -30,7 +31,9 @@ export function keyboardEnter( // so further delete steps can keep working result.deleteResult = 'notDeleted'; - const steps = rawEvent.shiftKey ? [] : [handleEnterOnList, deleteEmptyQuote]; + const steps = rawEvent.shiftKey + ? [] + : [handleAutoLink, handleEnterOnList, deleteEmptyQuote]; if (handleNormalEnter) { steps.push(handleEnterOnParagraph); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 89af142dedc..884e0472dbc 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -6,7 +6,6 @@ export { DefaultSanitizers } from './paste/DefaultSanitizers'; export { EditPlugin, EditOptions } from './edit/EditPlugin'; export { AutoFormatPlugin } from './autoFormat/AutoFormatPlugin'; export { AutoFormatOptions } from './autoFormat/interface/AutoFormatOptions'; -export { AutoLinkOptions } from './autoFormat/interface/AutoLinkOptions'; export { ShortcutBold, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 45d5c236d56..f9cb705bc4f 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -3,26 +3,20 @@ import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model import * as unlink from '../../lib/autoFormat/link/unlink'; import { AutoFormatOptions } from '../../lib/autoFormat/interface/AutoFormatOptions'; import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; -import { ChangeSource } from '../../../roosterjs-content-model-dom/lib/constants/ChangeSource'; -import { createLinkAfterSpace } from '../../lib/autoFormat/link/createLinkAfterSpace'; -import { keyboardListTrigger } from '../../lib/autoFormat/list/keyboardListTrigger'; -import { transformFraction } from '../../lib/autoFormat/numbers/transformFraction'; -import { transformHyphen } from '../../lib/autoFormat/hyphen/transformHyphen'; -import { transformOrdinals } from '../../lib/autoFormat/numbers/transformOrdinals'; import { ContentChangedEvent, ContentModelDocument, ContentModelParagraph, + ContentModelSelectionMarker, ContentModelText, EditorInputEvent, - FormatContentModelContext, IEditor, KeyDownEvent, } from 'roosterjs-content-model-types'; describe('Content Model Auto Format Plugin Test', () => { let editor: IEditor; - let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; + let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; let triggerEventSpy: jasmine.Spy; beforeEach(() => { @@ -46,70 +40,55 @@ describe('Content Model Auto Format Plugin Test', () => { }); describe('onPluginEvent - keyboardListTrigger', () => { + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; function runTest( event: EditorInputEvent, testBullet: boolean, - expectResult: boolean, - options?: AutoFormatOptions + expectResult: ContentModelDocument, + options: AutoFormatOptions, + shouldCallFormat: boolean ) { const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - - const formatOptions = { - apiName: '', + const textSegment: ContentModelText = { + segmentType: 'Text', + text: testBullet ? '*' : '1)', + format: {}, }; - - const inputModel = (bullet: boolean): ContentModelDocument => ({ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [textSegment, marker], + format: {}, + }; + const inputModel: ContentModelDocument = { blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: bullet ? '*' : '1)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], + blocks: [paragraph], format: {}, - }); + }; formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - const result = keyboardListTrigger( - inputModel(testBullet), - paragraph, - context, - options!.autoBullet, - options!.autoNumbering - ); - expect(result).toBe(expectResult); - formatOptions.apiName = result ? 'autoToggleList' : ''; - return result; - } + callback( + inputModel, + textSegment, + paragraph, + {}, + { deletedEntities: [], newEntities: [], newImages: [] } ); - expect(options).toEqual({ - changeSource: 'AutoFormat', - apiName: formatOptions.apiName, - }); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(inputModel).toEqual(expectResult); } it('should trigger keyboardListTrigger', () => { @@ -117,10 +96,65 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, true, { - autoBullet: true, - autoNumbering: true, - }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + ], + }, + { + autoBullet: true, + autoNumbering: true, + }, + true + ); }); it('should not trigger keyboardListTrigger', () => { @@ -128,10 +162,33 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: '*', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, false, { - autoBullet: true, - autoNumbering: true, - }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: '*', + }, + marker, + ], + }, + ], + }, + { + autoBullet: true, + autoNumbering: true, + }, + false + ); }); it('should not trigger keyboardListTrigger', () => { @@ -139,7 +196,30 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, false, false, { autoBullet: false, autoNumbering: false }); + runTest( + event, + false, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: '1)', + }, + marker, + ], + }, + ], + }, + { autoBullet: false, autoNumbering: false }, + true + ); }); it('should trigger keyboardListTrigger with auto bullet only', () => { @@ -147,7 +227,62 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, false, { autoBullet: true, autoNumbering: false }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: false }, + true + ); }); it('should trigger keyboardListTrigger with auto numbering only', () => { @@ -155,7 +290,62 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, false, true, { autoBullet: false, autoNumbering: true }); + runTest( + event, + false, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":3}', + }, + }, + ], + + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + ], + }, + { autoBullet: false, autoNumbering: true }, + true + ); }); it('should not trigger keyboardListTrigger if the input type is different from insertText', () => { @@ -163,83 +353,89 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { key: ' ', defaultPrevented: false, inputType: 'test' } as any, }; - runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: '*', + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + false + ); }); }); describe('onPluginEvent - [TAB] - keyboardListTrigger', () => { + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; function runTest( event: KeyDownEvent, testBullet: boolean, - expectResult: boolean, - options?: AutoFormatOptions + expectResult: ContentModelDocument, + options: AutoFormatOptions, + shouldCallFormat: boolean, + shouldPreventDefault: boolean ) { const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - - const formatOptions = { - apiName: '', + const textSegment: ContentModelText = { + segmentType: 'Text', + text: testBullet ? '*' : '1)', + format: {}, }; - - const inputModel = (bullet: boolean): ContentModelDocument => ({ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [textSegment, marker], + format: {}, + }; + const inputModel: ContentModelDocument = { blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: bullet ? '*' : '1)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], + blocks: [paragraph], format: {}, - }); + }; + + event.rawEvent.preventDefault = jasmine.createSpy('preventDefault'); formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - const result = keyboardListTrigger( - inputModel(testBullet), - paragraph, - context, - options!.autoBullet, - options!.autoNumbering - ); - expect(result).toBe(expectResult); - const preventDefaultSpy = spyOn(event.rawEvent, 'preventDefault'); - if (result) { - expect(context.canUndoByBackspace).toBe(true); - expect(preventDefaultSpy).toHaveBeenCalled(); - } else { - expect(context.canUndoByBackspace).toBe(false); - expect(preventDefaultSpy).not.toHaveBeenCalled(); - } - formatOptions.apiName = result ? 'autoToggleList' : ''; - return result; - } + callback( + inputModel, + textSegment, + paragraph, + {}, + { newEntities: [], newImages: [], deletedEntities: [] } ); - expect(options).toEqual({ - changeSource: 'AutoFormat', - apiName: formatOptions.apiName, - }); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(inputModel).toEqual(expectResult); + expect(event.rawEvent.preventDefault).toHaveBeenCalledTimes( + shouldPreventDefault ? 1 : 0 + ); } it('[TAB] should trigger keyboardListTrigger bullet ', () => { @@ -251,7 +447,56 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, true, true, { autoBullet: true, autoNumbering: false }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, { segmentType: 'Br', format: {} }], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + }, + { autoBullet: true, autoNumbering: false }, + true, + true + ); }); it('[TAB] should trigger keyboardListTrigger numbering ', () => { @@ -263,7 +508,56 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, false, true, { autoBullet: true, autoNumbering: true }); + runTest( + event, + false, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, { segmentType: 'Br', format: {} }], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + true, + true + ); }); it('[TAB] should not trigger keyboardListTrigger numbering ', () => { @@ -275,7 +569,31 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, false, false, { autoBullet: true, autoNumbering: false }); + runTest( + event, + false, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: false }, + true, + true + ); }); it('[TAB] should not trigger keyboardListTrigger bullet ', () => { @@ -287,7 +605,31 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, true, false, { autoBullet: false, autoNumbering: false }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: false, autoNumbering: false }, + true, + false + ); }); it('[TAB] should not trigger keyboardListTrigger - not tab ', () => { @@ -299,7 +641,31 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + false, + false + ); }); it('[TAB] should not trigger keyboardListTrigger - default prevented ', () => { @@ -311,7 +677,31 @@ describe('Content Model Auto Format Plugin Test', () => { handledByEditFeature: false, } as any, }; - runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + false, + false + ); }); it('[TAB] should not trigger keyboardListTrigger - handledByEditFeature', () => { @@ -320,10 +710,34 @@ describe('Content Model Auto Format Plugin Test', () => { rawEvent: { key: 'Tab', defaultPrevented: false, - handledByEditFeature: true, } as any, + handledByEditFeature: true, }; - runTest(event, true, false, { autoBullet: true, autoNumbering: true }); + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + false, + false + ); }); }); @@ -439,11 +853,12 @@ describe('Content Model Auto Format Plugin Test', () => { }); }); - describe('onPluginEvent - createLinkAfterSpace', () => { + describe('onPluginEvent - promoteLink', () => { function runTest( event: EditorInputEvent, - expectResult: boolean, - options: AutoFormatOptions + expectResult: ContentModelParagraph, + options: AutoFormatOptions, + shouldCallFormat: boolean ) { const plugin = new AutoFormatPlugin(options as AutoFormatOptions); plugin.initialize(editor); @@ -453,73 +868,141 @@ describe('Content Model Auto Format Plugin Test', () => { text: 'www.test.com', format: {}, }; - const formatOptions = { - changeSource: '', + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, }; - plugin.onPluginEvent(event); formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - const result = - options && createLinkAfterSpace(segment, paragraph, context, options); - - expect(result).toBe(expectResult); - - formatOptions.changeSource = result ? ChangeSource.AutoLink : ''; - return result; - } + callback( + null!, + segment, + paragraph, + {}, + { deletedEntities: [], newEntities: [], newImages: [] } ); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(paragraph).toEqual(expectResult); } - it('should call createLinkAfterSpace', () => { + it('should call promoteLink', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoLink: true, - }); + runTest( + event, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + isSelected: undefined, + link: { + format: { + href: 'http://www.test.com', + underline: true, + }, + dataset: {}, + }, + }, + ], + }, + { + autoLink: true, + }, + true + ); }); - it('should call createLinkAfterSpace | autoTel', () => { + it('should call promoteLink | autoTel', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoTel: true, - }); + runTest( + event, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + }, + ], + }, + { + autoTel: true, + }, + true + ); }); - it('should call createLinkAfterSpace | autoMailto', () => { + it('should call promoteLink | autoMailto', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoMailto: true, - }); + runTest( + event, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + }, + ], + }, + { + autoMailto: true, + }, + true + ); }); - it('should not call createLinkAfterSpace - disable options', () => { + it('should not call promoteLink - disable options', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, false, { - autoLink: false, - }); + runTest( + event, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + }, + ], + }, + { + autoLink: false, + }, + true + ); }); - it('should not call createLinkAfterSpace - not space', () => { + it('should not call promoteLink - not space', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { @@ -528,63 +1011,101 @@ describe('Content Model Auto Format Plugin Test', () => { inputType: 'insertText', } as any, }; - runTest(event, false, { - autoLink: true, - }); + runTest( + event, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + }, + ], + }, + { + autoLink: true, + }, + false + ); }); }); describe('onPluginEvent - transformHyphen', () => { function runTest( event: EditorInputEvent, - expectedResult: boolean, - options?: AutoFormatOptions + expectedResult: ContentModelDocument, + options: AutoFormatOptions, + shouldCallFormat: boolean ) { const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - const formatOption = { - apiName: '', - }; + const segment: ContentModelText = { segmentType: 'Text', text: 'test--test', format: {}, }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - let result = false; - - if (options && options.autoHyphen) { - result = transformHyphen(segment, paragraph, context); - } - expect(result).toBe(expectedResult); - formatOption.apiName = result ? 'autoHyphen' : ''; - return result; - } + callback( + model, + segment, + paragraph, + {}, + { newEntities: [], newImages: [], deletedEntities: [] } ); - expect(options).toEqual({ - changeSource: 'AutoFormat', - apiName: formatOption.apiName, - }); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(model).toEqual(expectedResult); } - it('should call transformHyphen', () => { + xit('should call transformHyphen', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoHyphen: true, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test—test', + format: {}, + isSelected: undefined, + }, + ], + format: {}, + }, + ], + }, + { + autoHyphen: true, + }, + true + ); }); it('should not call transformHyphen - disable options', () => { @@ -592,9 +1113,29 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, false, { - autoHyphen: false, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test--test', + format: {}, + }, + ], + format: {}, + }, + ], + }, + { + autoHyphen: false, + }, + true + ); }); it('should not call transformHyphen - not space', () => { @@ -606,65 +1147,106 @@ describe('Content Model Auto Format Plugin Test', () => { inputType: 'insertText', } as any, }; - runTest(event, false, { - autoHyphen: true, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test--test', + format: {}, + }, + ], + format: {}, + }, + ], + }, + { + autoHyphen: true, + }, + false + ); }); }); describe('onPluginEvent - transformFraction', () => { function runTest( event: EditorInputEvent, - expectResult: boolean, - options?: AutoFormatOptions + expectResult: ContentModelDocument, + options: AutoFormatOptions, + shouldCallFormat: boolean ) { const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - const formatOption = { - apiName: '', - }; const segment: ContentModelText = { segmentType: 'Text', text: '1/2', format: {}, }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment], + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - let result = false; - - if (options && options.autoHyphen) { - result = transformFraction(segment, paragraph, context); - } - expect(result).toBe(expectResult); - formatOption.apiName = ''; - return result; - } + callback( + model, + segment, + paragraph, + {}, + { newEntities: [], newImages: [], deletedEntities: [] } ); - expect(options).toEqual({ - changeSource: 'AutoFormat', - apiName: formatOption.apiName, - }); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(model).toEqual(expectResult); } - it('should call transformFraction', () => { + xit('should call transformFraction', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoFraction: true, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '½', + format: {}, + isSelected: undefined, + }, + ], + }, + ], + }, + { + autoFraction: true, + }, + true + ); }); it('should not call transformHyphen - disable options', () => { @@ -672,65 +1254,106 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, false, { - autoFraction: false, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [{ segmentType: 'Text', text: '1/2', format: {} }], + }, + ], + }, + { + autoFraction: false, + }, + true + ); }); }); describe('onPluginEvent - transformOrdinals', () => { function runTest( event: EditorInputEvent, - expectResult: boolean, - options?: AutoFormatOptions + expectResult: ContentModelDocument, + options: AutoFormatOptions, + shouldCallFormat: boolean ) { const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - const formatOption = { - apiName: '', - }; const segment: ContentModelText = { segmentType: 'Text', text: '1st', format: {}, }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment], + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { - expect(callback).toBe( - editor, - ( - _model: ContentModelDocument, - _previousSegment: ContentModelText, - paragraph: ContentModelParagraph, - context: FormatContentModelContext - ) => { - let result = false; - - if (options && options.autoHyphen) { - result = transformOrdinals(segment, paragraph, context); - } - expect(result).toBe(expectResult); - formatOption.apiName = ''; - return result; - } + callback( + model, + segment, + paragraph, + {}, + { newEntities: [], newImages: [], deletedEntities: [] } ); - expect(options).toEqual({ - changeSource: 'AutoFormat', - apiName: formatOption.apiName, - }); + + return true; }); + + plugin.onPluginEvent(event); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes( + shouldCallFormat ? 1 : 0 + ); + expect(model).toEqual(expectResult); } - it('should call transformOrdinals', () => { + xit('should call transformOrdinals', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { - autoOrdinals: true, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '1', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 'st', + format: { superOrSubScriptSequence: 'super' }, + isSelected: undefined, + }, + ], + }, + ], + }, + { + autoOrdinals: true, + }, + true + ); }); it('should not call transformOrdinals - disable options', () => { @@ -738,9 +1361,23 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, false, { - autoOrdinals: false, - }); + runTest( + event, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [{ segmentType: 'Text', text: '1st', format: {} }], + }, + ], + }, + { + autoOrdinals: false, + }, + true + ); }); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts index 4b709ab5a60..4e3be384131 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts @@ -120,6 +120,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -209,6 +210,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -264,6 +266,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -319,6 +322,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -374,6 +378,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -429,6 +434,7 @@ describe('createLink', () => { }, dataset: {}, }, + isSelected: undefined, }, { segmentType: 'SelectionMarker', diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 80df0987096..888ccf1263b 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -387,6 +387,7 @@ export { } from './pluginState/PluginState'; export { ContextMenuPluginState } from './pluginState/ContextMenuPluginState'; +export { AutoLinkOptions } from './parameter/AutoLinkOptions'; export { EditorEnvironment, ContentModelSettings } from './parameter/EditorEnvironment'; export { EntityState, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoLinkOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/AutoLinkOptions.ts similarity index 100% rename from packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoLinkOptions.ts rename to packages/roosterjs-content-model-types/lib/parameter/AutoLinkOptions.ts From 64ba856b05e5909f04a70f25caf2509746e1ccc0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 22 Oct 2024 08:18:22 -0700 Subject: [PATCH 21/25] Fix #2825 (#2839) --- .../editing/deleteExpandedSelection.ts | 2 +- .../modelApi/editing/getSegmentTextFormat.ts | 23 +++++++++------- .../editing/getSegmentTextFormatTest.ts | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts index 33ebafe00ca..2e3da4e64ee 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts @@ -62,7 +62,7 @@ export function deleteExpandedSelection( // so we can put cursor here after delete paragraph = block; insertMarkerIndex = indexes[0]; - markerFormat = getSegmentTextFormat(segments[0]); + markerFormat = getSegmentTextFormat(segments[0], true /*includingBIU*/); context.lastParagraph = paragraph; context.lastTableContext = tableContext; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts index b8962f3205a..9393f491765 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts @@ -6,21 +6,24 @@ import type { /** * Get the text format of a segment, this function will return only format that is applicable to text * @param segment The segment to get format from + * @param includingBIU When pass true, also get Bold/Italic/Underline format * @returns */ export function getSegmentTextFormat( - segment: ReadonlyContentModelSegment + segment: ReadonlyContentModelSegment, + includingBIU?: boolean ): ContentModelSegmentFormat { - const { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } = - segment?.format ?? {}; - + const format = segment.format ?? {}; const textFormat: ContentModelSegmentFormat = { - fontFamily, - fontSize, - textColor, - backgroundColor, - letterSpacing, - lineHeight, + fontFamily: format.fontFamily, + fontSize: format.fontSize, + textColor: format.textColor, + backgroundColor: format.backgroundColor, + letterSpacing: format.letterSpacing, + lineHeight: format.lineHeight, + fontWeight: includingBIU ? format.fontWeight : undefined, + italic: includingBIU ? format.italic : undefined, + underline: includingBIU ? format.underline : undefined, }; return removeUndefinedValues(textFormat); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/getSegmentTextFormatTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/getSegmentTextFormatTest.ts index 79f316975b4..5f2892f013b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/getSegmentTextFormatTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/getSegmentTextFormatTest.ts @@ -11,6 +11,7 @@ describe('getSegmentTextFormat', () => { backgroundColor: 'blue', letterSpacing: '1px', lineHeight: '1.5', + fontWeight: 'bold', }); expect(getSegmentTextFormat(segment)).toEqual({ fontFamily: 'Arial', @@ -49,4 +50,29 @@ describe('getSegmentTextFormat', () => { letterSpacing: '1px', }); }); + + it('get format including B/I/U', () => { + const segment = createText('test', { + fontFamily: 'Arial', + fontSize: '12px', + textColor: 'red', + backgroundColor: 'blue', + letterSpacing: '1px', + lineHeight: '1.5', + fontWeight: 'bold', + italic: true, + underline: false, + }); + expect(getSegmentTextFormat(segment, true)).toEqual({ + fontFamily: 'Arial', + fontSize: '12px', + textColor: 'red', + backgroundColor: 'blue', + letterSpacing: '1px', + lineHeight: '1.5', + fontWeight: 'bold', + italic: true, + underline: false, + }); + }); }); From e02c69aa8044c892fa506d113c0875068671a84a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 24 Oct 2024 10:12:52 -0700 Subject: [PATCH 22/25] Do not handle ENTER key when CTRL is pressed (#2842) --- .../lib/edit/EditPlugin.ts | 7 +++++-- .../test/edit/EditPluginTest.ts | 20 +++++++++++++++++++ 2 files changed, 25 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 0e3af9c35c3..87c4779ea85 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -150,6 +150,7 @@ export class EditPlugin implements EditorPlugin { private handleKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const rawEvent = event.rawEvent; + const hasCtrlOrMetaKey = rawEvent.ctrlKey || rawEvent.metaKey; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { @@ -169,7 +170,7 @@ export class EditPlugin implements EditorPlugin { break; case 'Tab': - if (this.options.handleTabKey) { + if (this.options.handleTabKey && !hasCtrlOrMetaKey) { keyboardTab(editor, rawEvent); } break; @@ -180,7 +181,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - keyboardEnter(editor, rawEvent, this.handleNormalEnter); + if (!hasCtrlOrMetaKey) { + keyboardEnter(editor, rawEvent, this.handleNormalEnter); + } break; default: diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 6ad3df27402..51e2c32bccd 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -183,6 +183,26 @@ describe('EditPlugin', () => { expect(keyboardTabSpy).not.toHaveBeenCalled(); }); + it('Ctrl+Enter, nothing happens', () => { + plugin = new EditPlugin(); + const rawEvent = { which: 13, key: 'Enter', ctrlKey: true } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); + }); + it('Other key', () => { plugin = new EditPlugin(); const rawEvent = { which: 41, key: 'A' } as any; From 09531d85addc9880c58d7591853322a1d036e91e Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Thu, 24 Oct 2024 15:45:08 -0600 Subject: [PATCH 23/25] Fix ZoomScaleChangedEvent no longer being triggered in EditorAdapter #2843 --- .../lib/editor/EditorAdapter.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index cc9f977ce6a..b80589167e9 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1093,17 +1093,13 @@ export class EditorAdapter extends Editor implements ILegacyEditor { */ setZoomScale(scale: number): void { if (scale > 0 && scale <= 10) { - const oldValue = this.getZoomScale(); - - if (oldValue != scale) { - this.triggerEvent( - 'zoomChanged', - { - newZoomScale: scale, - }, - true /*broadcast*/ - ); - } + this.triggerEvent( + 'zoomChanged', + { + newZoomScale: scale, + }, + true /*broadcast*/ + ); } } From eaf810ba514aaa1c45007aea8b55057516bc2499 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 25 Oct 2024 13:53:46 -0600 Subject: [PATCH 24/25] Add `preferSource` and `preferTarget` merge options to mergeModel API. (#2844) * initial * init --- .../lib/modelApi/editing/mergeModel.ts | 64 +- .../test/modelApi/editing/mergeModelTest.ts | 1755 +++++++++++++++++ .../lib/parameter/MergeModelOption.ts | 11 +- 3 files changed, 1803 insertions(+), 27 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index 08497fea2a2..b8d566e1336 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -33,6 +33,8 @@ import type { const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; const KeysOfSegmentFormat = getObjectKeys(EmptySegmentFormat); +type MergeFormatTypes = 'mergeAll' | 'keepSourceEmphasisFormat' | 'preferSource' | 'preferTarget'; + /** * Merge source model into target mode * @param target Target Content Model that will merge content into @@ -333,7 +335,7 @@ function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { function applyDefaultFormat( group: ReadonlyContentModelBlockGroup, format: ContentModelSegmentFormat, - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat' + applyDefaultFormatOption: MergeFormatTypes ) { group.blocks.forEach(block => { mergeBlockFormat(applyDefaultFormatOption, block); @@ -414,39 +416,51 @@ function getSegmentFormatInLinkFormat( } function mergeLinkFormat( - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', + applyDefaultFormatOption: MergeFormatTypes, targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelHyperLinkFormat ) { - return applyDefaultFormatOption == 'mergeAll' - ? { ...getSegmentFormatInLinkFormat(targetFormat), ...sourceFormat } - : { - // Hyperlink segment format contains other attributes such as LinkFormat - // so we have to retain them - ...getFormatWithoutSegmentFormat(sourceFormat), - // Link format only have Text color, background color, Underline, but only - // text color + background color should be merged from the target - ...getSegmentFormatInLinkFormat(targetFormat), - // Get the semantic format of the source - ...getSemanticFormat(sourceFormat), - // The text color of the hyperlink should not be merged and - // we should always retain the source text color - ...getHyperlinkTextColor(sourceFormat), - }; + switch (applyDefaultFormatOption) { + case 'mergeAll': + case 'preferSource': + return { ...getSegmentFormatInLinkFormat(targetFormat), ...sourceFormat }; + case 'keepSourceEmphasisFormat': + return { + // Hyperlink segment format contains other attributes such as LinkFormat + // so we have to retain them + ...getFormatWithoutSegmentFormat(sourceFormat), + // Link format only have Text color, background color, Underline, but only + // text color + background color should be merged from the target + ...getSegmentFormatInLinkFormat(targetFormat), + // Get the semantic format of the source + ...getSemanticFormat(sourceFormat), + // The text color of the hyperlink should not be merged and + // we should always retain the source text color + ...getHyperlinkTextColor(sourceFormat), + }; + case 'preferTarget': + return { ...sourceFormat, ...getSegmentFormatInLinkFormat(targetFormat) }; + } } function mergeSegmentFormat( - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', + applyDefaultFormatOption: MergeFormatTypes, targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { - return applyDefaultFormatOption == 'mergeAll' - ? { ...targetFormat, ...sourceFormat } - : { - ...getFormatWithoutSegmentFormat(sourceFormat), - ...targetFormat, - ...getSemanticFormat(sourceFormat), - }; + switch (applyDefaultFormatOption) { + case 'mergeAll': + case 'preferSource': + return { ...targetFormat, ...sourceFormat }; + case 'preferTarget': + return { ...sourceFormat, ...targetFormat }; + case 'keepSourceEmphasisFormat': + return { + ...getFormatWithoutSegmentFormat(sourceFormat), + ...targetFormat, + ...getSemanticFormat(sourceFormat), + }; + } } function getSemanticFormat(segmentFormat: ContentModelSegmentFormat): ContentModelSegmentFormat { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 6d55c59b8d7..f806f20e84a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -4052,4 +4052,1759 @@ describe('mergeModel', () => { ], }); }); + + // #region preferTarget + + it('Use customized insert position', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker({ fontSize: '10pt' }); + const marker3 = createSelectionMarker(); + + para1.segments.push(marker1, text1, marker2, text2, marker3); + majorModel.blocks.push(para1); + + const newPara = createParagraph(); + const newText = createText('new text'); + + newPara.segments.push(newText); + sourceModel.blocks.push(newPara); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', + format: {}, + }, + marker2, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker: marker2, + paragraph, + path: [majorModel], + }); + }); + + it('Merge with default format paragraph and paragraph with decorator, preferTarget', () => { + const MockedFormat = { + fontFamily: 'Target', + fontWeight: 'Target', + italic: 'Target', + underline: 'Target', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourcefontSize', + }, + }, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferTarget', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'Target', + fontWeight: 'Target', + italic: 'Target' as any, + underline: 'Target' as any, + fontSize: 'sourcefontSize', + }, + }, + marker, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + format: MockedFormat, + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Table + Paragraph', () => { + const majorModel = createContentModelDocument(); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferTarget', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + marker, + ], + format: {}, + segmentFormat: { fontFamily: 'Calibri', fontSize: '11pt', textColor: 'black' }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + paragraph, + ], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge two Images', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const newImage1: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage, newImage1], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + newImage1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage, newImage1], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge into a paragraph with image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const image: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const marker = createSelectionMarker(); + + para1.segments.push(image, marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [image], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + image, + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [image, newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Link Format with preferTarget option', () => { + const newTarget: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const mergeLinkSourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + mergeModel(newTarget, mergeLinkSourceModel, undefined, { + mergeFormat: 'preferTarget', + }); + + const para = newTarget.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + underline: true, + }, + dataset: {}, + }); + }); + + it('Merge Link Format with preferTarget option 2', () => { + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'preferTarget', + }); + + const para = targetModel.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + backgroundColor: 'rgb(32, 31, 30)', + }, + dataset: {}, + }); + }); + // #endregion + + // #region preferSource + + it('Use customized insert position', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker({ fontSize: '10pt' }); + const marker3 = createSelectionMarker(); + + para1.segments.push(marker1, text1, marker2, text2, marker3); + majorModel.blocks.push(para1); + + const newPara = createParagraph(); + const newText = createText('new text'); + + newPara.segments.push(newText); + sourceModel.blocks.push(newPara); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', + format: {}, + }, + marker2, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker: marker2, + paragraph, + path: [majorModel], + }); + }); + + it('Merge with default format paragraph and paragraph with decorator, preferSource', () => { + const MockedFormat = { + fontFamily: 'mocked', + fontWeight: 'ToBeRemoved', + italic: 'ToBeRemoved', + underline: 'ToBeRemoved', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourcefontSize', + }, + }, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferSource', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontWeight: 'sourceDecoratorFontWeight', + italic: true, + underline: true, + fontFamily: 'sourceFontFamily', + fontSize: 'sourcefontSize', + }, + }, + marker, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + format: MockedFormat, + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Table + Paragraph', () => { + const majorModel = createContentModelDocument(); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferSource', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + marker, + ], + format: {}, + segmentFormat: { fontFamily: 'Calibri', fontSize: '11pt', textColor: 'black' }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + paragraph, + ], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge two Images', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const newImage1: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage, newImage1], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + newImage1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage, newImage1], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge into a paragraph with image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const image: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const marker = createSelectionMarker(); + + para1.segments.push(image, marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [image], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + image, + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [image, newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Link Format with preferSource option', () => { + const newTarget: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const mergeLinkSourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + mergeModel(newTarget, mergeLinkSourceModel, undefined, { + mergeFormat: 'preferSource', + }); + + const para = newTarget.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + underline: true, + }, + dataset: {}, + }); + }); + + it('Merge Link Format with preferSource option 2', () => { + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'preferSource', + }); + + const para = targetModel.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + backgroundColor: 'rgb(32, 31, 30)', + }, + dataset: {}, + }); + }); + + // #endregion }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts index 25a24267e52..50d5945272f 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -19,14 +19,21 @@ export interface MergeModelOption { /** * Use this to decide whether to change the source model format when doing the merge. - * 'mergeAll': segment format of the insert position will be merged into the content that is merged into current model. + * 'mergeAll': (deprecated) Use PreferSource Instead, segment format of the insert position will be merged into the content that is merged into current model. * If the source model already has some format, it will not be overwritten. * 'keepSourceEmphasisFormat': format of the insert position will be set into the content that is merged into current model. * If the source model already has emphasis format, such as, fontWeight, Italic or underline different than the default style, it will not be overwritten. * 'none' the source segment format will not be modified. + * 'preferSource' Will merge both formatting, but source will overwrite target + * 'preferTarget' Will merge both formatting, but target will overwrite source * @default undefined */ - mergeFormat?: 'mergeAll' | 'keepSourceEmphasisFormat' | 'none'; + mergeFormat?: + | 'mergeAll' + | 'keepSourceEmphasisFormat' + | 'none' + | 'preferSource' + | 'preferTarget'; /** * Whether to add a paragraph after the merged content. From 76b72824e506a1ac14c5b2ba3edf7fc8f652e622 Mon Sep 17 00:00:00 2001 From: Vi Nguyen <74168693+vinguyen12@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:31:52 -0700 Subject: [PATCH 25/25] change version and update change --- versions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.json b/versions.json index f8a1b412407..41880c50943 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.0", - "main": "9.11.2", - "legacyAdapter": "8.62.1", + "main": "9.12.0", + "legacyAdapter": "8.62.2", "overrides": {} }