From ab88e730a1f9cf9e1309fea3b30f51ba642e4712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Sun, 24 Mar 2024 12:24:31 -0300 Subject: [PATCH 01/13] auto link --- .../lib/autoFormat/AutoFormatPlugin.ts | 15 +++++++----- .../autoFormat/link/createLinkAfterSpace.ts | 12 ++++++---- .../test/autoFormat/AutoFormatPluginTest.ts | 24 +++++++++++-------- .../link/createLinkAfterSpaceTest.ts | 1 + 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index e531b6011e5..0456affc739 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -113,8 +113,11 @@ export class AutoFormatPlugin implements EditorPlugin { if (rawEvent.inputType === 'insertText') { switch (rawEvent.data) { case ' ': - const { autoBullet, autoNumbering } = this.options; + const { autoBullet, autoNumbering, autoLink } = this.options; keyboardListTrigger(editor, autoBullet, autoNumbering); + if (autoLink) { + createLinkAfterSpace(editor); + } break; } } @@ -124,11 +127,11 @@ export class AutoFormatPlugin implements EditorPlugin { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { - case ' ': - if (this.options.autoLink) { - createLinkAfterSpace(editor); - } - break; + // case ' ': + // if (this.options.autoLink) { + // createLinkAfterSpace(editor); + // } + // break; case 'Backspace': if (this.options.autoUnlink) { unlink(editor, rawEvent); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 6c87cb68b01..1139ff1a905 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -6,7 +6,7 @@ import type { IEditor } from 'roosterjs-content-model-types'; * @internal */ export function createLinkAfterSpace(editor: IEditor) { - editor.formatContentModel(model => { + editor.formatContentModel((model, context) => { const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( model, false /* includingFormatHolder */ @@ -15,22 +15,26 @@ export function createLinkAfterSpace(editor: IEditor) { const length = selectedSegmentsAndParagraphs[0][1].segments.length; const marker = selectedSegmentsAndParagraphs[0][1].segments[length - 1]; const textSegment = selectedSegmentsAndParagraphs[0][1].segments[length - 2]; + if ( marker.segmentType == 'SelectionMarker' && textSegment.segmentType == 'Text' && !textSegment.link ) { const link = textSegment.text.split(' ').pop(); - if (link && matchLink(link)) { + const url = link?.trimRight(); + if (url && link && matchLink(url)) { textSegment.text = textSegment.text.replace(link, ''); - const linkSegment = createText(link, marker.format, { + const linkSegment = createText(url, marker.format, { format: { - href: link, + href: url, underline: true, }, dataset: {}, }); selectedSegmentsAndParagraphs[0][1].segments.splice(length - 1, 0, linkSegment); + context.canUndoByBackspace = true; + return true; } } diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index ea9fc284209..a7d20a61a5f 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -218,7 +218,7 @@ describe('Content Model Auto Format Plugin Test', () => { }); function runTest( - event: KeyDownEvent, + event: EditorInputEvent, shouldCallTrigger: boolean, options?: { autoLink: boolean; @@ -237,17 +237,17 @@ describe('Content Model Auto Format Plugin Test', () => { } it('should call createLinkAfterSpace', () => { - const event: KeyDownEvent = { - eventType: 'keyDown', - rawEvent: { key: ' ', preventDefault: () => {} } as any, + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; runTest(event, true); }); it('should not call createLinkAfterSpace - disable options', () => { - const event: KeyDownEvent = { - eventType: 'keyDown', - rawEvent: { key: ' ', preventDefault: () => {} } as any, + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; runTest(event, false, { autoLink: false, @@ -255,9 +255,13 @@ describe('Content Model Auto Format Plugin Test', () => { }); it('should not call createLinkAfterSpace - not backspace', () => { - const event: KeyDownEvent = { - eventType: 'keyDown', - rawEvent: { key: 'Backspace', preventDefault: () => {} } as any, + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { + data: 'Backspace', + preventDefault: () => {}, + inputType: 'insertText', + } as any, }; runTest(event, false); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index 7c01772f662..c9b58f6ecbe 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -14,6 +14,7 @@ describe('createLinkAfterSpace', () => { newEntities: [], deletedEntities: [], newImages: [], + canUndoByBackspace: true, }); expect(result).toBe(expectedResult); }); From bd069f8eb19baeefe52f7aa2eaa2efc68c83e8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Sun, 24 Mar 2024 12:31:26 -0300 Subject: [PATCH 02/13] remove comment --- .../lib/autoFormat/AutoFormatPlugin.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 0456affc739..eb8c6f192ed 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -127,11 +127,6 @@ export class AutoFormatPlugin implements EditorPlugin { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { - // case ' ': - // if (this.options.autoLink) { - // createLinkAfterSpace(editor); - // } - // break; case 'Backspace': if (this.options.autoUnlink) { unlink(editor, rawEvent); From cfa6326f23a489d233dee5bf501e75cccacecbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 25 Mar 2024 14:40:38 -0300 Subject: [PATCH 03/13] add space --- .../lib/autoFormat/link/createLinkAfterSpace.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 1139ff1a905..1b6dbca858f 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -32,7 +32,11 @@ export function createLinkAfterSpace(editor: IEditor) { }, dataset: {}, }); + selectedSegmentsAndParagraphs[0][1].segments.splice(length - 1, 0, linkSegment); + const spaceText = createText(link.substring(url.length), marker.format); + selectedSegmentsAndParagraphs[0][1].segments.splice(length, 0, spaceText); + context.canUndoByBackspace = true; return true; From 2461c806be3025248171d3311736953bd60741e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 25 Mar 2024 14:55:34 -0300 Subject: [PATCH 04/13] fix --- .../test/autoFormat/link/createLinkAfterSpaceTest.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index c9b58f6ecbe..b670ca3c62b 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -123,6 +123,11 @@ describe('createLinkAfterSpace', () => { dataset: {}, }, }, + { + segmentType: 'Text', + text: '', + format: {}, + }, { segmentType: 'SelectionMarker', isSelected: true, @@ -217,6 +222,11 @@ describe('createLinkAfterSpace', () => { dataset: {}, }, }, + { + segmentType: 'Text', + text: '', + format: {}, + }, { segmentType: 'SelectionMarker', isSelected: true, From 2ef67d7f18c84c960aa6032eab3ef2d651619bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 27 Mar 2024 12:26:52 -0300 Subject: [PATCH 05/13] text --- .../autoFormat/link/createLinkAfterSpace.ts | 68 ++++++++------ .../lib/pluginUtils/splitTextSegment.ts | 16 ++++ .../link/createLinkAfterSpaceTest.ts | 89 +++++++++++++++++++ .../test/pluginUtils/splitTextSegmentTest.ts | 29 ++++++ 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts create mode 100644 packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 1b6dbca858f..a9f47f44b1f 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,5 +1,6 @@ import { createText, getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { matchLink } from 'roosterjs-content-model-api'; +import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; import type { IEditor } from 'roosterjs-content-model-types'; /** @@ -11,35 +12,52 @@ export function createLinkAfterSpace(editor: IEditor) { model, false /* includingFormatHolder */ ); - if (selectedSegmentsAndParagraphs[0] && selectedSegmentsAndParagraphs[0][1]) { - const length = selectedSegmentsAndParagraphs[0][1].segments.length; - const marker = selectedSegmentsAndParagraphs[0][1].segments[length - 1]; - const textSegment = selectedSegmentsAndParagraphs[0][1].segments[length - 2]; + if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) { + const markerIndex = selectedSegmentsAndParagraphs[0][1].segments.findIndex( + segment => segment.segmentType == 'SelectionMarker' + ); + const paragraph = selectedSegmentsAndParagraphs[0][1]; + if (markerIndex > 0) { + const textSegment = paragraph.segments[markerIndex - 1]; + const marker = paragraph.segments[markerIndex]; + if ( + marker.segmentType == 'SelectionMarker' && + textSegment && + textSegment.segmentType == 'Text' && + !textSegment.link + ) { + const link = textSegment.text.split(' ').pop(); + const url = link?.trim(); - if ( - marker.segmentType == 'SelectionMarker' && - textSegment.segmentType == 'Text' && - !textSegment.link - ) { - const link = textSegment.text.split(' ').pop(); - const url = link?.trimRight(); - if (url && link && matchLink(url)) { - textSegment.text = textSegment.text.replace(link, ''); - const linkSegment = createText(url, marker.format, { - format: { - href: url, - underline: true, - }, - dataset: {}, - }); + if (url && link && matchLink(url)) { + const textBefore = splitTextSegment( + textSegment.text, + 0, + textSegment.text.length - link.trimLeft().length, + marker.format + ); - selectedSegmentsAndParagraphs[0][1].segments.splice(length - 1, 0, linkSegment); - const spaceText = createText(link.substring(url.length), marker.format); - selectedSegmentsAndParagraphs[0][1].segments.splice(length, 0, spaceText); + const spaceTextAfter = splitTextSegment( + textSegment.text, + textSegment.text.trimRight().length, + undefined, + marker.format + ); + const linkSegment = createText(url, marker.format, { + format: { + href: url, + underline: true, + }, + dataset: {}, + }); + paragraph.segments.splice(markerIndex - 1, 1, textBefore); + paragraph.segments.splice(markerIndex, 0, linkSegment); + paragraph.segments.splice(markerIndex + 1, 0, spaceTextAfter); - context.canUndoByBackspace = true; + context.canUndoByBackspace = true; - return true; + return true; + } } } } diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts new file mode 100644 index 00000000000..99d6b2e0e08 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts @@ -0,0 +1,16 @@ +import { createText } from 'roosterjs-content-model-dom'; +import type { ContentModelSegmentFormat, ContentModelText } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function splitTextSegment( + text: string, + start: number, + end?: number, + format?: ContentModelSegmentFormat +): ContentModelText { + const newText = text.substring(start, end); + const newSegment = createText(newText, format); + return newSegment; +} diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index b670ca3c62b..2606218cff7 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -267,4 +267,93 @@ describe('createLinkAfterSpace', () => { }; runTest(input, input, false); }); + + it('link after link', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'www.bing.com', + format: {}, + link: { + format: { + href: 'www.bing.com', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: ' www.bing.com', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'www.bing.com', + format: {}, + link: { + format: { + href: 'www.bing.com', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + { + segmentType: 'Text', + text: 'www.bing.com', + format: {}, + link: { + format: { + href: 'www.bing.com', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: '', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts new file mode 100644 index 00000000000..d68bd3bc822 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts @@ -0,0 +1,29 @@ +import { ContentModelSegmentFormat, ContentModelText } from 'roosterjs-content-model-types'; +import { splitTextSegment } from '../../lib/pluginUtils/splitTextSegment'; + +describe('splitTextSegment', () => { + function runTest( + text: string, + start: number, + expectedResult: ContentModelText, + end?: number | undefined, + format?: ContentModelSegmentFormat | undefined + ) { + const result = splitTextSegment(text, start, end, format); + expect(result).toEqual(expectedResult); + } + + it('splitTextSegment with end', () => { + runTest('test', 0, { text: 'te', format: {}, segmentType: 'Text' }, 2, undefined); + }); + + it('splitTextSegment without end', () => { + runTest('test', 2, { text: 'st', format: {}, segmentType: 'Text' }); + }); + + it('splitTextSegment with format', () => { + runTest('test', 0, { text: 'te', format: { italic: true }, segmentType: 'Text' }, 2, { + italic: true, + }); + }); +}); From 22cf37af00dc1387b3b8f5106883395d3eb158f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 27 Mar 2024 15:35:11 -0300 Subject: [PATCH 06/13] split text --- .../autoFormat/link/createLinkAfterSpace.ts | 24 ++--- .../lib/pluginUtils/splitTextSegment.ts | 40 ++++++-- .../link/createLinkAfterSpaceTest.ts | 9 ++ .../test/pluginUtils/splitTextSegmentTest.ts | 97 ++++++++++++++++--- 4 files changed, 133 insertions(+), 37 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index a9f47f44b1f..9884fee7ff4 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,4 +1,4 @@ -import { createText, getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { matchLink } from 'roosterjs-content-model-api'; import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; import type { IEditor } from 'roosterjs-content-model-types'; @@ -30,29 +30,19 @@ export function createLinkAfterSpace(editor: IEditor) { const url = link?.trim(); if (url && link && matchLink(url)) { - const textBefore = splitTextSegment( - textSegment.text, - 0, + const linkSegment = splitTextSegment( + textSegment, + paragraph, textSegment.text.length - link.trimLeft().length, - marker.format + textSegment.text.trimRight().length ); - - const spaceTextAfter = splitTextSegment( - textSegment.text, - textSegment.text.trimRight().length, - undefined, - marker.format - ); - const linkSegment = createText(url, marker.format, { + linkSegment.link = { format: { href: url, underline: true, }, dataset: {}, - }); - paragraph.segments.splice(markerIndex - 1, 1, textBefore); - paragraph.segments.splice(markerIndex, 0, linkSegment); - paragraph.segments.splice(markerIndex + 1, 0, spaceTextAfter); + }; context.canUndoByBackspace = true; diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts index 99d6b2e0e08..c7995ac4b8d 100644 --- a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts +++ b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts @@ -1,16 +1,42 @@ import { createText } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat, ContentModelText } from 'roosterjs-content-model-types'; +import type { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; /** * @internal */ export function splitTextSegment( - text: string, + textSegment: ContentModelText, + parent: ContentModelParagraph, start: number, - end?: number, - format?: ContentModelSegmentFormat + end: number ): ContentModelText { - const newText = text.substring(start, end); - const newSegment = createText(newText, format); - return newSegment; + const text = textSegment.text; + const index = parent.segments.indexOf(textSegment); + const textBefore = createText( + text.substring(0, start), + textSegment.format, + textSegment.link, + textSegment.code + ); + const middleText = createText( + text.substring(start, end), + textSegment.format, + textSegment.link, + textSegment.code + ); + const textAfter = createText( + text.substring(end), + textSegment.format, + textSegment.link, + textSegment.code + ); + textBefore.isSelected = textSegment.isSelected; + middleText.isSelected = textSegment.isSelected; + textAfter.isSelected = textSegment.isSelected; + + parent.segments.splice(index, 1, textBefore); + parent.segments.splice(index + 1, 0, middleText); + parent.segments.splice(index + 2, 0, textAfter); + + return middleText; } diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index 2606218cff7..0825aa8c219 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -110,11 +110,13 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: '', format: {}, + isSelected: undefined, }, { segmentType: 'Text', text: 'www.bing.com', format: {}, + isSelected: undefined, link: { format: { href: 'www.bing.com', @@ -127,6 +129,7 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: '', format: {}, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -209,11 +212,13 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: 'this is the link ', format: {}, + isSelected: undefined, }, { segmentType: 'Text', text: 'www.bing.com', format: {}, + isSelected: undefined, link: { format: { underline: true, @@ -226,6 +231,7 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: '', format: {}, + isSelected: undefined, }, { segmentType: 'SelectionMarker', @@ -325,11 +331,13 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: ' ', format: {}, + isSelected: undefined, }, { segmentType: 'Text', text: 'www.bing.com', format: {}, + isSelected: undefined, link: { format: { href: 'www.bing.com', @@ -342,6 +350,7 @@ describe('createLinkAfterSpace', () => { segmentType: 'Text', text: '', format: {}, + isSelected: undefined, }, { segmentType: 'SelectionMarker', diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts index d68bd3bc822..e3066455144 100644 --- a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts +++ b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts @@ -1,29 +1,100 @@ -import { ContentModelSegmentFormat, ContentModelText } from 'roosterjs-content-model-types'; import { splitTextSegment } from '../../lib/pluginUtils/splitTextSegment'; +import { + ContentModelParagraph, + ContentModelSegmentFormat, + ContentModelText, +} from 'roosterjs-content-model-types'; describe('splitTextSegment', () => { function runTest( - text: string, + textSegment: ContentModelText, + parent: ContentModelParagraph, start: number, - expectedResult: ContentModelText, - end?: number | undefined, - format?: ContentModelSegmentFormat | undefined + end: number, + expectedResult: ContentModelText ) { - const result = splitTextSegment(text, start, end, format); + const result = splitTextSegment(textSegment, parent, start, end); expect(result).toEqual(expectedResult); } - it('splitTextSegment with end', () => { - runTest('test', 0, { text: 'te', format: {}, segmentType: 'Text' }, 2, undefined); + it('splitTextSegment', () => { + const textSegment: ContentModelText = { + text: 'test test', + format: {}, + segmentType: 'Text', + }; + const parent: ContentModelParagraph = { + segments: [textSegment], + blockType: 'Paragraph', + format: {}, + }; + runTest(textSegment, parent, 0, 2, { + text: 'te', + format: {}, + segmentType: 'Text', + isSelected: undefined, + }); }); - it('splitTextSegment without end', () => { - runTest('test', 2, { text: 'st', format: {}, segmentType: 'Text' }); + it('splitTextSegment with selection', () => { + const textSegment: ContentModelText = { + text: 'test test', + format: {}, + segmentType: 'Text', + isSelected: true, + }; + const parent: ContentModelParagraph = { + segments: [textSegment], + blockType: 'Paragraph', + format: {}, + }; + runTest(textSegment, parent, 0, 2, { + text: 'te', + format: {}, + segmentType: 'Text', + isSelected: true, + }); }); - it('splitTextSegment with format', () => { - runTest('test', 0, { text: 'te', format: { italic: true }, segmentType: 'Text' }, 2, { - italic: true, + it('splitTextSegment with decorators', () => { + const textSegment: ContentModelText = { + text: 'test test', + format: {}, + segmentType: 'Text', + isSelected: true, + link: { + format: { + href: 'test', + }, + dataset: {}, + }, + code: { + format: { + fontFamily: 'Consolas', + }, + }, + }; + const parent: ContentModelParagraph = { + segments: [textSegment], + blockType: 'Paragraph', + format: {}, + }; + runTest(textSegment, parent, 0, 2, { + text: 'te', + format: {}, + segmentType: 'Text', + isSelected: true, + link: { + format: { + href: 'test', + }, + dataset: {}, + }, + code: { + format: { + fontFamily: 'Consolas', + }, + }, }); }); }); From ce4d65b3374955ec6824d530cab116460bdfd0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 28 Mar 2024 11:26:31 -0300 Subject: [PATCH 07/13] refactor --- .../lib/pluginUtils/splitTextSegment.ts | 41 ++++++++++--------- .../link/createLinkAfterSpaceTest.ts | 24 ----------- .../test/pluginUtils/splitTextSegmentTest.ts | 6 +-- 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts index c7995ac4b8d..d407a8d04e0 100644 --- a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts +++ b/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts @@ -12,31 +12,32 @@ export function splitTextSegment( ): ContentModelText { const text = textSegment.text; const index = parent.segments.indexOf(textSegment); - const textBefore = createText( - text.substring(0, start), - textSegment.format, - textSegment.link, - textSegment.code - ); - const middleText = createText( + const middleSegment = createText( text.substring(start, end), textSegment.format, textSegment.link, textSegment.code ); - const textAfter = createText( - text.substring(end), - textSegment.format, - textSegment.link, - textSegment.code - ); - textBefore.isSelected = textSegment.isSelected; - middleText.isSelected = textSegment.isSelected; - textAfter.isSelected = textSegment.isSelected; - parent.segments.splice(index, 1, textBefore); - parent.segments.splice(index + 1, 0, middleText); - parent.segments.splice(index + 2, 0, textAfter); + const newSegments: ContentModelText[] = [middleSegment]; + if (start > 0) { + newSegments.unshift( + createText( + text.substring(0, start), + textSegment.format, + textSegment.link, + textSegment.code + ) + ); + } + if (end < text.length) { + newSegments.push( + createText(text.substring(end), textSegment.format, textSegment.link, textSegment.code) + ); + } + + newSegments.forEach(segment => (segment.isSelected = textSegment.isSelected)); + parent.segments.splice(index, 1, ...newSegments); - return middleText; + return middleSegment; } diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index 0825aa8c219..9aededb6595 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -106,12 +106,6 @@ describe('createLinkAfterSpace', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'Text', - text: '', - format: {}, - isSelected: undefined, - }, { segmentType: 'Text', text: 'www.bing.com', @@ -125,12 +119,6 @@ describe('createLinkAfterSpace', () => { dataset: {}, }, }, - { - segmentType: 'Text', - text: '', - format: {}, - isSelected: undefined, - }, { segmentType: 'SelectionMarker', isSelected: true, @@ -227,12 +215,6 @@ describe('createLinkAfterSpace', () => { dataset: {}, }, }, - { - segmentType: 'Text', - text: '', - format: {}, - isSelected: undefined, - }, { segmentType: 'SelectionMarker', isSelected: true, @@ -346,12 +328,6 @@ describe('createLinkAfterSpace', () => { dataset: {}, }, }, - { - segmentType: 'Text', - text: '', - format: {}, - isSelected: undefined, - }, { segmentType: 'SelectionMarker', isSelected: true, diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts index e3066455144..5d9363fc413 100644 --- a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts +++ b/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts @@ -1,9 +1,5 @@ +import { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; import { splitTextSegment } from '../../lib/pluginUtils/splitTextSegment'; -import { - ContentModelParagraph, - ContentModelSegmentFormat, - ContentModelText, -} from 'roosterjs-content-model-types'; describe('splitTextSegment', () => { function runTest( From 2e64c9070ce5f2d78eed502ddbfd727bd4280c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 28 Mar 2024 14:50:56 -0300 Subject: [PATCH 08/13] disable auto format feature --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 8 +++++- .../lib/autoFormat/AutoFormatPlugin.ts | 12 ++++---- .../test/autoFormat/AutoFormatPluginTest.ts | 28 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 2cc9cd9a796..597e9e14d72 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -475,7 +475,13 @@ export class MainPane extends React.Component<{}, MainPaneState> { watermarkText, } = this.state.initState; return [ - pluginList.autoFormat && new AutoFormatPlugin(), + pluginList.autoFormat && + new AutoFormatPlugin({ + autoBullet: true, + autoNumbering: true, + autoUnlink: true, + autoLink: true, + }), pluginList.edit && new EditPlugin(), pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index e531b6011e5..51d3606b5a0 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -40,10 +40,10 @@ export type AutoFormatOptions = { * @internal */ const DefaultOptions: Required = { - autoBullet: true, - autoNumbering: true, + autoBullet: false, + autoNumbering: false, autoUnlink: false, - autoLink: true, + autoLink: false, }; /** @@ -55,8 +55,10 @@ export class AutoFormatPlugin implements EditorPlugin { /** * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: - * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. - * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. + * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to false. + * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to false. + * - autoLink: A boolean that enables or disables automatic hyperlink creation when pasting or typing content. Defaults to false. + * - autoUnlink: A boolean that enables or disables automatic hyperlink removal when pressing backspace. Defaults to false. */ constructor(private options: AutoFormatOptions = DefaultOptions) {} diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index ea9fc284209..7d64083ac99 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -60,7 +60,10 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true); + runTest(event, true, { + autoBullet: true, + autoNumbering: true, + }); }); it('should not trigger keyboardListTrigger', () => { @@ -68,7 +71,10 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: '*', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, false); + runTest(event, false, { + autoBullet: true, + autoNumbering: true, + }); }); it('should not trigger keyboardListTrigger', () => { @@ -135,7 +141,9 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'contentChanged', source: 'Paste', }; - runTest(event, true); + runTest(event, true, { + autoLink: true, + }); }); it('should not call createLink - autolink disabled', () => { @@ -151,7 +159,9 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'contentChanged', source: 'Format', }; - runTest(event, false); + runTest(event, false, { + autoLink: true, + }); }); }); @@ -241,7 +251,9 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'keyDown', rawEvent: { key: ' ', preventDefault: () => {} } as any, }; - runTest(event, true); + runTest(event, true, { + autoLink: true, + }); }); it('should not call createLinkAfterSpace - disable options', () => { @@ -254,12 +266,14 @@ describe('Content Model Auto Format Plugin Test', () => { }); }); - it('should not call createLinkAfterSpace - not backspace', () => { + it('should not call createLinkAfterSpace - not space', () => { const event: KeyDownEvent = { eventType: 'keyDown', rawEvent: { key: 'Backspace', preventDefault: () => {} } as any, }; - runTest(event, false); + runTest(event, false, { + autoLink: true, + }); }); }); }); From a97fb1c36de7e0860b3b03e0bf1abb1c689e0cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 28 Mar 2024 15:07:23 -0300 Subject: [PATCH 09/13] fix url --- .../lib/autoFormat/link/createLinkAfterSpace.ts | 8 ++++---- .../test/autoFormat/link/createLinkAfterSpaceTest.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 9884fee7ff4..d529474d025 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,7 +1,7 @@ import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { matchLink } from 'roosterjs-content-model-api'; import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; -import type { IEditor } from 'roosterjs-content-model-types'; +import type { IEditor, LinkData } from 'roosterjs-content-model-types'; /** * @internal @@ -28,8 +28,8 @@ export function createLinkAfterSpace(editor: IEditor) { ) { const link = textSegment.text.split(' ').pop(); const url = link?.trim(); - - if (url && link && matchLink(url)) { + let linkData: LinkData | null = null; + if (url && link && (linkData = matchLink(url))) { const linkSegment = splitTextSegment( textSegment, paragraph, @@ -38,7 +38,7 @@ export function createLinkAfterSpace(editor: IEditor) { ); linkSegment.link = { format: { - href: url, + href: linkData.normalizedUrl, underline: true, }, dataset: {}, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index 9aededb6595..a93df5e9573 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -113,7 +113,7 @@ describe('createLinkAfterSpace', () => { isSelected: undefined, link: { format: { - href: 'www.bing.com', + href: 'http://www.bing.com', underline: true, }, dataset: {}, @@ -210,7 +210,7 @@ describe('createLinkAfterSpace', () => { link: { format: { underline: true, - href: 'www.bing.com', + href: 'http://www.bing.com', }, dataset: {}, }, @@ -322,7 +322,7 @@ describe('createLinkAfterSpace', () => { isSelected: undefined, link: { format: { - href: 'www.bing.com', + href: 'http://www.bing.com', underline: true, }, dataset: {}, From 20f220d39f378119af07131e63a1fc9e5dce72ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Mar 2024 12:19:49 -0700 Subject: [PATCH 10/13] Bump express from 4.18.2 to 4.19.2 (#2542) Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2) --- updated-dependencies: - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jiuqing Song --- yarn.lock | 53 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 375fab391ed..89044413ebf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1439,7 +1439,25 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -body-parser@1.20.1, body-parser@^1.19.0: +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +body-parser@^1.19.0: version "1.20.1" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== @@ -1862,6 +1880,11 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" @@ -1874,10 +1897,10 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== cookie@~0.4.1: version "0.4.2" @@ -2723,16 +2746,16 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: homedir-polyfill "^1.0.1" express@^4.17.1: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.5.0" + cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -5562,6 +5585,16 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" From 9fde045d364c673583d0e7abcdb56ac0d4bf9c1d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 28 Mar 2024 13:57:19 -0700 Subject: [PATCH 11/13] Fix 264234 (#2543) --- .../createDomToModelContextForSanitizing.ts | 2 +- .../createModelFromHtml.ts | 11 ++-- .../createModelFromHtmlTest.ts | 54 ++++++++++++++++++- .../processPastedContentFromExcelTest.ts | 1 - 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts index 48e3531e3ea..439bb3466e4 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts @@ -30,7 +30,7 @@ export function createDomToModelContextForSanitizing( document: Document, defaultFormat?: ContentModelSegmentFormat, defaultOption?: DomToModelOption, - additionalSanitizingOption?: DomToModelOptionForSanitizing + additionalSanitizingOption?: Partial ): DomToModelContext { const sanitizingOption: DomToModelOptionForSanitizing = { ...DefaultSanitizingOption, diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts index 7164fa7cbfa..68b34cbc3a9 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts @@ -4,7 +4,7 @@ import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-cont import type { ContentModelDocument, ContentModelSegmentFormat, - DomToModelOption, + DomToModelOptionForSanitizing, TrustedHTMLHandler, } from 'roosterjs-content-model-types'; @@ -17,7 +17,7 @@ import type { */ export function createModelFromHtml( html: string, - options?: DomToModelOption, + options?: Partial, trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat ): ContentModelDocument { @@ -26,7 +26,12 @@ export function createModelFromHtml( : null; if (doc?.body) { - const context = createDomToModelContextForSanitizing(doc, defaultSegmentFormat, options); + const context = createDomToModelContextForSanitizing( + doc, + defaultSegmentFormat, + undefined /*defaultOptions*/, + options + ); const cssRules = doc ? retrieveCssRules(doc) : []; convertInlineCss(doc, cssRules); diff --git a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createModelFromHtmlTest.ts b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createModelFromHtmlTest.ts index d7874f315d6..b109ed62715 100644 --- a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createModelFromHtmlTest.ts +++ b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createModelFromHtmlTest.ts @@ -2,8 +2,12 @@ import * as convertInlineCss from '../../../lib/command/createModelFromHtml/conv import * as createDomToModelContextForSanitizing from '../../../lib/command/createModelFromHtml/createDomToModelContextForSanitizing'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as parseFormat from 'roosterjs-content-model-dom/lib/domToModel/utils/parseFormat'; -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createModelFromHtml } from '../../../lib/command/createModelFromHtml/createModelFromHtml'; +import { + ContentModelGeneralBlock, + ContentModelSegmentFormat, + ElementProcessor, +} from 'roosterjs-content-model-types'; describe('createModelFromHtml', () => { it('Empty html, no options', () => { @@ -134,6 +138,7 @@ describe('createModelFromHtml', () => { expect(createContextSpy).toHaveBeenCalledWith( mockedDoc, mockedDefaultSegmentFormat, + undefined, mockedOptions ); expect(domToContentModelSpy).toHaveBeenCalledWith('BODY' as any, mockedContext); @@ -202,4 +207,51 @@ describe('createModelFromHtml', () => { expect(retrieveCssRulesSpy).not.toHaveBeenCalled(); expect(convertInlineCssSpy).not.toHaveBeenCalled(); }); + + it('Treat DIV with id as general block, and preserve id', () => { + const divProcessor: ElementProcessor = (group, element, context) => { + const processor = element.id + ? context.elementProcessors['*'] + : context.defaultElementProcessors.div; + + processor?.(group, element, context); + }; + const model = createModelFromHtml('
test
', { + processorOverride: { + div: divProcessor, + }, + attributeSanitizers: { + id: true, + }, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + element: jasmine.anything(), + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + isImplicit: true, + }, + ], + format: {}, + }, + ], + }); + expect((model.blocks[0] as ContentModelGeneralBlock).element.outerHTML).toBe( + '
test
' + ); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index 20ff31b27cc..449cb65591b 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -6,7 +6,6 @@ import { contentModelToDom, createDomToModelContext, createModelToDomContext, - createTable, createTableCell, domToContentModel, moveChildNodes, From ee44e5432c19c8957cdf0f3a5bc60a974254e203 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 28 Mar 2024 14:08:57 -0700 Subject: [PATCH 12/13] Leverage DOMInsertPoint, remove posContainer/offset (#2538) --- .../formatContentModel/formatContentModel.ts | 12 ++- .../lib/corePlugin/format/FormatPlugin.ts | 4 +- .../lib/corePlugin/undo/UndoPlugin.ts | 13 ++- .../formatContentModelTest.ts | 78 +++++++++++------ .../test/corePlugin/undo/UndoPluginTest.ts | 87 +++++++------------ .../domToModel/utils/addSelectionMarker.ts | 4 +- .../processors/childProcessorTest.ts | 12 ++- .../processors/textProcessorTest.ts | 18 ++-- .../utils/addSelectionMarkerTest.ts | 6 +- .../lib/pluginState/FormatPluginState.ts | 10 +-- .../lib/pluginState/UndoPluginState.ts | 10 +-- .../lib/editor/EditorAdapter.ts | 6 +- 12 files changed, 132 insertions(+), 128 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 6e3df1385c0..001309b4972 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -71,8 +71,10 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) core.api.triggerEvent(core, eventData, true /*broadcast*/); if (canUndoByBackspace && selection?.type == 'range') { - core.undo.posContainer = selection.range.startContainer; - core.undo.posOffset = selection.range.startOffset; + core.undo.autoCompleteInsertPoint = { + node: selection.range.startContainer, + offset: selection.range.startOffset, + }; } if (shouldAddSnapshot) { @@ -123,8 +125,10 @@ function handlePendingFormat( if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { core.format.pendingFormat = { format: { ...pendingFormat }, - posContainer: selection.range.startContainer, - posOffset: selection.range.startOffset, + insertPoint: { + node: selection.range.startContainer, + offset: selection.range.startOffset, + }, }; } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts index b4c0a279659..973bc685c52 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -163,9 +163,9 @@ class FormatPlugin implements PluginWithState { const selection = this.editor.getDOMSelection(); const range = selection?.type == 'range' && selection.range.collapsed ? selection.range : null; - const { posContainer, posOffset } = this.state.pendingFormat; + const { node, offset } = this.state.pendingFormat.insertPoint; - if (range && range.startContainer == posContainer && range.startOffset == posOffset) { + if (range && range.startContainer == node && range.startOffset == offset) { result = true; } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts index 9eccc9f6f5a..df553aa6f57 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts @@ -30,8 +30,7 @@ class UndoPlugin implements PluginWithState { snapshotsManager: createSnapshotsManager(options.snapshots), isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }; } @@ -129,8 +128,7 @@ class UndoPlugin implements PluginWithState { if (evt.key == Backspace && !evt.ctrlKey && this.canUndoAutoComplete(editor)) { evt.preventDefault(); undo(editor); - this.state.posContainer = null; - this.state.posOffset = null; + this.state.autoCompleteInsertPoint = null; this.state.lastKeyPress = evt.key; } else if (!evt.defaultPrevented) { const selection = editor.getDOMSelection(); @@ -232,15 +230,14 @@ class UndoPlugin implements PluginWithState { this.state.snapshotsManager.canUndoAutoComplete() && selection?.type == 'range' && selection.range.collapsed && - selection.range.startContainer == this.state.posContainer && - selection.range.startOffset == this.state.posOffset + selection.range.startContainer == this.state.autoCompleteInsertPoint?.node && + selection.range.startOffset == this.state.autoCompleteInsertPoint.offset ); } private addUndoSnapshot() { this.editor?.takeSnapshot(); - this.state.posContainer = null; - this.state.posOffset = null; + this.state.autoCompleteInsertPoint = null; } private isCtrlOrMetaPressed(editor: IEditor, event: KeyboardEvent) { diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 0a57860e19d..32b650ad466 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -583,8 +583,10 @@ describe('formatContentModel', () => { it('Has pending format, callback returns true, preserve pending format', () => { core.format.pendingFormat = { format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }; formatContentModel(core, (model, context) => { @@ -594,16 +596,20 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, } as any); }); it('Has pending format, callback returns false, preserve pending format', () => { core.format.pendingFormat = { format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }; formatContentModel(core, (model, context) => { @@ -613,8 +619,10 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, } as any); }); @@ -626,8 +634,10 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, }); }); @@ -639,16 +649,20 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, }); }); it('Has pending format, callback returns true, new format', () => { core.format.pendingFormat = { format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }; formatContentModel(core, (model, context) => { @@ -658,16 +672,20 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, }); }); it('Has pending format, callback returns false, new format', () => { core.format.pendingFormat = { format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }; formatContentModel(core, (model, context) => { @@ -677,16 +695,20 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, - posContainer: mockedStartContainer2, - posOffset: mockedStartOffset2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, }); }); it('Has pending format, callback returns false, preserve format, selection is not collapsed', () => { core.format.pendingFormat = { format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }; core.api.getDOMSelection = () => @@ -706,8 +728,10 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, - posContainer: mockedStartContainer1, - posOffset: mockedStartOffset1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, }); }); }); @@ -809,8 +833,10 @@ describe('formatContentModel', () => { expect(core.undo).toEqual({ isNested: false, snapshotsManager: {}, - posContainer: mockedContainer, - posOffset: mockedOffset, + autoCompleteInsertPoint: { + node: mockedContainer, + offset: mockedOffset, + }, } as any); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts index 1309523cdda..eb0cf93c732 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts @@ -58,8 +58,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(createSnapshotsManagerSpy).toHaveBeenCalledWith(undefined); @@ -81,8 +80,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(createSnapshotsManagerSpy).toHaveBeenCalledWith(mockedSnapshots); @@ -217,8 +215,7 @@ describe('UndoPlugin', () => { it('Not handled exclusively for KeyDown event, selection is not the same', () => { const state = plugin.getState(); - state.posContainer = 'P1' as any; - state.posOffset = 'O1' as any; + state.autoCompleteInsertPoint = { node: 'P1' as any, offset: 'O1' as any }; canUndoAutoCompleteSpy.and.returnValue(true); getDOMSelectionSpy.and.returnValue({ @@ -245,8 +242,7 @@ describe('UndoPlugin', () => { it('Handled exclusively for KeyDown event', () => { const state = plugin.getState(); - state.posContainer = 'P1' as any; - state.posOffset = 'O1' as any; + state.autoCompleteInsertPoint = { node: 'P1' as any, offset: 'O1' as any }; canUndoAutoCompleteSpy.and.returnValue(true); getDOMSelectionSpy.and.returnValue({ @@ -296,8 +292,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalse(); @@ -318,8 +313,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalse(); @@ -340,8 +334,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalse(); @@ -362,8 +355,7 @@ describe('UndoPlugin', () => { const state = plugin.getState(); - state.posContainer = 'C1' as any; - state.posOffset = 'O1' as any; + state.autoCompleteInsertPoint = { node: 'C1' as any, offset: 'O1' as any }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -384,8 +376,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Backspace', }); expect(mockedSnapshotsManager.hasNewContent).toBeFalse(); @@ -418,8 +409,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Backspace', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -448,8 +438,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Delete', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -478,8 +467,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -511,8 +499,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -545,8 +532,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Backspace', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -576,8 +562,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -611,8 +596,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Enter', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -646,8 +630,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'A', }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -677,8 +660,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: ' ', }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -708,8 +690,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: ' ', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -736,8 +717,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'Enter', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -764,8 +744,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'A', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -789,8 +768,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -815,8 +793,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: true, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -838,8 +815,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -861,8 +837,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -884,8 +859,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeFalsy(); @@ -907,8 +881,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: null, }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -935,8 +908,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'B', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); @@ -963,8 +935,7 @@ describe('UndoPlugin', () => { snapshotsManager: mockedSnapshotsManager, isRestoring: false, isNested: false, - posContainer: null, - posOffset: null, + autoCompleteInsertPoint: null, lastKeyPress: 'A', }); expect(mockedSnapshotsManager.hasNewContent).toBeTrue(); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts index 9c23e18e7ca..5f750aa301c 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts @@ -34,8 +34,8 @@ export function addSelectionMarker( const pendingFormat = context.pendingFormat && - context.pendingFormat.posContainer === container && - context.pendingFormat.posOffset === offset + context.pendingFormat.insertPoint.node === container && + context.pendingFormat.insertPoint.offset === offset ? context.pendingFormat.format : undefined; const segmentFormat = { diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts index f34c80383cc..64124c3a65e 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts @@ -127,8 +127,10 @@ describe('childProcessor', () => { format: { a: 'a', } as any, - posContainer: div, - posOffset: 0, + insertPoint: { + node: div, + offset: 0, + }, }; childProcessor(doc, div, context); @@ -173,8 +175,10 @@ describe('childProcessor', () => { format: { a: 'a', } as any, - posContainer: div, - posOffset: 1, + insertPoint: { + node: div, + offset: 1, + }, }; childProcessor(doc, div, context); diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index b84b8a14172..f264ce94157 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -754,8 +754,10 @@ describe('textProcessor', () => { format: { a: 'a', } as any, - posContainer: text, - posOffset: 2, + insertPoint: { + node: text, + offset: 2, + }, }; textProcessor(doc, text, context); @@ -813,8 +815,10 @@ describe('textProcessor', () => { format: { a: 'a', } as any, - posContainer: text, - posOffset: 3, + insertPoint: { + node: text, + offset: 3, + }, }; textProcessor(doc, text, context); @@ -874,8 +878,10 @@ describe('textProcessor', () => { format: { a: 'a', } as any, - posContainer: text, - posOffset: 3, + insertPoint: { + node: text, + offset: 3, + }, }; textProcessor(doc, text, context); diff --git a/packages/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts index 9b83269e9ff..fb7f401f363 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts @@ -218,8 +218,10 @@ describe('addSelectionMarker', () => { c: 'c3', e: 'e', } as any, - posContainer: mockedContainer, - posOffset: mockedOffset, + insertPoint: { + node: mockedContainer, + offset: mockedOffset, + }, }, }); diff --git a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 05749a346f6..e0581094541 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -1,3 +1,4 @@ +import type { DOMInsertPoint } from '../selection/DOMSelection'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; /** @@ -10,14 +11,9 @@ export interface PendingFormat { format: ContentModelSegmentFormat; /** - * Container node of pending format + * Insert point of pending format */ - posContainer: Node; - - /** - * Offset under container node of pending format - */ - posOffset: number; + insertPoint: DOMInsertPoint; } /** diff --git a/packages/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts index 040e57d44f7..bf0ad3234ea 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts @@ -1,4 +1,5 @@ import type { SnapshotsManager } from '../parameter/SnapshotsManager'; +import type { DOMInsertPoint } from '../selection/DOMSelection'; /** * The state object for UndoPlugin @@ -20,14 +21,9 @@ export interface UndoPluginState { isNested: boolean; /** - * Container after last auto complete. Undo autoComplete only works if the current position matches this one + * Insert point after last auto complete. Undo autoComplete only works if the current position matches this one */ - posContainer: Node | null; - - /** - * Offset after last auto complete. Undo autoComplete only works if the current position matches this one - */ - posOffset: number | null; + autoCompleteInsertPoint: DOMInsertPoint | null; /** * Last key user pressed diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 8ee4773dca3..5ada4b80731 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -772,8 +772,10 @@ export class EditorAdapter extends Editor implements ILegacyEditor { if (selection?.type == 'range') { core.undo.snapshotsManager.hasNewContent = false; - core.undo.posContainer = selection.range.startContainer; - core.undo.posOffset = selection.range.startOffset; + core.undo.autoCompleteInsertPoint = { + node: selection.range.startContainer, + offset: selection.range.startOffset, + }; } } } From d5d25428129c1a9090609a50e31e36e7872a7f1b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 28 Mar 2024 14:55:55 -0700 Subject: [PATCH 13/13] Fix 262677: Allow insert entity to a specified position without changing selection (#2537) --- .../lib/modelApi/entity/insertEntityModel.ts | 34 +- .../lib/publicApi/entity/insertEntity.ts | 82 +- .../formatInsertPointWithContentModel.ts | 239 ++++++ .../modelApi/entity/insertEntityModelTest.ts | 105 ++- .../test/publicApi/entity/insertEntityTest.ts | 42 +- .../formatInsertPointWithContentModelTest.ts | 379 +++++++++ .../createContentModel/createContentModel.ts | 2 +- .../formatContentModel/formatContentModel.ts | 9 +- .../getPositionFromPath.ts | 11 +- .../lib/corePlugin/selection/normalizePos.ts | 4 +- .../lib/editor/Editor.ts | 26 +- .../createContentModelTest.ts | 14 + .../formatContentModelTest.ts | 32 + .../test/editor/EditorTest.ts | 36 +- .../domToModel/context/defaultProcessors.ts | 2 + .../domToModel/processors/textProcessor.ts | 73 +- .../processors/textWithSelectionProcessor.ts | 42 + .../domToModel/utils/addSelectionMarker.ts | 43 +- .../domToModel/utils/buildSelectionMarker.ts | 60 ++ .../roosterjs-content-model-dom/lib/index.ts | 2 + .../lib/modelApi/common/addSegment.ts | 8 +- .../lib/modelApi/common/addTextSegment.ts | 48 ++ .../textWithSelectionProcessorTest.ts | 741 ++++++++++++++++++ .../utils/buildSelectionMarkerTest.ts | 234 ++++++ .../test/modelApi/common/addSegmentTest.ts | 264 +++++++ .../modelApi/common/addTextSegmentTest.ts | 209 +++++ .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 2 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 2 +- .../lib/context/DomToModelSettings.ts | 6 + .../lib/editor/EditorCore.ts | 3 +- .../lib/editor/IEditor.ts | 7 +- .../segment/ContentModelSelectionMarker.ts | 7 +- .../test/editor/EditorAdapterTest.ts | 2 +- 33 files changed, 2557 insertions(+), 213 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts create mode 100644 packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts create mode 100644 packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts create mode 100644 packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts create mode 100644 packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts create mode 100644 packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts create mode 100644 packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts diff --git a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index 91b2a33aac0..faa023c42a8 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -13,9 +13,9 @@ import type { ContentModelDocument, ContentModelEntity, ContentModelParagraph, - DeleteSelectionResult, FormatContentModelContext, InsertEntityPosition, + InsertPoint, } from 'roosterjs-content-model-types'; /** @@ -27,11 +27,12 @@ export function insertEntityModel( position: InsertEntityPosition, isBlock: boolean, focusAfterEntity?: boolean, - context?: FormatContentModelContext + context?: FormatContentModelContext, + insertPointOverride?: InsertPoint ) { let blockParent: ContentModelBlockGroup | undefined; let blockIndex = -1; - let deleteResult: DeleteSelectionResult; + let insertPoint: InsertPoint | null; if (position == 'begin' || position == 'end') { blockParent = model; @@ -40,12 +41,8 @@ export function insertEntityModel( if (!isBlock) { Object.assign(entityModel.format, model.format); } - } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { - const { marker, paragraph, path } = deleteResult.insertPoint; - - if (deleteResult.deleteResult == 'range') { - normalizeContentModel(model); - } + } else if ((insertPoint = getInsertPoint(model, insertPointOverride, context))) { + const { marker, paragraph, path } = insertPoint; if (!isBlock) { const index = paragraph.segments.indexOf(marker); @@ -111,3 +108,22 @@ export function insertEntityModel( } } } + +function getInsertPoint( + model: ContentModelDocument, + insertPointOverride?: InsertPoint, + context?: FormatContentModelContext +): InsertPoint | null { + if (insertPointOverride) { + return insertPointOverride; + } else { + const deleteResult = deleteSelection(model, [], context); + const insertPoint = deleteResult.insertPoint; + + if (deleteResult.deleteResult == 'range') { + normalizeContentModel(model); + } + + return insertPoint; + } +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index d57acea5c3a..0eb92d5cc0f 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -1,3 +1,4 @@ +import { formatInsertPointWithContentModel } from '../utils/formatInsertPointWithContentModel'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; import { ChangeSource, @@ -7,11 +8,15 @@ import { } from 'roosterjs-content-model-dom'; import type { ContentModelEntity, - DOMSelection, InsertEntityPosition, InsertEntityOptions, IEditor, EntityState, + DOMInsertPoint, + FormatContentModelOptions, + ContentModelDocument, + FormatContentModelContext, + InsertPoint, } from 'roosterjs-content-model-types'; const BlockEntityTag = 'div'; @@ -32,7 +37,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: boolean, - position: 'focus' | 'begin' | 'end' | DOMSelection, + position: 'focus' | 'begin' | 'end' | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null; @@ -51,7 +56,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: true, - position: InsertEntityPosition | DOMSelection, + position: InsertEntityPosition | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null; @@ -59,7 +64,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: boolean, - position?: InsertEntityPosition | DOMSelection, + position?: InsertEntityPosition | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot, initialEntityState } = @@ -85,36 +90,45 @@ export function insertEntity( editor.takeSnapshot(); } - editor.formatContentModel( - (model, context) => { - insertEntityModel( - model, - entityModel, - typeof position == 'string' ? position : 'focus', - isBlock, - focusAfterEntity, - context - ); - - normalizeContentModel(model); - - context.skipUndoSnapshot = true; - context.newEntities.push(entityModel); - - return true; - }, - { - selectionOverride: typeof position === 'object' ? position : undefined, - changeSource: ChangeSource.InsertEntity, - getChangeData: () => ({ - wrapper, - type, - id: '', - isReadonly: true, - }), - apiName: 'insertEntity', - } - ); + const formatOptions: FormatContentModelOptions = { + changeSource: ChangeSource.InsertEntity, + getChangeData: () => ({ + wrapper, + type, + id: '', + isReadonly: true, + }), + apiName: 'insertEntity', + }; + + const callback = ( + model: ContentModelDocument, + context: FormatContentModelContext, + insertPoint?: InsertPoint + ) => { + insertEntityModel( + model, + entityModel, + typeof position == 'string' ? position : 'focus', + isBlock, + focusAfterEntity, + context, + insertPoint + ); + + normalizeContentModel(model); + + context.skipUndoSnapshot = true; + context.newEntities.push(entityModel); + + return true; + }; + + if (typeof position == 'object') { + formatInsertPointWithContentModel(editor, position, callback, formatOptions); + } else { + editor.formatContentModel(callback, formatOptions); + } if (!skipUndoSnapshot) { let entityState: EntityState | undefined; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts new file mode 100644 index 00000000000..63018d7869a --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts @@ -0,0 +1,239 @@ +import { + addSegment, + addTextSegment, + buildSelectionMarker, + getRegularSelectionOffsets, + processChildNode, +} from 'roosterjs-content-model-dom'; +import type { + ElementProcessor, + DOMInsertPoint, + FormatContentModelOptions, + IEditor, + InsertPoint, + DomToModelContext, + ContentModelBlockGroup, + ContentModelDocument, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function formatInsertPointWithContentModel( + editor: IEditor, + insertPoint: DOMInsertPoint, + callback: ( + model: ContentModelDocument, + context: FormatContentModelContext, + insertPoint?: InsertPoint + ) => void, + options?: FormatContentModelOptions +) { + const bundle: InsertPointBundle = { + input: insertPoint, + }; + + editor.formatContentModel( + (model, context) => { + callback(model, context, bundle.result); + + if (bundle?.result) { + const { paragraph, marker } = bundle.result; + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + paragraph.segments.splice(index, 1); + } + } + return true; + }, + options, + { + processorOverride: { + child: getShadowChildProcessor(bundle), + '#text': getShadowTextProcessor(bundle), + }, + } + ); +} + +/** + * @internal Export for test only + */ +export interface InsertPointBundle { + input: DOMInsertPoint; + result?: InsertPoint; +} + +/** + * @internal Export for test only + */ +export interface DomToModelContextWithPath extends DomToModelContext { + /** + * Block group path of this insert point, from direct parent group to the root group + */ + path?: ContentModelBlockGroup[]; +} + +/** + * @internal Export for test only + */ +export function getShadowChildProcessor(bundle: InsertPointBundle): ElementProcessor { + return (group, parent, context) => { + const contextWithPath = context as DomToModelContextWithPath; + + contextWithPath.path = contextWithPath.path || []; + + let shouldShiftPath = false; + if (contextWithPath.path[0] != group) { + contextWithPath.path.unshift(group); + shouldShiftPath = true; + } + + const offsets = getShadowSelectionOffsets(context, bundle, parent); + let index = 0; + + for (let child = parent.firstChild; child; child = child.nextSibling) { + handleElementShadowSelection(bundle, index, context, group, offsets, parent); + + processChildNode(group, child, context); + + index++; + } + + handleElementShadowSelection(bundle, index, context, group, offsets, parent); + + if (shouldShiftPath) { + contextWithPath.path.shift(); + } + }; +} + +function handleElementShadowSelection( + bundle: InsertPointBundle, + index: number, + context: DomToModelContext, + group: ContentModelBlockGroup, + offsets: [number, number, number], + container?: Node +) { + if ( + index == offsets[2] && + (index <= offsets[0] || offsets[0] < 0) && + (index < offsets[1] || offsets[1] < 0) + ) { + addSelectionMarker(group, context, container, index, bundle); + offsets[2] = -1; + } + + if (index == offsets[0]) { + context.isInSelection = true; + addSelectionMarker(group, context, container, index); + } + + if (index == offsets[2] && (index < offsets[1] || offsets[1] < 0)) { + addSelectionMarker(group, context, container, index, bundle); + offsets[2] = -1; + } + + if (index == offsets[1]) { + addSelectionMarker(group, context, container, index); + context.isInSelection = false; + } + + if (index == offsets[2]) { + addSelectionMarker(group, context, container, index, bundle); + } +} + +/** + * @internal export for test only + */ +export const getShadowTextProcessor = (bundle: InsertPointBundle): ElementProcessor => ( + group, + textNode, + context +) => { + let txt = textNode.nodeValue || ''; + const offsets = getShadowSelectionOffsets(context, bundle, textNode); + const [start, end, shadow] = offsets; + + const handleTextSelection = ( + subtract: number, + originalOffset: number, + bundle?: InsertPointBundle + ) => { + addTextSegment(group, txt.substring(0, subtract), context); + addSelectionMarker(group, context, textNode, originalOffset, bundle); + + offsets[0] -= subtract; + offsets[1] -= subtract; + offsets[2] = bundle ? -1 : offsets[2] - subtract; + + txt = txt.substring(subtract); + }; + + if ( + offsets[2] >= 0 && + (offsets[2] <= offsets[0] || offsets[0] < 0) && + (offsets[2] < offsets[1] || offsets[1] < 0) + ) { + handleTextSelection(offsets[2], shadow, bundle); + } + + if (offsets[0] >= 0) { + handleTextSelection(offsets[0], start); + + context.isInSelection = true; + } + + if (offsets[2] >= 0 && offsets[2] > offsets[0] && (offsets[2] < offsets[1] || offsets[1] < 0)) { + handleTextSelection(offsets[2], shadow, bundle); + } + + if (offsets[1] >= 0) { + handleTextSelection(offsets[1], end); + + context.isInSelection = false; + } + + if (offsets[2] >= 0 && offsets[2] >= offsets[1]) { + handleTextSelection(offsets[2], shadow, bundle); + } + + addTextSegment(group, txt, context); +}; + +function addSelectionMarker( + group: ContentModelBlockGroup, + context: DomToModelContextWithPath, + container?: Node, + offset?: number, + bundle?: InsertPointBundle +) { + const marker = buildSelectionMarker(group, context, container, offset); + + marker.isSelected = !bundle; + + const para = addSegment(group, marker, context.blockFormat, marker.format); + + if (bundle && context.path) { + bundle.result = { + path: [...context.path], + paragraph: para, + marker, + }; + } +} + +function getShadowSelectionOffsets( + context: DomToModelContext, + bundle: InsertPointBundle, + currentContainer: Node +): [number, number, number] { + const [start, end] = getRegularSelectionOffsets(context, currentContainer); + const shadow = bundle.input.node == currentContainer ? bundle.input.offset : -1; + + return [start, end, shadow]; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts b/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts index 612895b5b50..92c305d46c9 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts @@ -1,5 +1,9 @@ -import { ContentModelDocument, InsertEntityPosition } from 'roosterjs-content-model-types'; import { insertEntityModel } from '../../../lib/modelApi/entity/insertEntityModel'; +import { + ContentModelDocument, + InsertEntityPosition, + InsertPoint, +} from 'roosterjs-content-model-types'; import { createBr, createContentModelDocument, @@ -2518,3 +2522,102 @@ describe('insertEntityModel, inline element, focus after entity', () => { ); }); }); + +describe('insertEntityModel, use insert point', () => { + const Entity = { + format: {}, + } as any; + + it('Inline entity, Has insert point override', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test'); + const marker = createSelectionMarker(); + + text1.isSelected = true; + para1.segments.push(text1); + + marker.isSelected = false; + para2.segments.push(marker); + + model.blocks.push(para1, para2); + + const ip: InsertPoint = { + path: [model], + paragraph: para2, + marker, + }; + + insertEntityModel(model, Entity, 'focus', false, false, undefined, ip); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {}, isSelected: true }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + Entity, + ], + format: {}, + }, + ], + }); + }); + + it('Block entity, Has insert point override', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test'); + const marker = createSelectionMarker(); + + text1.isSelected = true; + para1.segments.push(text1); + + marker.isSelected = false; + para2.segments.push(marker); + + model.blocks.push(para1, para2); + + const ip: InsertPoint = { + path: [model], + paragraph: para2, + marker, + }; + + insertEntityModel(model, Entity, 'focus', true, false, undefined, ip); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {}, isSelected: true }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'SelectionMarker', isSelected: false, format: {} }], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index 55c90196e66..2a04b21e9b9 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -1,8 +1,9 @@ import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import * as formatInsertPointWithContentModel from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { ChangeSource } from 'roosterjs-content-model-dom'; -import { IEditor } from 'roosterjs-content-model-types'; +import { DOMInsertPoint, IEditor } from 'roosterjs-content-model-types'; import { insertEntity } from '../../../lib/publicApi/entity/insertEntity'; import { FormatContentModelContext, @@ -16,6 +17,7 @@ describe('insertEntity', () => { const model = 'MockedModel' as any; let formatWithContentModelSpy: jasmine.Spy; + let formatInsertPointWithContentModelSpy: jasmine.Spy; let triggerContentChangedEventSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; let createElementSpy: jasmine.Spy; @@ -57,6 +59,10 @@ describe('insertEntity', () => { .and.callFake((formatter: Function, options: FormatContentModelOptions) => { formatter(model, context); }); + formatInsertPointWithContentModelSpy = spyOn( + formatInsertPointWithContentModel, + 'formatInsertPointWithContentModel' + ); triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEventSpy'); createElementSpy = jasmine.createSpy('createElementSpy').and.returnValue(wrapper); @@ -105,7 +111,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -153,7 +160,8 @@ describe('insertEntity', () => { 'root', true, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -172,9 +180,9 @@ describe('insertEntity', () => { }); it('block inline entity with more options', () => { - const range = { range: 'RangeEx' } as any; + const domPos: DOMInsertPoint = { pos: 'DOMPOS' } as any; const contentNode = 'ContentNode' as any; - const entity = insertEntity(editor, type, true, range, { + const entity = insertEntity(editor, type, true, domPos, { contentNode: contentNode, focusAfterEntity: true, skipUndoSnapshot: true, @@ -187,11 +195,22 @@ describe('insertEntity', () => { expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block'); expect(setPropertySpy).not.toHaveBeenCalledWith('width', '100%'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); - expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + expect(formatInsertPointWithContentModelSpy).toHaveBeenCalledTimes(1); + expect(formatInsertPointWithContentModelSpy).toHaveBeenCalledWith( + editor, + domPos, + jasmine.anything() as any, + jasmine.anything() as any + ); + expect(formatInsertPointWithContentModelSpy.calls.argsFor(0)[3].apiName).toBe(apiName); + expect(formatInsertPointWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( ChangeSource.InsertEntity ); + const mockedIP = 'IP' as any; + formatInsertPointWithContentModelSpy.calls.argsFor(0)[2](model, context, mockedIP); + expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -208,7 +227,8 @@ describe('insertEntity', () => { 'focus', true, true, - context + context, + mockedIP ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -257,7 +277,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -329,7 +350,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts new file mode 100644 index 00000000000..7f490175440 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts @@ -0,0 +1,379 @@ +import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; +import { + ContentModelParagraph, + ContentModelSegment, + DomToModelOption, +} from 'roosterjs-content-model-types'; +import { + DomToModelContextWithPath, + formatInsertPointWithContentModel, + getShadowChildProcessor, + getShadowTextProcessor, +} from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; + +describe('formatInsertPointWithContentModel', () => { + it('format with insertPoint', () => { + const node = document.createElement('div'); + const offset = 0; + const mockedInsertPoint = { node, offset }; + const mockedCallback = jasmine.createSpy('CALLBACK'); + const mockedOptions = 'OPTIONS' as any; + const mockedModel = createContentModelDocument(); + const mockedContext = createDomToModelContext(); + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function, options: any, override: DomToModelOption) => { + expect(override.processorOverride?.child).toBeDefined(); + expect(override.processorOverride?.['#text']).toBeDefined(); + + override.processorOverride?.child!(mockedModel, node, mockedContext); + + callback(mockedModel, mockedContext); + }); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + mockedInsertPoint, + mockedCallback, + mockedOptions + ); + + expect(formatContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() as any, + mockedOptions, + jasmine.anything() as any + ); + + const marker = { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }; + const para: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }; + expect(mockedCallback).toHaveBeenCalledWith(mockedModel, mockedContext, { + path: [mockedModel], + marker, + paragraph: para, + }); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [para], + }); + }); + + it('format with insertPoint that is not in editor', () => { + const node1 = document.createElement('div'); + const node2 = document.createElement('div'); + const offset = 0; + const mockedInsertPoint = { node: node1, offset }; + const mockedCallback = jasmine.createSpy('CALLBACK'); + const mockedOptions = 'OPTIONS' as any; + const mockedModel = createContentModelDocument(); + const mockedContext = createDomToModelContext(); + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function, options: any, override: DomToModelOption) => { + expect(override.processorOverride?.child).toBeDefined(); + expect(override.processorOverride?.['#text']).toBeDefined(); + + override.processorOverride?.child!(mockedModel, node2, mockedContext); + + callback(mockedModel, mockedContext); + }); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + mockedInsertPoint, + mockedCallback, + mockedOptions + ); + + expect(formatContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() as any, + mockedOptions, + jasmine.anything() as any + ); + + expect(mockedCallback).toHaveBeenCalledWith(mockedModel, mockedContext, undefined); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); +}); + +describe('getShadowChildProcessor', () => { + function runTest(startOffset: number, endOffset: number, shadow: number, result: string[]) { + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + + span1.textContent = 'a'; + span2.textContent = 'b'; + + div.appendChild(span1); + div.appendChild(span2); + + const group = createContentModelDocument(); + const context: DomToModelContextWithPath = createDomToModelContext(); + const bundle = { + input: { + node: div, + offset: shadow, + }, + }; + const processor = getShadowChildProcessor(bundle); + + context.selection = { + type: 'range', + range: { + startContainer: div, + startOffset, + endContainer: div, + endOffset, + } as any, + isReverted: false, + }; + + processor(group, div, context); + + const actualResult = (group.blocks[0] as ContentModelParagraph).segments.map(translate); + + expect(actualResult).toEqual(result); + expect(context.isInSelection).toBeFalse(); + } + + it('no insert point', () => { + runTest(-1, -1, -1, ['a', 'b']); + }); + + it('has insert point', () => { + runTest(-1, -1, 0, ['_', 'a', 'b']); + runTest(-1, -1, 1, ['a', '_', 'b']); + runTest(-1, -1, 2, ['a', 'b', '_']); + }); + + it('has insert point and collapsed regular selection', () => { + runTest(0, 0, 0, ['*', '_', 'a', 'b']); + runTest(1, 1, 0, ['_', 'a', '*', 'b']); + runTest(2, 2, 0, ['_', 'a', 'b', '*']); + runTest(0, 0, 1, ['*', 'a', '_', 'b']); + runTest(1, 1, 1, ['a', '*', '_', 'b']); + runTest(2, 2, 1, ['a', '_', 'b', '*']); + runTest(0, 0, 2, ['*', 'a', 'b', '_']); + runTest(1, 1, 2, ['a', '*', 'b', '_']); + runTest(2, 2, 2, ['a', 'b', '*', '_']); + }); + + it('has insert point and expanded regular selection', () => { + runTest(0, 1, 0, ['_', '*a', 'b']); + runTest(1, 2, 0, ['_', 'a', '*b']); + runTest(0, 2, 0, ['_', '*a', '*b']); + runTest(0, 1, 1, ['*a', '_', 'b']); + runTest(1, 2, 1, ['a', '_', '*b']); + runTest(0, 2, 1, ['*a', '_', '*b']); + runTest(0, 1, 2, ['*a', 'b', '_']); + runTest(1, 2, 2, ['a', '*b', '_']); + runTest(0, 2, 2, ['*a', '*b', '_']); + }); +}); + +describe('getShadowTextProcessor', () => { + const inputText = 'abcdef'; + + function runTest( + startOffset: number, + endOffset: number, + shadowOffset: number, + result: string[], + inSelectionResult: boolean, + alreadyInSelection?: boolean + ) { + const text = document.createTextNode(inputText); + const group = createContentModelDocument(); + const context: DomToModelContextWithPath = createDomToModelContext(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + startOffset, + endContainer: text, + endOffset, + } as any, + isReverted: false, + }; + + if (alreadyInSelection) { + context.isInSelection = true; + } + + const bundle = { + input: { + node: text, + offset: shadowOffset, + }, + }; + const processor = getShadowTextProcessor(bundle); + + processor(group, text, context); + + const actualResult = (group.blocks[0] as ContentModelParagraph).segments.map(translate); + + expect(actualResult).toEqual(result); + expect(context.isInSelection).toBe(inSelectionResult); + } + + describe('no selection', () => { + it('No selection', () => { + runTest(-1, -1, -1, ['abcdef'], false); + runTest(-1, -1, 0, ['_', 'abcdef'], false); + runTest(-1, -1, 3, ['abc', '_', 'def'], false); + runTest(-1, -1, 6, ['abcdef', '_'], false); + }); + + it('No selection, but in selection', () => { + runTest(-1, -1, -1, ['*abcdef'], true, true); + 1081; + runTest(-1, -1, 0, ['_', '*abcdef'], true, true); + runTest(-1, -1, 3, ['*abc', '_', '*def'], true, true); + runTest(-1, -1, 6, ['*abcdef', '_'], true, true); + }); + }); + + describe('Has start', () => { + it('start at 0', () => { + runTest(0, -1, -1, ['*abcdef'], true); + runTest(0, -1, 0, ['_', '*abcdef'], true); + runTest(0, -1, 3, ['*abc', '_', '*def'], true); + runTest(0, -1, 6, ['*abcdef', '_'], true); + }); + + it('start at middle', () => { + runTest(2, -1, -1, ['ab', '*cdef'], true); + runTest(2, -1, 0, ['_', 'ab', '*cdef'], true); + runTest(2, -1, 1, ['a', '_', 'b', '*cdef'], true); + runTest(2, -1, 2, ['ab', '_', '*cdef'], true); + runTest(2, -1, 3, ['ab', '*c', '_', '*def'], true); + runTest(2, -1, 6, ['ab', '*cdef', '_'], true); + }); + + it('start at end', () => { + runTest(6, -1, -1, ['abcdef', '*'], true); + runTest(6, -1, 0, ['_', 'abcdef', '*'], true); + runTest(6, -1, 3, ['abc', '_', 'def', '*'], true); + runTest(6, -1, 6, ['abcdef', '_', '*'], true); + }); + }); + + describe('Has end', () => { + it('end at 0', () => { + runTest(-1, 0, -1, ['*', 'abcdef'], false, true); + runTest(-1, 0, 0, ['*', '_', 'abcdef'], false, true); + runTest(-1, 0, 3, ['*', 'abc', '_', 'def'], false, true); + runTest(-1, 0, 6, ['*', 'abcdef', '_'], false, true); + }); + + it('end at middle', () => { + runTest(-1, 4, -1, ['*abcd', 'ef'], false, true); + runTest(-1, 4, 0, ['_', '*abcd', 'ef'], false, true); + runTest(-1, 4, 3, ['*abc', '_', '*d', 'ef'], false, true); + runTest(-1, 4, 4, ['*abcd', '_', 'ef'], false, true); + runTest(-1, 4, 5, ['*abcd', 'e', '_', 'f'], false, true); + runTest(-1, 4, 6, ['*abcd', 'ef', '_'], false, true); + }); + + it('end at end', () => { + runTest(-1, 6, -1, ['*abcdef'], false, true); + runTest(-1, 6, 0, ['_', '*abcdef'], false, true); + runTest(-1, 6, 3, ['*abc', '_', '*def'], false, true); + runTest(-1, 6, 6, ['*abcdef', '_'], false, true); + }); + }); + + describe('Has same start and end', () => { + it('at 0', () => { + runTest(0, 0, -1, ['*', 'abcdef'], false); + runTest(0, 0, 0, ['*', '_', 'abcdef'], false); + runTest(0, 0, 3, ['*', 'abc', '_', 'def'], false); + runTest(0, 0, 6, ['*', 'abcdef', '_'], false); + }); + + it('at middle', () => { + runTest(3, 3, -1, ['abc', '*', 'def'], false); + runTest(3, 3, 0, ['_', 'abc', '*', 'def'], false); + runTest(3, 3, 2, ['ab', '_', 'c', '*', 'def'], false); + runTest(3, 3, 3, ['abc', '*', '_', 'def'], false); + runTest(3, 3, 4, ['abc', '*', 'd', '_', 'ef'], false); + runTest(3, 3, 6, ['abc', '*', 'def', '_'], false); + }); + + it('at end', () => { + runTest(6, 6, -1, ['abcdef', '*'], false); + runTest(6, 6, 0, ['_', 'abcdef', '*'], false); + runTest(6, 6, 3, ['abc', '_', 'def', '*'], false); + runTest(6, 6, 6, ['abcdef', '*', '_'], false); + }); + }); + + describe('Has different start and end', () => { + it('start at 0, end at end', () => { + runTest(0, 6, -1, ['*abcdef'], false); + runTest(0, 6, 0, ['_', '*abcdef'], false); + runTest(0, 6, 3, ['*abc', '_', '*def'], false); + runTest(0, 6, 6, ['*abcdef', '_'], false); + }); + + it('start at 0, end at 3', () => { + runTest(0, 3, -1, ['*abc', 'def'], false); + runTest(0, 3, 0, ['_', '*abc', 'def'], false); + runTest(0, 3, 2, ['*ab', '_', '*c', 'def'], false); + runTest(0, 3, 3, ['*abc', '_', 'def'], false); + runTest(0, 3, 4, ['*abc', 'd', '_', 'ef'], false); + runTest(0, 3, 6, ['*abc', 'def', '_'], false); + }); + + it('start at 3, end at end', () => { + runTest(3, 6, -1, ['abc', '*def'], false); + runTest(3, 6, 0, ['_', 'abc', '*def'], false); + runTest(3, 6, 2, ['ab', '_', 'c', '*def'], false); + runTest(3, 6, 3, ['abc', '_', '*def'], false); + runTest(3, 6, 4, ['abc', '*d', '_', '*ef'], false); + runTest(3, 6, 6, ['abc', '*def', '_'], false); + }); + + it('start at 2, end at 4', () => { + runTest(2, 4, -1, ['ab', '*cd', 'ef'], false); + runTest(2, 4, 0, ['_', 'ab', '*cd', 'ef'], false); + runTest(2, 4, 1, ['a', '_', 'b', '*cd', 'ef'], false); + runTest(2, 4, 2, ['ab', '_', '*cd', 'ef'], false); + runTest(2, 4, 3, ['ab', '*c', '_', '*d', 'ef'], false); + runTest(2, 4, 4, ['ab', '*cd', '_', 'ef'], false); + runTest(2, 4, 5, ['ab', '*cd', 'e', '_', 'f'], false); + runTest(2, 4, 6, ['ab', '*cd', 'ef', '_'], false); + }); + }); +}); + +function translate(input: ContentModelSegment): string { + if (input.segmentType == 'Text') { + return input.isSelected ? '*' + input.text : input.text; + } else if (input.segmentType == 'SelectionMarker') { + return input.isSelected ? '*' : '_'; + } else { + throw new Error('Wrong input type'); + } +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index b04ac08dcad..835d7032077 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -18,7 +18,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv // Flush all mutations if any, so that we can get an up-to-date Content Model core.cache.textMutationObserver?.flushMutations(); - let cachedModel = selectionOverride ? null : core.cache.cachedModel; + let cachedModel = selectionOverride || option ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 001309b4972..4d1c1e5f574 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -18,10 +18,15 @@ import type { * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ -export const formatContentModel: FormatContentModel = (core, formatter, options) => { +export const formatContentModel: FormatContentModel = ( + core, + formatter, + options, + domToModelOptions +) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; - const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); + const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts index 0d05e019a20..14cd0207a36 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts @@ -1,19 +1,12 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export interface Pos { - node: Node; - offset: number; -} +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; /** * @internal * * Use with paths generated by `getPath`. */ -export function getPositionFromPath(node: Node, path: number[]): Pos { +export function getPositionFromPath(node: Node, path: number[]): DOMInsertPoint { // Iterate with a for loop to avoid mutating the passed in element path stack // or needing to copy it. let offset: number = 0; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts index 4a9c0483eac..794b2c9ea36 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts @@ -1,10 +1,10 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { Pos } from '../../coreApi/restoreUndoSnapshot/getPositionFromPath'; +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; /** * @internal */ -export function normalizePos(node: Node, offset: number): Pos { +export function normalizePos(node: Node, offset: number): DOMInsertPoint { const len = isNodeOfType(node, 'TEXT_NODE') ? node.nodeValue?.length ?? 0 : node.childNodes.length; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 9fd32b0a7c9..ac32832ca35 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -31,6 +31,7 @@ import type { Rect, EntityState, CachedElementHandler, + DomToModelOption, } from 'roosterjs-content-model-types'; /** @@ -104,23 +105,25 @@ export class Editor implements IEditor { switch (mode) { case 'connected': - return core.api.createContentModel(core, { - processorOverride: { - table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity - }, - }); + return core.api.createContentModel(core); case 'disconnected': - return cloneModel(core.api.createContentModel(core), { - includeCachedElement: this.cloneOptionCallback, - }); + return cloneModel( + core.api.createContentModel(core, { + processorOverride: { + table: tableProcessor, + }, + }), + { + includeCachedElement: this.cloneOptionCallback, + } + ); case 'clean': const domToModelContext = createDomToModelContextWithConfig( core.environment.domToModelSettings.calculated, core.api.createEditorContext(core, false /*saveIndex*/) ); - return domToContentModel(core.physicalRoot, domToModelContext); case 'reduced': @@ -178,11 +181,12 @@ export class Editor implements IEditor { */ formatContentModel( formatter: ContentModelFormatter, - options?: FormatContentModelOptions + options?: FormatContentModelOptions, + domToModelOptions?: DomToModelOption ): void { const core = this.getCore(); - core.api.formatContentModel(core, formatter, options); + core.api.formatContentModel(core, formatter, options, domToModelOptions); } /** diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index 5af320900f3..e53c54653a5 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -86,6 +86,20 @@ describe('createContentModel', () => { expect(domToContentModelSpy).not.toHaveBeenCalled(); expect(model).toBe(mockedClonedModel); }); + + it('Do not reuse model, with cache, no shadow edit, has option', () => { + const currentContext = 'CURRENTCONTEXT' as any; + + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(currentContext); + + const model = createContentModel(core, {}); + + expect(cloneModelSpy).not.toHaveBeenCalled(); + expect(createEditorContext).toHaveBeenCalledWith(core, false); + expect(getDOMSelection).toHaveBeenCalledWith(core); + expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, currentContext); + expect(model).toBe(mockedModel); + }); }); describe('createContentModel with selection', () => { diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 32b650ad466..17078241df9 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -408,6 +408,38 @@ describe('formatContentModel', () => { ); }); + it('With domToModelOptions', () => { + const options = 'Options' as any; + + formatContentModel( + core, + () => true, + { + apiName, + }, + options + ); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledWith(core, options, undefined); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + formatApiName: apiName, + changedEntities: [], + }, + true + ); + }); + it('Has image', () => { const image = createImage('test'); const rawEvent = 'RawEvent' as any; diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 7f98a5194e3..ca832e3577e 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1,12 +1,12 @@ -import { ChangeSource, tableProcessor } from 'roosterjs-content-model-dom'; +import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; import * as createDomToModelContextWithConfig from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createEditorCore from '../../lib/editor/core/createEditorCore'; +import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as transformColor from 'roosterjs-content-model-dom/lib/domUtils/style/transformColor'; -import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; -import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; import { CachedElementHandler, EditorCore, Rect } from 'roosterjs-content-model-types'; +import { ChangeSource, tableProcessor } from 'roosterjs-content-model-dom'; import { Editor } from '../../lib/editor/Editor'; -import * as createEditorCore from '../../lib/editor/core/createEditorCore'; import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; describe('Editor', () => { @@ -132,11 +132,7 @@ describe('Editor', () => { const model1 = editor.getContentModelCopy('connected'); expect(model1).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity - }, - }); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); const model2 = editor.getContentModelCopy('reduced'); @@ -210,7 +206,11 @@ describe('Editor', () => { expect(cloneNodeSpy).toHaveBeenCalledWith(true); expect(model).toBe(mockedClonedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, + }, + }); expect(transformColorSpy).not.toHaveBeenCalled(); // Clone in dark mode @@ -219,7 +219,11 @@ describe('Editor', () => { expect(cloneNodeSpy).toHaveBeenCalledWith(true); expect(model).toBe(mockedClonedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, + }, + }); expect(transformColorSpy).toHaveBeenCalledWith( clonedNode, true, @@ -411,14 +415,20 @@ describe('Editor', () => { editor.formatContentModel(mockedFormatter); - expect(formatContentModelSpy).toHaveBeenCalledWith(mockedCore, mockedFormatter, undefined); + expect(formatContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedFormatter, + undefined, + undefined + ); editor.formatContentModel(mockedFormatter, mockedOptions); expect(formatContentModelSpy).toHaveBeenCalledWith( mockedCore, mockedFormatter, - mockedOptions + mockedOptions, + undefined ); editor.dispose(); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts index 31a4bda40cf..152334499f5 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts @@ -17,6 +17,7 @@ import { listProcessor } from '../processors/listProcessor'; import { pProcessor } from '../processors/pProcessor'; import { tableProcessor } from '../processors/tableProcessor'; import { textProcessor } from '../processors/textProcessor'; +import { textWithSelectionProcessor } from '../processors/textWithSelectionProcessor'; import type { ElementProcessorMap } from 'roosterjs-content-model-types'; /** @@ -57,6 +58,7 @@ export const defaultProcessorMap: ElementProcessorMap = { '*': generalProcessor, '#text': textProcessor, + textWithSelection: textWithSelectionProcessor, element: elementProcessor, entity: entityProcessor, child: childProcessor, diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts index 23c0210c9e7..a0a409aa499 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts @@ -1,15 +1,7 @@ -import { addDecorators } from '../../modelApi/common/addDecorators'; -import { addSegment } from '../../modelApi/common/addSegment'; -import { addSelectionMarker } from '../utils/addSelectionMarker'; -import { createText } from '../../modelApi/creators/createText'; import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; -import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; -import { hasSpacesOnly } from '../../modelApi/common/hasSpacesOnly'; -import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { stackFormat } from '../utils/stackFormat'; import type { ContentModelBlockGroup, - ContentModelParagraph, ContentModelText, DomToModelContext, ElementProcessor, @@ -40,72 +32,15 @@ function internalTextProcessor( textNode: Text, context: DomToModelContext ) { - let txt = textNode.nodeValue || ''; - const offsets = getRegularSelectionOffsets(context, textNode); - const txtStartOffset = offsets[0]; - let txtEndOffset = offsets[1]; - const segments: (ContentModelText | undefined)[] = []; const paragraph = ensureParagraph(group, context.blockFormat); + const segmentCount = paragraph.segments.length; - if (txtStartOffset >= 0) { - const subText = txt.substring(0, txtStartOffset); - segments.push(addTextSegment(group, subText, paragraph, context)); - context.isInSelection = true; + context.elementProcessors.textWithSelection(group, textNode, context); - addSelectionMarker(group, context, textNode, txtStartOffset); - - txt = txt.substring(txtStartOffset); - txtEndOffset -= txtStartOffset; - } - - if (txtEndOffset >= 0) { - const subText = txt.substring(0, txtEndOffset); - segments.push(addTextSegment(group, subText, paragraph, context)); - - if ( - context.selection && - (context.selection.type != 'range' || !context.selection.range.collapsed) - ) { - addSelectionMarker(group, context, textNode, offsets[1]); // Must use offsets[1] here as the unchanged offset value, cannot use txtEndOffset since it has been modified - } - - context.isInSelection = false; - txt = txt.substring(txtEndOffset); - } - - segments.push(addTextSegment(group, txt, paragraph, context)); + const newSegments = paragraph.segments.slice(segmentCount); context.domIndexer?.onSegment( textNode, paragraph, - segments.filter((x): x is ContentModelText => !!x) + newSegments.filter((x): x is ContentModelText => x?.segmentType == 'Text') ); } - -function addTextSegment( - group: ContentModelBlockGroup, - text: string, - paragraph: ContentModelParagraph, - context: DomToModelContext -): ContentModelText | undefined { - let textModel: ContentModelText | undefined; - - if (text) { - if ( - !hasSpacesOnly(text) || - (paragraph?.segments.length ?? 0) > 0 || - isWhiteSpacePreserved(paragraph?.format.whiteSpace) - ) { - textModel = createText(text, context.segmentFormat); - - if (context.isInSelection) { - textModel.isSelected = true; - } - - addDecorators(textModel, context); - - addSegment(group, textModel, context.blockFormat); - } - } - - return textModel; -} diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts new file mode 100644 index 00000000000..f95c6f61bf3 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts @@ -0,0 +1,42 @@ +import { addSelectionMarker } from '../utils/addSelectionMarker'; +import { addTextSegment } from '../../modelApi/common/addTextSegment'; +import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; +import type { ElementProcessor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const textWithSelectionProcessor: ElementProcessor = (group, textNode, context) => { + let txt = textNode.nodeValue || ''; + const offsets = getRegularSelectionOffsets(context, textNode); + const txtStartOffset = offsets[0]; + let txtEndOffset = offsets[1]; + + if (txtStartOffset >= 0) { + const subText = txt.substring(0, txtStartOffset); + addTextSegment(group, subText, context); + context.isInSelection = true; + + addSelectionMarker(group, context, textNode, txtStartOffset); + + txt = txt.substring(txtStartOffset); + txtEndOffset -= txtStartOffset; + } + + if (txtEndOffset >= 0) { + const subText = txt.substring(0, txtEndOffset); + addTextSegment(group, subText, context); + + if ( + context.selection && + (context.selection.type != 'range' || !context.selection.range.collapsed) + ) { + addSelectionMarker(group, context, textNode, offsets[1]); // Must use offsets[1] here as the unchanged offset value, cannot use txtEndOffset since it has been modified + } + + context.isInSelection = false; + txt = txt.substring(txtEndOffset); + } + + addTextSegment(group, txt, context); +}; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts index 5f750aa301c..3ba8cbc21e5 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts @@ -1,11 +1,6 @@ -import { addDecorators } from '../../modelApi/common/addDecorators'; import { addSegment } from '../../modelApi/common/addSegment'; -import { createSelectionMarker } from '../../modelApi/creators/createSelectionMarker'; -import type { - ContentModelBlockGroup, - ContentModelSegmentFormat, - DomToModelContext, -} from 'roosterjs-content-model-types'; +import { buildSelectionMarker } from './buildSelectionMarker'; +import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; /** * @internal @@ -16,37 +11,7 @@ export function addSelectionMarker( container?: Node, offset?: number ) { - const lastPara = group.blocks[group.blocks.length - 1]; - const formatFromParagraph: ContentModelSegmentFormat = - !lastPara || lastPara.blockType != 'Paragraph' - ? {} - : lastPara.decorator - ? { - fontFamily: lastPara.decorator.format.fontFamily, - fontSize: lastPara.decorator.format.fontSize, - } - : lastPara.segmentFormat - ? { - fontFamily: lastPara.segmentFormat.fontFamily, - fontSize: lastPara.segmentFormat.fontSize, - } - : {}; + const marker = buildSelectionMarker(group, context, container, offset); - const pendingFormat = - context.pendingFormat && - context.pendingFormat.insertPoint.node === container && - context.pendingFormat.insertPoint.offset === offset - ? context.pendingFormat.format - : undefined; - const segmentFormat = { - ...context.defaultFormat, - ...formatFromParagraph, - ...context.segmentFormat, - ...pendingFormat, - }; - const marker = createSelectionMarker(segmentFormat); - - addDecorators(marker, context); - - addSegment(group, marker, context.blockFormat, segmentFormat); + addSegment(group, marker, context.blockFormat, marker.format); } diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts new file mode 100644 index 00000000000..e71701eb2fd --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts @@ -0,0 +1,60 @@ +import { addDecorators } from '../../modelApi/common/addDecorators'; +import { createSelectionMarker } from '../../modelApi/creators/createSelectionMarker'; +import type { + ContentModelBlockGroup, + ContentModelSegmentFormat, + ContentModelSelectionMarker, + DomToModelContext, +} from 'roosterjs-content-model-types'; + +/** + * Build a new selection marker with correct format according to its parent paragraph + * @param group The BlockGroup that paragraph belongs to + * @param context Current DOM to Model context + * @param container @optional Container Node, used for retrieving pending format + * @param offset @optional Container offset, used for retrieving pending format + * @returns A new selection marker + */ +export function buildSelectionMarker( + group: ContentModelBlockGroup, + context: DomToModelContext, + container?: Node, + offset?: number +): ContentModelSelectionMarker { + const lastPara = group.blocks[group.blocks.length - 1]; + const formatFromParagraph: ContentModelSegmentFormat = + !lastPara || lastPara.blockType != 'Paragraph' + ? {} + : lastPara.decorator + ? { + fontFamily: lastPara.decorator.format.fontFamily, + fontSize: lastPara.decorator.format.fontSize, + } + : lastPara.segmentFormat + ? { + fontFamily: lastPara.segmentFormat.fontFamily, + fontSize: lastPara.segmentFormat.fontSize, + } + : {}; + + const pendingFormat = + context.pendingFormat && + context.pendingFormat.insertPoint.node === container && + context.pendingFormat.insertPoint.offset === offset + ? context.pendingFormat.format + : undefined; + + const format: ContentModelSegmentFormat = Object.assign( + {}, + context.defaultFormat, + formatFromParagraph, + context.segmentFormat, + pendingFormat + ); + + const marker = createSelectionMarker(format); + + addDecorators(marker, context); + + return marker; +} diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 9a5caf29aad..b35a5aa5ac0 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -13,6 +13,7 @@ export { getRegularSelectionOffsets } from './domToModel/utils/getRegularSelecti export { parseFormat } from './domToModel/utils/parseFormat'; export { areSameFormats } from './domToModel/utils/areSameFormats'; export { isBlockElement } from './domToModel/utils/isBlockElement'; +export { buildSelectionMarker } from './domToModel/utils/buildSelectionMarker'; export { updateMetadata, hasMetadata } from './modelApi/metadata/updateMetadata'; export { isNodeOfType } from './domUtils/isNodeOfType'; @@ -55,6 +56,7 @@ export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; +export { addTextSegment } from './modelApi/common/addTextSegment'; export { normalizeParagraph } from './modelApi/common/normalizeParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts index ea016c38388..d564538490a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts @@ -36,11 +36,15 @@ export function addSegment( } if (newSegment.segmentType == 'SelectionMarker') { - if (!lastSegment || !lastSegment.isSelected) { + if (!lastSegment || !lastSegment.isSelected || !newSegment.isSelected) { paragraph.segments.push(newSegment); } } else { - if (newSegment.isSelected && lastSegment?.segmentType == 'SelectionMarker') { + if ( + newSegment.isSelected && + lastSegment?.segmentType == 'SelectionMarker' && + lastSegment.isSelected + ) { paragraph.segments.pop(); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts new file mode 100644 index 00000000000..dc8536468de --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts @@ -0,0 +1,48 @@ +import { addDecorators } from './addDecorators'; +import { addSegment } from './addSegment'; +import { createText } from '../creators/createText'; +import { ensureParagraph } from './ensureParagraph'; +import { hasSpacesOnly } from './hasSpacesOnly'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import type { + ContentModelBlockGroup, + ContentModelText, + DomToModelContext, +} from 'roosterjs-content-model-types'; + +/** + * Add a new text segment to current paragraph + * @param group Current BlockGroup that the paragraph belong to + * @param text Text content of the text segment + * @param context Current DOM to Model context + * @returns A new Text segment, or undefined if the input text is empty + */ +export function addTextSegment( + group: ContentModelBlockGroup, + text: string, + context: DomToModelContext +): ContentModelText | undefined { + let textModel: ContentModelText | undefined; + + if (text) { + const paragraph = ensureParagraph(group, context.blockFormat); + + if ( + !hasSpacesOnly(text) || + (paragraph?.segments.length ?? 0) > 0 || + isWhiteSpacePreserved(paragraph?.format.whiteSpace) + ) { + textModel = createText(text, context.segmentFormat); + + if (context.isInSelection) { + textModel.isSelected = true; + } + + addDecorators(textModel, context); + + addSegment(group, textModel, context.blockFormat); + } + } + + return textModel; +} diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts new file mode 100644 index 00000000000..104c19654a0 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts @@ -0,0 +1,741 @@ +import * as addSelectionMarker from '../../../lib/domToModel/utils/addSelectionMarker'; +import { addBlock } from '../../../lib/modelApi/common/addBlock'; +import { addSegment } from '../../../lib/modelApi/common/addSegment'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { createText } from '../../../lib/modelApi/creators/createText'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { textWithSelectionProcessor } from '../../../lib/domToModel/processors/textWithSelectionProcessor'; + +describe('textWithSelectionProcessor', () => { + let context: DomToModelContext; + + beforeEach(() => { + context = createDomToModelContext(); + }); + + it('Empty group', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with empty paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + doc.blocks.push({ + blockType: 'Paragraph', + segments: [], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with paragraph with text segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test1'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with paragraph with different type of segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: null!, + blocks: [], + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: null!, + blocks: [], + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Handle text with selection 1', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection 2', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection 3', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.segmentFormat = { a: 'b' } as any; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { a: 'b' } as any, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Handle text with link format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.link = { format: { href: '/test' }, dataset: {} }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Handle text with selection and link format 1', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + context.link = { format: { href: '/test' }, dataset: {} }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection and link format 2', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.link = { format: { href: '/test' }, dataset: {} }; + context.selection = { + type: 'range', + range: { + startContainer: text, + startOffset: 2, + endContainer: text, + endOffset: 2, + collapsed: true, + } as any, + isReverted: false, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + link: { + format: { + href: '/test', + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + link: { + format: { + href: '/test', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + format: {}, + }); + }); + + it('Handle text with code format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.code = { format: { fontFamily: 'monospace' } }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { format: { fontFamily: 'monospace' } }, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Empty text', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(''); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Space only text without existing paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('Space only text with existing paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + addBlock(doc, createParagraph()); + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Space only text with existing implicit paragraph with existing segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + addSegment(doc, createText('test')); + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + }, + ], + }); + }); + + it('Paragraph with white-space style', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' \n '); + const paragraph = createParagraph(false, { + whiteSpace: 'pre', + }); + + doc.blocks.push(paragraph); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + whiteSpace: 'pre', + }, + segments: [ + { + segmentType: 'Text', + format: {}, + text: ' \n ', + }, + ], + }, + ], + }); + }); + + it('With pending format, match collapsed selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 2, + endOffset: 2, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + insertPoint: { + node: text, + offset: 2, + }, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: { a: 'a' } as any, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 2); + }); + + it('With pending format, match expanded selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 1, + endOffset: 3, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + insertPoint: { + node: text, + offset: 3, + }, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 't', + format: {}, + }, + { + segmentType: 'Text', + text: 'es', + format: {} as any, + isSelected: true, + }, + { + segmentType: 'Text', + text: 't', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 1); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 3); + }); + + it('With pending format, not match selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 2, + endOffset: 2, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + insertPoint: { + node: text, + offset: 3, + }, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 2); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts new file mode 100644 index 00000000000..662abd0c56c --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts @@ -0,0 +1,234 @@ +import * as addDecorator from '../../../lib/modelApi/common/addDecorators'; +import { buildSelectionMarker } from '../../../lib/domToModel/utils/buildSelectionMarker'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; + +describe('buildSelectionMarker', () => { + it('no exiting para, no pending format, no default format, no segment format', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('no exiting para, no pending format, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'red', + }, + isSelected: true, + }); + }); + + it('no exiting para, has pending format, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset = 'OFFSET' as any; + + context.pendingFormat = { + insertPoint: { + node: mockedContainer, + offset: mockedOffset, + }, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'blue', + backgroundColor: 'green', + }, + isSelected: true, + }); + }); + + it('no exiting para, has pending format but not match, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset1 = 'OFFSET1' as any; + const mockedOffset2 = 'OFFSET2' as any; + + context.pendingFormat = { + insertPoint: { + node: mockedContainer, + offset: mockedOffset1, + }, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset2); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'red', + }, + isSelected: true, + }); + }); + + it('has exiting para and format', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + group.blocks.push(para); + + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial', fontSize: undefined }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('has exiting para and format, has decorator', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + para.decorator = { + tagName: 'div', + format: { + fontFamily: 'Tahoma', + }, + }; + + group.blocks.push(para); + + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { fontFamily: 'Tahoma', fontSize: undefined }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('has everything', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + para.decorator = { + tagName: 'div', + format: { + fontFamily: 'Tahoma', + }, + }; + + group.blocks.push(para); + + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset = 'OFFSET' as any; + + context.pendingFormat = { + insertPoint: { + node: mockedContainer, + offset: mockedOffset, + }, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Tahoma', + fontSize: '10pt', + textColor: 'blue', + backgroundColor: 'green', + }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts index 8d0688c8b07..9bcde0996dc 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts @@ -1,8 +1,10 @@ import { addBlock } from '../../../lib/modelApi/common/addBlock'; import { addSegment } from '../../../lib/modelApi/common/addSegment'; import { ContentModelGeneralBlock, ContentModelParagraph } from 'roosterjs-content-model-types'; +import { createBr } from '../../../lib/modelApi/creators/createBr'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; describe('addSegment', () => { @@ -136,4 +138,266 @@ describe('addSegment', () => { ], }); }); + + it('Add selection marker in empty paragraph', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selected segment', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const br = createBr(); + + br.isSelected = true; + para.segments.push(br); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker that is not selected', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + marker.isSelected = false; + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: false, + }, + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add unselected selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + newMarker.isSelected = false; + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: false, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selected segment after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const br = createBr({ fontFamily: 'Arial' }); + + br.isSelected = true; + + addSegment(doc, br); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selected segment after unselected selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + marker.isSelected = false; + para.segments.push(marker); + doc.blocks.push(para); + + const br = createBr({ fontFamily: 'Arial' }); + + br.isSelected = true; + + addSegment(doc, br); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: false, + }, + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts new file mode 100644 index 00000000000..c3ccabd1dfa --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts @@ -0,0 +1,209 @@ +import * as isWhiteSpacePreserved from '../../../lib/domUtils/isWhiteSpacePreserved'; +import { addTextSegment } from '../../../lib/modelApi/common/addTextSegment'; +import { createBr } from '../../../lib/modelApi/creators/createBr'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; + +describe('addTextSegment', () => { + it('Add empty text', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, '', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Add text with space only', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: true, + }, + ], + }); + }); + + it('Add text with space only, has existing segment', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const br = createBr(); + + para.segments.push(br); + group.blocks.push(para); + + const context = createDomToModelContext(); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + }, + ], + }); + }); + + it('Add text with space only, white space preserved', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + spyOn(isWhiteSpacePreserved, 'isWhiteSpacePreserved').and.returnValue(true); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, no existing paragraph', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, to existing paragraph', () => { + const group = createContentModelDocument(); + const paragraph = createParagraph(); + + group.blocks.push(paragraph); + + const context = createDomToModelContext(); + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Add text, already in selection', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.isInSelection = true; + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, has format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.segmentFormat.fontFamily = 'Arial'; + context.blockFormat.textAlign = 'end'; + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'end' }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontFamily: 'Arial' }, + }, + ], + isImplicit: true, + }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 846a6583fa2..b858eedabf1 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -54,7 +54,7 @@ describe(ID, () => { paste(editor, CD); - const model = editor.getContentModelCopy('connected'); + const model = editor.getContentModelCopy('disconnected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 4e4df8022e7..db4122522ce 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -51,7 +51,7 @@ describe(ID, () => { paste(editor, clipboardData); - const model = editor.getContentModelCopy('connected'); + const model = editor.getContentModelCopy('disconnected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts index 589dde68a8a..042f7ab0532 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts @@ -85,6 +85,12 @@ export type ElementProcessorMap = { */ '#text': ElementProcessor; + /** + * Processor for text node with selection. + * This is an internal processor used by #text processor + */ + textWithSelection: ElementProcessor; + /** * Processor for entity */ diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index fc222c352d3..1f49594166e 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -91,7 +91,8 @@ export type SetLogicalRoot = (core: EditorCore, logicalRoot: HTMLDivElement | nu export type FormatContentModel = ( core: EditorCore, formatter: ContentModelFormatter, - options?: FormatContentModelOptions + options?: FormatContentModelOptions, + domToModelOptions?: DomToModelOption ) => void; /** diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index c21e0231380..2b0de6c7002 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -1,3 +1,4 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; import type { PluginEventType } from '../event/PluginEventType'; @@ -72,7 +73,11 @@ export interface IEditor { * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ - formatContentModel(formatter: ContentModelFormatter, options?: FormatContentModelOptions): void; + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatContentModelOptions, + domToModelOption?: DomToModelOption + ): void; /** * Get pending format of editor if any, or return null diff --git a/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts b/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts index af6cf9fae6d..e99db906635 100644 --- a/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts +++ b/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts @@ -3,9 +3,4 @@ import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; /** * Content Model of Selection Marker */ -export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> { - /** - * Whether this segment is selected - */ - isSelected: true; -} +export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> {} diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 3ed3052748c..0036c9aafe8 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -149,7 +149,7 @@ describe('EditorAdapter', () => { editor.formatContentModel(callback, options); - expect(formatContentModelSpy).toHaveBeenCalledWith(core, callback, options); + expect(formatContentModelSpy).toHaveBeenCalledWith(core, callback, options, undefined); }); it('default format', () => {