From a0a8ec3c8b83a9c2bd5fffd13f1d576ce46af13d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Nov 2024 11:39:01 -0700 Subject: [PATCH] Bump version to 9.13.0 (#2856) * fix test * test * Merge text node and segments (#2846) * Merge text segments * Fix test * merge node * fix build and test * add test * Add test * fix test * Remove tablePreProcessor (#2849) * Add change data and apiName to ContentChangedEvent when handle keyboard input (#2854) * Change version --------- Co-authored-by: Julia Roldi (from Dev Box) Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> --- .../lib/corePlugin/cache/domIndexerImpl.ts | 20 + .../lib/editor/Editor.ts | 4 - .../core/createEditorDefaultSettings.ts | 7 +- .../lib/override/tablePreProcessor.ts | 30 - .../command/paste/mergePasteContentTest.ts | 54 +- .../corePlugin/cache/domIndexerImplTest.ts | 97 +++ .../test/editor/EditorTest.ts | 8 +- .../core/createEditorDefaultSettingsTest.ts | 28 +- .../test/overrides/tablePreProcessorTest.ts | 214 ------ .../lib/modelApi/common/normalizeParagraph.ts | 61 +- .../lib/modelToDom/contentModelToDom.ts | 5 +- .../modelToDom/handlers/handleParagraph.ts | 2 +- .../lib/modelToDom/optimizers/optimize.ts | 59 +- .../domToModel/processors/brProcessorTest.ts | 1 + .../processors/entityProcessorTest.ts | 1 + .../processors/generalProcessorTest.ts | 1 + .../processors/imageProcessorTest.ts | 1 + .../processors/tableProcessorTest.ts | 1 + .../processors/textProcessorTest.ts | 3 + .../common/normalizeContentModelTest.ts | 7 +- .../modelApi/common/normalizeParagraphTest.ts | 682 +++++++++++++++++- .../test/modelApi/editing/mergeModelTest.ts | 39 +- .../test/modelToDom/contentModelToDomTest.ts | 70 ++ .../handlers/handleParagraphTest.ts | 345 ++++++++- .../modelToDom/handlers/handleTableTest.ts | 1 + .../modelToDom/optimizers/optimizeTest.ts | 6 +- .../lib/edit/keyboardEnter.ts | 2 + .../lib/edit/keyboardInput.ts | 2 + .../lib/edit/keyboardTab.ts | 5 + .../test/autoFormat/AutoFormatPluginTest.ts | 62 +- .../edit/inputSteps/handleEnterOnListTest.ts | 2 +- .../Cropper/createImageCropperTest.ts | 6 + .../test/imageEdit/ImageEditPluginTest.ts | 5 + .../Resizer/createImageResizerTest.ts | 4 + .../Rotator/createImageRotatorTest.ts | 2 +- .../imageEdit/utils/createImageWrapperTest.ts | 6 +- .../test/imageEdit/utils/updateWrapperTest.ts | 1 + .../paste/processPastedContentFromWacTest.ts | 290 +------- ...processPastedContentFromWordDesktopTest.ts | 28 +- .../lib/context/DomIndexer.ts | 9 + .../lib/context/ModelToDomSelectionContext.ts | 5 + versions.json | 2 +- 42 files changed, 1468 insertions(+), 710 deletions(-) delete mode 100644 packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts delete mode 100644 packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts 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..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,6 +145,11 @@ function getIndexedTableItem(element: HTMLTableElement): TableItem | null { } } +// 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; +} + /** * @internal * Implementation of DomIndexer @@ -197,6 +202,21 @@ export class DomIndexerImpl implements DomIndexer { this.onBlockEntityDelimiter(entity.wrapper.nextSibling, entity, group); } + onMergeText(targetText: Text, sourceText: Text) { + if (isIndexedSegment(targetText) && isIndexedSegment(sourceText)) { + if (targetText.nextSibling == sourceText) { + targetText.__roosterjsContentModel.segments.push( + ...sourceText.__roosterjsContentModel.segments + ); + + unindex(sourceText); + } + } else { + unindex(sourceText); + unindex(targetText); + } + } + reconcileSelection( model: ContentModelDocument, newSelection: DOMSelection, diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 6bad5976a24..c01aee381c7 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -1,7 +1,6 @@ import { createEditorCore } from './core/createEditorCore'; import { createEmptyModel, - tableProcessor, ChangeSource, cloneModel, transformColor, @@ -102,9 +101,6 @@ export class Editor implements IEditor { case 'disconnected': return cloneModel( core.api.createContentModel(core, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }), { diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 52bd64b886f..ffab5e66ffa 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -1,5 +1,4 @@ import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { tablePreProcessor } from '../../override/tablePreProcessor'; import { listItemMetadataApplier, listLevelMetadataApplier, @@ -21,11 +20,7 @@ import type { export function createDomToModelSettings( options: EditorOptions ): ContentModelSettings { - const builtIn: DomToModelOption = { - processorOverride: { - table: tablePreProcessor, - }, - }; + const builtIn: DomToModelOption = {}; const customized: DomToModelOption = options.defaultDomToModelOptions ?? {}; return { diff --git a/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts b/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts deleted file mode 100644 index 0a753606abd..00000000000 --- a/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - entityProcessor, - getSelectionRootNode, - hasMetadata, - tableProcessor, -} from 'roosterjs-content-model-dom'; -import type { DomToModelContext, ElementProcessor } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export const tablePreProcessor: ElementProcessor = (group, element, context) => { - const processor = shouldUseTableProcessor(element, context) ? tableProcessor : entityProcessor; - - processor(group, element, context); -}; - -function shouldUseTableProcessor(element: HTMLTableElement, context: DomToModelContext) { - const selectionRoot = getSelectionRootNode(context.selection); - // Treat table as a real table when: - // 1. It is a roosterjs table (has metadata) - // 2. Table is in selection - // 3. There is selection inside table (or whole table is selected) - // Otherwise, we treat the table as entity so we will not change it when write back - return ( - hasMetadata(element) || - context.isInSelection || - (selectionRoot && element.contains(selectionRoot)) - ); -} 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-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-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 3db0c62136f..ace32194a31 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -4,9 +4,9 @@ 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 { ChangeSource, tableProcessor } from 'roosterjs-content-model-dom'; import { Editor } from '../../lib/editor/Editor'; import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; +import { ChangeSource } from 'roosterjs-content-model-dom'; import { CachedElementHandler, ContentModelDocument, @@ -198,9 +198,6 @@ describe('Editor', () => { expect(model).toBe(mockedClonedModel); expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }); expect(transformColorSpy).not.toHaveBeenCalled(); @@ -212,9 +209,6 @@ describe('Editor', () => { expect(model).toBe(mockedClonedModel); expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }); expect(transformColorSpy).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index 2c9a69aab21..942c94313df 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -1,6 +1,5 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; -import { tablePreProcessor } from '../../../lib/override/tablePreProcessor'; import { listItemMetadataApplier, listLevelMetadataApplier, @@ -23,22 +22,11 @@ describe('createDomToModelSettings', () => { const settings = createDomToModelSettings({}); expect(settings).toEqual({ - builtIn: { - processorOverride: { - table: tablePreProcessor, - }, - }, + builtIn: {}, customized: {}, calculated: mockedCalculatedConfig, }); - expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ - { - processorOverride: { - table: tablePreProcessor, - }, - }, - {}, - ]); + expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([{}, {}]); }); it('Has options', () => { @@ -48,20 +36,12 @@ describe('createDomToModelSettings', () => { }); expect(settings).toEqual({ - builtIn: { - processorOverride: { - table: tablePreProcessor, - }, - }, + builtIn: {}, customized: defaultDomToModelOptions, calculated: mockedCalculatedConfig, }); expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ - { - processorOverride: { - table: tablePreProcessor, - }, - }, + {}, defaultDomToModelOptions, ]); }); diff --git a/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts b/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts deleted file mode 100644 index 55089ec522a..00000000000 --- a/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts +++ /dev/null @@ -1,214 +0,0 @@ -import * as tableProcessor from 'roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor'; -import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; -import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; - -describe('tablePreProcessor', () => { - it('Table without metadata, use Entity', () => { - const table = document.createElement('table'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - entityFormat: { - isFakeEntity: true, - id: undefined, - entityType: undefined, - isReadonly: true, - }, - wrapper: table, - }, - ], - }); - }); - - it('Table with metadata, do not use Entity', () => { - const table = document.createElement('table'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.dataset.editingInfo = '{}'; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 1, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: txt, - } as any, - isReverted: false, - }; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 2, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: txt, - } as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 3, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: table, - } as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with table selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'table', - table, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with image selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'image', - image: txt as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table in selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.isInSelection = true; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); -}); 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) { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 131e7d68730..cfdde4f1520 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -83,7 +83,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..465017f41f4 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; + + context.domIndexer?.onMergeText(lastText, child); + lastText.nodeValue += child.nodeValue ?? ''; + + 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-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/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..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) { @@ -69,9 +74,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 +171,7 @@ describe('Normalize text that contains space', () => { segments: [ { segmentType: 'Text', - text: 'a ', - format: {}, - }, - { - segmentType: 'Text', - text: '\u00A0b', + text: 'a \u00A0b', format: {}, }, ], @@ -528,17 +528,11 @@ describe('Move up format', () => { segments: [ { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', + text: 'test1test2', format: {}, }, ], format: {}, - cachedElement: mockedCache, }); }); @@ -840,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/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index f806f20e84a..78ec1d24aa9 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, @@ -4097,12 +4080,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, @@ -4974,12 +4952,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, 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 2c906304572..32daefdcfd1 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; @@ -650,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); + }); +}); 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
' diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 2bb32aa5fe5..4b31935d854 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -58,6 +58,8 @@ export function keyboardEnter( rawEvent, scrollCaretIntoView: true, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, + apiName: 'handleEnterKey', } ); } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 95ffb0d8667..201f387f455 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -38,6 +38,8 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { scrollCaretIntoView: true, rawEvent, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, + apiName: 'handleInputKey', } ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 495b79fdc80..0c275284a93 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -30,6 +30,9 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { }, { apiName: 'handleTabKey', + rawEvent, + changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, } ); @@ -41,7 +44,9 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { }, { apiName: 'handleTabKey', + rawEvent, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, } ); 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 f9cb705bc4f..481571448fc 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1044,12 +1044,17 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', format: {}, }; + const selectionMarker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; const paragraph: ContentModelParagraph = { blockType: 'Paragraph', - segments: [segment], + segments: [segment, selectionMarker], format: {}, }; const model: ContentModelDocument = { @@ -1077,7 +1082,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectedResult); } - xit('should call transformHyphen', () => { + it('should call transformHyphen', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1096,6 +1101,17 @@ describe('Content Model Auto Format Plugin Test', () => { format: {}, isSelected: undefined, }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1123,8 +1139,13 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', + format: {}, + }, + { + segmentType: 'SelectionMarker', format: {}, + isSelected: true, }, ], format: {}, @@ -1157,9 +1178,14 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', format: {}, }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1185,7 +1211,7 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: '1/2', + text: '1/2 ', format: {}, }; const paragraph: ContentModelParagraph = { @@ -1218,7 +1244,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectResult); } - xit('should call transformFraction', () => { + it('should call transformFraction', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1238,6 +1264,12 @@ describe('Content Model Auto Format Plugin Test', () => { format: {}, isSelected: undefined, }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: undefined, + }, ], }, ], @@ -1249,7 +1281,7 @@ describe('Content Model Auto Format Plugin Test', () => { ); }); - it('should not call transformHyphen - disable options', () => { + it('should not call transformFraction - disable options', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1262,7 +1294,7 @@ describe('Content Model Auto Format Plugin Test', () => { { blockType: 'Paragraph', format: {}, - segments: [{ segmentType: 'Text', text: '1/2', format: {} }], + segments: [{ segmentType: 'Text', text: '1/2 ', format: {} }], }, ], }, @@ -1286,7 +1318,7 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: '1st', + text: '1st ', format: {}, }; const paragraph: ContentModelParagraph = { @@ -1319,7 +1351,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectResult); } - xit('should call transformOrdinals', () => { + it('should call transformOrdinals', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1345,6 +1377,12 @@ describe('Content Model Auto Format Plugin Test', () => { format: { superOrSubScriptSequence: 'super' }, isSelected: undefined, }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: undefined, + }, ], }, ], @@ -1369,7 +1407,7 @@ describe('Content Model Auto Format Plugin Test', () => { { blockType: 'Paragraph', format: {}, - segments: [{ segmentType: 'Text', text: '1st', format: {} }], + segments: [{ segmentType: 'Text', text: '1st ', format: {} }], }, ], }, diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index c96f2291b05..da42138e570 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1817,7 +1817,7 @@ describe('handleEnterOnList - keyboardEnter', () => { let editor: any; editingTestCommon( - undefined, + 'handleEnterKey', newEditor => { editor = newEditor; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts index 820270013c9..e30d87e825a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts @@ -44,5 +44,11 @@ describe('createImageCropper', () => { ] as HTMLDivElement[]; expect(JSON.stringify(croppers)).toEqual(JSON.stringify(expectedCropper)); + + cropCenterDiv.remove(); + cropOverlayTopLeftDiv.remove(); + cropOverlayTopRightDiv.remove(); + cropOverlayBottomLeftDiv.remove(); + cropOverlayBottomRightDiv.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index d00cc2cde4a..9bcaabf2f3a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -148,6 +148,10 @@ describe('ImageEditPlugin', () => { } as any; }); + afterEach(() => { + editor = null!; + }); + it('keyDown', () => { const mockedImage = { getAttribute: getAttributeSpy, @@ -719,6 +723,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { plugin.dispose(); editor.dispose(); editor = null; + mockedImage.remove(); } it('image to text', () => { diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts index 590b69ef19f..7c7ab3101eb 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts @@ -12,6 +12,10 @@ describe('createImageResizer', () => { const result = createImageResizer(document); const resizers = [...createCorners(), ...createSides()].filter(element => !!element); expect(result).toEqual(resizers); + + result.forEach((resizer, index) => { + resizer.remove(); + }); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts index c707a5f6d0e..8beba2076b2 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts @@ -15,6 +15,6 @@ describe('createImageRotator', () => { const expectedRotator = div.firstElementChild! as HTMLDivElement; expect(result).toEqual([expectedRotator]); - document.body.removeChild(div); + div.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index 33ccfa11cee..24e9fb43605 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -77,7 +77,7 @@ describe('createImageWrapper', () => { rotators: [], croppers: [], }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); it('rotate', () => { @@ -124,7 +124,7 @@ describe('createImageWrapper', () => { rotators: rotator, croppers: [], }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); it('crop', () => { @@ -179,7 +179,7 @@ describe('createImageWrapper', () => { rotators: [], croppers: cropper, }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts index 9575c9c8b5b..eebc19dcce8 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -56,5 +56,6 @@ describe('updateWrapper', () => { expect(imageClone.style.height).toBe('13.3333px'); expect(imageClone.style.verticalAlign).toBe('bottom'); expect(imageClone.style.position).toBe('absolute'); + image.remove(); }); }); 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: {}, }, 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; } /** diff --git a/versions.json b/versions.json index 41880c50943..bb9fa365751 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.0", - "main": "9.12.0", + "main": "9.13.0", "legacyAdapter": "8.62.2", "overrides": {} }