From 4d0313be19f7eace43d75b322b52f874fc5d0593 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 24 Oct 2024 13:01:05 -0700 Subject: [PATCH 1/7] Merge text segments --- .../lib/modelApi/common/normalizeParagraph.ts | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index c86efc34dae..6a760889ac2 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -2,12 +2,16 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; -import { mutateBlock, mutateSegment } from './mutate'; +import { mutateBlock, mutateSegment, mutateSegments } from './mutate'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelSegmentFormat, + ContentModelText, + ReadonlyContentModelCode, + ReadonlyContentModelLink, ReadonlyContentModelParagraph, ReadonlyContentModelSegment, + ReadonlyContentModelText, } from 'roosterjs-content-model-types'; /** @@ -47,9 +51,8 @@ export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { } removeEmptyLinks(paragraph); - removeEmptySegments(paragraph); - + mergeTextSegments(paragraph); moveUpSegmentFormat(paragraph); } @@ -73,6 +76,58 @@ function removeEmptySegments(block: ReadonlyContentModelParagraph) { } } +function mergeTextSegments(block: ReadonlyContentModelParagraph) { + let lastText: ReadonlyContentModelText | null = null; + + for (let i = 0; i < block.segments.length; i++) { + const segment = block.segments[i]; + + if (segment.segmentType != 'Text') { + lastText = null; + } else if (!lastText || !segmentsWithSameFormat(lastText, segment)) { + lastText = segment; + } else { + const [mutableBlock, [mutableLastText]] = mutateSegments(block, [lastText, segment]); + + (mutableLastText as ContentModelText).text += segment.text; + mutableBlock.segments.splice(i, 1); + i--; + } + } +} + +function segmentsWithSameFormat( + seg1: ReadonlyContentModelSegment, + seg2: ReadonlyContentModelSegment +) { + return ( + !!seg1.isSelected == !!seg2.isSelected && + areSameFormats(seg1.format, seg2.format) && + areSameLinks(seg1.link, seg2.link) && + areSameCodes(seg1.code, seg2.code) + ); +} + +function areSameLinks( + link1: ReadonlyContentModelLink | undefined, + link2: ReadonlyContentModelLink | undefined +) { + return ( + (!link1 && !link2) || + (link1 && + link2 && + areSameFormats(link1.format, link2.format) && + areSameFormats(link1.dataset, link2.dataset)) + ); +} + +function areSameCodes( + code1: ReadonlyContentModelCode | undefined, + code2: ReadonlyContentModelCode | undefined +) { + return (!code1 && !code2) || (code1 && code2 && areSameFormats(code1.format, code2.format)); +} + function removeEmptyLinks(paragraph: ReadonlyContentModelParagraph) { const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); if (marker) { From 977181444ac58fdf9f0211b2b2bcc682d7095601 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 24 Oct 2024 14:00:02 -0700 Subject: [PATCH 2/7] Fix test --- .../command/paste/mergePasteContentTest.ts | 54 +--- .../common/normalizeContentModelTest.ts | 7 +- .../modelApi/common/normalizeParagraphTest.ts | 21 +- .../test/modelApi/editing/mergeModelTest.ts | 25 +- .../paste/processPastedContentFromWacTest.ts | 290 ++---------------- ...processPastedContentFromWordDesktopTest.ts | 28 +- 6 files changed, 42 insertions(+), 383 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts index 71efb5798c0..2810be3d786 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -971,7 +971,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line', + text: 'Unformatted line\n', format: { fontSize: '14px', textColor: 'white', @@ -1149,15 +1149,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line', - format: { - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Unformatted line\n', format: { fontSize: '14px', textColor: 'white', @@ -1490,15 +1482,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text', - format: { - fontSize: '14px', - textColor: 'rgb(0,0,0)', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1553,15 +1537,7 @@ describe('mergePasteContent', () => { }, { segmentType: 'Text', - text: 'Inline text', - format: { - fontSize: '14px', - textColor: 'rgb(0,0,0)', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1629,16 +1605,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text', - format: { - fontFamily: 'Aptos', - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontFamily: 'Aptos', fontSize: '14px', @@ -1686,16 +1653,7 @@ describe('mergePasteContent', () => { { segmentType: 'Text', text: 'Text in source', format: {} }, { segmentType: 'Text', - text: 'Inline text', - format: { - fontFamily: 'Aptos', - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontFamily: 'Aptos', fontSize: '14px', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts index bc3aa260c52..3466a7656b3 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts @@ -59,12 +59,7 @@ describe('normalizeContentModel', () => { { segmentType: 'Text', format: {}, - text: 'test1', - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', + text: 'test1test2', }, ], }, diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index 1de5d7c3e3b..dea4db38112 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -69,9 +69,9 @@ describe('Normalize text that contains space', () => { }); it('Text ends with  ', () => { - runTest(['a\u00A0', 'b'], ['a ', 'b']); - runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 ', 'b']); - runTest(['a \u00A0', 'b'], ['a \u00A0', 'b']); + runTest(['a\u00A0', 'b'], ['a b']); + runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 b']); + runTest(['a \u00A0', 'b'], ['a \u00A0b']); }); it('with other type of segment', () => { @@ -166,12 +166,7 @@ describe('Normalize text that contains space', () => { segments: [ { segmentType: 'Text', - text: 'a ', - format: {}, - }, - { - segmentType: 'Text', - text: '\u00A0b', + text: 'a \u00A0b', format: {}, }, ], @@ -528,17 +523,11 @@ describe('Move up format', () => { segments: [ { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', + text: 'test1test2', format: {}, }, ], format: {}, - cachedElement: mockedCache, }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 6d55c59b8d7..11e93d3d0cf 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -96,12 +96,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', + text: 'test1test2', format: {}, }, { @@ -400,12 +395,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test11', - format: {}, - }, - { - segmentType: 'Text', - text: 'newText1', + text: 'test11newText1', format: {}, }, ], @@ -1700,12 +1690,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, @@ -2952,9 +2937,7 @@ describe('mergeModel', () => { const paragraph: ContentModelParagraph = { blockType: 'Paragraph', segments: [ - { segmentType: 'Text', text: 'test1', format: {} }, - { segmentType: 'Text', text: 'sourceTest1', format: {} }, - { segmentType: 'Text', text: 'sourceTest2', format: {} }, + { segmentType: 'Text', text: 'test1sourceTest1sourceTest2', format: {} }, { segmentType: 'SelectionMarker', isSelected: true, diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index 50ff7a50e80..fab4753280f 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -3103,10 +3103,7 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [ - { segmentType: 'Text', text: 'it went: ', format: {} }, - { segmentType: 'Text', text: ' ', format: {} }, - ], + segments: [{ segmentType: 'Text', text: 'it went:  ', format: {} }], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -3135,10 +3132,7 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [ - { segmentType: 'Text', text: 'Test.', format: {} }, - { segmentType: 'Text', text: ' ', format: {} }, - ], + segments: [{ segmentType: 'Text', text: 'Test. ', format: {} }], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -4417,19 +4411,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4483,19 +4465,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4549,19 +4519,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4625,19 +4583,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4701,19 +4647,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4787,19 +4721,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4873,19 +4795,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4949,19 +4859,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5025,19 +4923,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5091,19 +4977,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5258,19 +5132,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5369,19 +5231,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5441,19 +5291,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5502,19 +5340,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5619,19 +5445,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5730,19 +5544,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5801,19 +5603,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5918,19 +5708,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5979,19 +5757,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -6050,19 +5816,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 07f17b4039d..bfa5272bc6e 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -80,12 +80,7 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'Test', - format: {}, - }, - { - segmentType: 'Text', - text: 'Test', + text: 'TestTest', format: {}, }, ], @@ -106,12 +101,7 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'Test', - format: {}, - }, - { - segmentType: 'Text', - text: 'Test', + text: 'TestTest', format: {}, }, ], @@ -4264,12 +4254,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text', - segmentType: 'Text', - format: {}, - }, - { - text: '.', + text: 'text.', segmentType: 'Text', format: {}, }, @@ -4848,12 +4833,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text', - segmentType: 'Text', - format: {}, - }, - { - text: ' ', + text: 'text ', segmentType: 'Text', format: {}, }, From c3397c7b51c2c6f31b4c2b4b57d6d4794f26527a Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Thu, 24 Oct 2024 17:12:55 -0700 Subject: [PATCH 3/7] merge node --- .../lib/corePlugin/cache/domIndexerImpl.ts | 16 +++++ .../lib/modelToDom/contentModelToDom.ts | 7 ++- .../modelToDom/handlers/handleParagraph.ts | 2 +- .../lib/modelToDom/optimizers/optimize.ts | 59 ++++++++++++++++++- .../lib/context/DomIndexer.ts | 9 +++ .../lib/context/ModelToDomSelectionContext.ts | 5 ++ 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 001b80a0fde..d9ec8246aa5 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -145,6 +145,10 @@ function getIndexedTableItem(element: HTMLTableElement): TableItem | null { } } +function unindex(node: Node) { + delete (node as any).__roosterjsContentModel; +} + /** * @internal * Implementation of DomIndexer @@ -197,6 +201,18 @@ export class DomIndexerImpl implements DomIndexer { this.onBlockEntityDelimiter(entity.wrapper.nextSibling, entity, group); } + onMergeText(targetText: Text, sourceText: Text) { + if (isIndexedSegment(targetText) && isIndexedSegment(sourceText)) { + targetText.__roosterjsContentModel.segments.push( + ...sourceText.__roosterjsContentModel.segments + ); + } else { + unindex(targetText); + } + + unindex(sourceText); + } + reconcileSelection( model: ContentModelDocument, newSelection: DOMSelection, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 131e7d68730..503da52b654 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -36,8 +36,6 @@ export function contentModelToDom( (model as ContentModelDocumentWithPersistedCache).persistCache = true; } - root.normalize(); - return range; } @@ -83,7 +81,10 @@ function calcPosition( if (!pos.segment) { result = { container: pos.block, offset: 0 }; } else if (isNodeOfType(pos.segment, 'TEXT_NODE')) { - result = { container: pos.segment, offset: pos.segment.nodeValue?.length || 0 }; + result = { + container: pos.segment, + offset: pos.offset ?? pos.segment.nodeValue?.length ?? 0, + }; } else if (pos.segment.parentNode) { result = { container: pos.segment.parentNode, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 2ae14c63391..73054976b03 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -98,7 +98,7 @@ export const handleParagraph: ContentModelBlockHandler = handleSegments(); } - optimize(container); + optimize(container, context); // It is possible the next sibling node is changed during processing child segments // e.g. When this paragraph is an implicit paragraph and it contains an inline entity segment diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts index eed720aa157..19cd4ceb2aa 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts @@ -1,11 +1,16 @@ import { isEntityElement } from '../../domUtils/entityUtils'; +import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { mergeNode } from './mergeNode'; import { removeUnnecessarySpan } from './removeUnnecessarySpan'; +import type { + ModelToDomBlockAndSegmentNode, + ModelToDomContext, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function optimize(root: Node) { +export function optimize(root: Node, context: ModelToDomContext) { /** * Do no do any optimization to entity */ @@ -17,6 +22,56 @@ export function optimize(root: Node) { mergeNode(root); for (let child = root.firstChild; child; child = child.nextSibling) { - optimize(child); + optimize(child, context); + } + + normalizeTextNode(root, context); +} + +// Merge continuous text nodes into one single node (same with normalize()), +// and update selection and dom indexes +function normalizeTextNode(root: Node, context: ModelToDomContext) { + let lastText: Text | null = null; + let child: Node | null; + let next: Node | null; + const selection = context.regularSelection; + + for ( + child = root.firstChild, next = child ? child.nextSibling : null; + child; + child = next, next = child ? child.nextSibling : null + ) { + if (!isNodeOfType(child, 'TEXT_NODE')) { + lastText = null; + } else if (!lastText) { + lastText = child; + } else { + const originalLength = lastText.nodeValue?.length ?? 0; + + lastText.nodeValue += child.nodeValue ?? ''; + context.domIndexer?.onMergeText(lastText, child); + + if (selection) { + updateSelection(selection.start, lastText, child, originalLength); + updateSelection(selection.end, lastText, child, originalLength); + } + + root.removeChild(child); + } + } +} + +function updateSelection( + mark: ModelToDomBlockAndSegmentNode | undefined, + lastText: Text, + nextText: Text, + lastTextOriginalLength: number +) { + if (mark && mark.offset == undefined) { + if (mark.segment == lastText) { + mark.offset = lastTextOriginalLength; + } else if (mark.segment == nextText) { + mark.segment = lastText; + } } } diff --git a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts index 7c600dc0c8f..60a3429a919 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts @@ -43,6 +43,15 @@ export interface DomIndexer { */ onBlockEntity: (entity: ContentModelEntity, group: ContentModelBlockGroup) => void; + /** + * Invoke when merge two continuous text nodes, we need to merge their indexes as well + * @param targetText Target text node to merge into + * @param sourceText Source text node to merge from + * @example Assume we have two text nodes: Node1="Foo", Node2="Bar", after merge, + * Node1 will become "FooBar", Node2 will be removed from DOM tree + */ + onMergeText: (targetText: Text, sourceText: Text) => void; + /** * When document content or selection is changed by user, we need to use this function to update the content model * to reflect the latest document. This process can fail since the selected node may not have a related model data structure. diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts index d1af5f375c3..525b2aec21e 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts @@ -14,6 +14,11 @@ export interface ModelToDomBlockAndSegmentNode { * Segment node of this position. When provided, it represents the position right after this node */ segment: Node | null; + + /** + * Offset number of this position. It is only used for Text node, default value is 0 + */ + offset?: number; } /** From 3fa9fa8de09142d6d8ac1ef3cb5b7bda08570022 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 25 Oct 2024 10:19:32 -0700 Subject: [PATCH 4/7] fix build and test --- .../lib/modelToDom/contentModelToDom.ts | 2 ++ .../test/domToModel/processors/brProcessorTest.ts | 1 + .../test/domToModel/processors/entityProcessorTest.ts | 1 + .../test/domToModel/processors/generalProcessorTest.ts | 1 + .../test/domToModel/processors/imageProcessorTest.ts | 1 + .../test/domToModel/processors/tableProcessorTest.ts | 1 + .../test/domToModel/processors/textProcessorTest.ts | 3 +++ .../test/modelToDom/handlers/handleParagraphTest.ts | 4 +++- .../test/modelToDom/handlers/handleTableTest.ts | 1 + .../test/modelToDom/optimizers/optimizeTest.ts | 6 +++--- 10 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 503da52b654..cfdde4f1520 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -36,6 +36,8 @@ export function contentModelToDom( (model as ContentModelDocumentWithPersistedCache).persistCache = true; } + root.normalize(); + return range; } diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index 9ba29b9601b..5d36f9b76e6 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -77,6 +77,7 @@ describe('brProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index 8521782a205..52b005ea005 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -262,6 +262,7 @@ describe('entityProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index a6d516f3e66..528415eb76f 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -397,6 +397,7 @@ describe('generalProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index 15bd59f68a7..ec391daf6a6 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -326,6 +326,7 @@ describe('imageProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 27bcf4221be..c18816850d4 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -293,6 +293,7 @@ describe('tableProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; 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 621e29e17a8..40fb02ac06b 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -580,6 +580,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -617,6 +618,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -665,6 +667,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 2c906304572..74a896ed06c 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -431,7 +431,7 @@ describe('handleParagraph', () => { expect(para2.cachedElement).toBe(parent.firstChild?.nextSibling as HTMLElement); expect(para2.cachedElement?.outerHTML).toBe('
test2
'); - optimize(parent); + optimize(parent, context); expect(parent.innerHTML).toBe( '
test1
test2

' @@ -584,6 +584,7 @@ describe('handleParagraph', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -630,6 +631,7 @@ describe('handleParagraph', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 5eac39a4727..75cc441f0c3 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -604,6 +604,7 @@ describe('handleTable', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts index 3d3a934706f..c3acd36e9b4 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts @@ -11,7 +11,7 @@ describe('optimize', () => { it('Optimize', () => { const div = document.createElement('div'); - optimize(div); + optimize(div, {} as any); expect(mergeNode.mergeNode).toHaveBeenCalled(); expect(removeUnnecessarySpan.removeUnnecessarySpan).toHaveBeenCalled(); @@ -22,7 +22,7 @@ describe('optimize', () => { const span = document.createElement('span'); div.appendChild(span); - optimize(div); + optimize(div, {} as any); expect(mergeNode.mergeNode).toHaveBeenCalledTimes(2); expect(mergeNode.mergeNode).toHaveBeenCalledWith(div); @@ -49,7 +49,7 @@ describe('real optimization', () => { div.appendChild(span1); div.appendChild(span2); - optimize(div); + optimize(div, {} as any); expect(div.outerHTML).toBe( '
test1entity
' From 2c185e1d6be187c83993bed70be2b8e88183d1c8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 25 Oct 2024 12:14:24 -0700 Subject: [PATCH 5/7] add test --- .../lib/corePlugin/cache/domIndexerImpl.ts | 18 ++-- .../corePlugin/cache/domIndexerImplTest.ts | 97 +++++++++++++++++++ .../lib/modelToDom/optimizers/optimize.ts | 2 +- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index d9ec8246aa5..dd96fd509ae 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -145,8 +145,9 @@ function getIndexedTableItem(element: HTMLTableElement): TableItem | null { } } -function unindex(node: Node) { - delete (node as any).__roosterjsContentModel; +// Make a node not indexed. Do not export this function since we should not let code outside here know this detail +function unindex(node: Partial) { + delete node.__roosterjsContentModel; } /** @@ -203,14 +204,17 @@ export class DomIndexerImpl implements DomIndexer { onMergeText(targetText: Text, sourceText: Text) { if (isIndexedSegment(targetText) && isIndexedSegment(sourceText)) { - targetText.__roosterjsContentModel.segments.push( - ...sourceText.__roosterjsContentModel.segments - ); + if (targetText.nextSibling == sourceText) { + targetText.__roosterjsContentModel.segments.push( + ...sourceText.__roosterjsContentModel.segments + ); + + unindex(sourceText); + } } else { + unindex(sourceText); unindex(targetText); } - - unindex(sourceText); } reconcileSelection( diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index 85c4310d0b6..a1a3b565a75 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -250,6 +250,103 @@ describe('domIndexerImpl.onBlockEntity', () => { }); }); +describe('domIndexImpl.onMergeText', () => { + it('Two unindexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); + + it('One indexed node, one unindexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); + + it('Two separated indexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(document.createElement('img')); + div.appendChild(text2); + + const text1Model = createText('test1'); + const text2Model = createText('test2'); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text1Model], + }; + ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text2Model], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text1Model], + }); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text2Model], + }); + }); + + it('Two continuous indexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + const text1Model = createText('test1'); + const text2Model = createText('test2'); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text1Model], + }; + ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text2Model], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text1Model, text2Model], + }); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); +}); + describe('domIndexerImpl.reconcileSelection', () => { let setSelectionSpy: jasmine.Spy; let model: ContentModelDocument; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts index 19cd4ceb2aa..465017f41f4 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts @@ -48,8 +48,8 @@ function normalizeTextNode(root: Node, context: ModelToDomContext) { } else { const originalLength = lastText.nodeValue?.length ?? 0; - lastText.nodeValue += child.nodeValue ?? ''; context.domIndexer?.onMergeText(lastText, child); + lastText.nodeValue += child.nodeValue ?? ''; if (selection) { updateSelection(selection.start, lastText, child, originalLength); From a0305689dc97202bacb1f60e2b66c26e322aeee1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 25 Oct 2024 20:55:12 -0700 Subject: [PATCH 6/7] Add test --- .../modelApi/common/normalizeParagraphTest.ts | 661 +++++++++++++++++- .../test/modelToDom/contentModelToDomTest.ts | 70 ++ .../handlers/handleParagraphTest.ts | 341 +++++++++ 3 files changed, 1071 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index dea4db38112..de798f6a570 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -6,7 +6,12 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; -import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; +import { + ContentModelParagraph, + ContentModelSegment, + ContentModelSegmentFormat, + ReadonlyContentModelParagraph, +} from 'roosterjs-content-model-types'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -829,3 +834,657 @@ describe('Move up format', () => { }); }); }); + +describe('Merge text segments', () => { + function runTest( + input: ContentModelSegment[], + expectedResult: ContentModelSegment[], + stillHasCache: boolean, + expectedParagraphFormat?: ContentModelSegmentFormat + ) { + const paragraph = createParagraph(); + const cache = 'CACHE' as any; + + paragraph.cachedElement = cache; + + paragraph.segments = input; + + normalizeParagraph(paragraph); + + const expectedParagraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: expectedResult, + }; + + if (expectedParagraphFormat) { + expectedParagraph.segmentFormat = expectedParagraphFormat; + } + + if (stillHasCache) { + expectedParagraph.cachedElement = cache; + } + + expect(paragraph).toEqual(expectedParagraph); + } + + it('Empty paragraph', () => { + runTest([], [], true); + }); + + it('Single text segment', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ], + true + ); + }); + + it('Two text segments, same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, same format, with space - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: ' abc ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, same format, with space - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc\u00A0def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, different format - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, different format - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two text segments, different format - 3', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two text segments, one has link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have same link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have different link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, one has code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have same code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments around selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments after selection marker', () => { + runTest( + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments before selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Three text segments with same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdefghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two pairs - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghijkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two pairs - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ], + true + ); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts index 871c1a22baf..cd87a418155 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts @@ -104,6 +104,76 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).isReverted).toBe(false); }); + it('Extract expanded range - range in middle of text', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const div = document.createElement('div'); + const text = document.createTextNode('abcd'); + + div.appendChild(text); + root.appendChild(div); + + context.regularSelection.start = { + block: div, + segment: text, + offset: 1, + }; + context.regularSelection.end = { + block: div, + segment: text, + offset: 3, + }; + + const range = contentModelToDom(document, root, {} as any, context); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(text); + expect((range as RangeSelection).range.startOffset).toBe(1); + expect((range as RangeSelection).range.endContainer).toBe(text); + expect((range as RangeSelection).range.endOffset).toBe(3); + expect((range as RangeSelection).isReverted).toBe(false); + }); + + it('Extract range after empty text', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const div = document.createElement('div'); + const text = document.createTextNode(''); + + div.appendChild(text); + root.appendChild(div); + + context.regularSelection.start = { + block: div, + segment: text, + }; + context.regularSelection.end = { + block: div, + segment: text, + }; + + const range = contentModelToDom(document, root, {} as any, context); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(div); + expect((range as RangeSelection).range.startOffset).toBe(0); + expect((range as RangeSelection).range.endContainer).toBe(div); + expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); + }); + it('Extract selection range - normal collapsed range with empty text', () => { const mockedHandler = jasmine.createSpy('blockGroupChildren'); const context = createModelToDomContext(undefined, { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 74a896ed06c..32daefdcfd1 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -652,3 +652,344 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledWith(parent.lastChild, paragraph, [segment2]); }); }); + +describe('Handle paragraph and adjust selections', () => { + it('Selection is at beginning, followed by BR', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('

'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + }); + + it('Selection is at beginning, followed by Text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 0, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 0, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is at end of text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1
test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2test3
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 10, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in front of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: null, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is at the end of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text and BR, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test4', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
test3test4
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.lastChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + }); +}); From e39ada6100061f1e5be65cdc876658782ed09baf Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 25 Oct 2024 21:54:56 -0700 Subject: [PATCH 7/7] fix test --- .../test/modelApi/editing/mergeModelTest.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 7bec42fb72a..78ec1d24aa9 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -4080,12 +4080,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, @@ -4957,12 +4952,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2,