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 4f7a035882d..96bb781bb01 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -22,20 +22,32 @@ import type { Selectable, } from 'roosterjs-content-model-types'; -interface SegmentItem { +/** + * @internal Export for test only + */ +export interface SegmentItem { paragraph: ContentModelParagraph; segments: ContentModelSegment[]; } -interface TableItem { +/** + * @internal Export for test only + */ +export interface TableItem { tableRows: ContentModelTableRow[]; } -interface IndexedSegmentNode extends Node { +/** + * @internal Export for test only + */ +export interface IndexedSegmentNode extends Node { __roosterjsContentModel: SegmentItem; } -interface IndexedTableElement extends HTMLTableElement { +/** + * @internal Export for test only + */ +export interface IndexedTableElement extends HTMLTableElement { __roosterjsContentModel: TableItem; } @@ -89,7 +101,7 @@ function getIndexedSegmentItem(node: Node | null): SegmentItem | null { * Implementation of DomIndexer */ export class DomIndexerImpl implements DomIndexer { - constructor(public readonly persistCache?: boolean) {} + constructor(private readonly persistCache?: boolean) {} onSegment(segmentNode: Node, paragraph: ContentModelParagraph, segment: ContentModelSegment[]) { const indexedText = segmentNode as IndexedSegmentNode; @@ -206,6 +218,37 @@ export class DomIndexerImpl implements DomIndexer { return false; } + reconcileChildList(addedNodes: ArrayLike, removedNodes: ArrayLike): boolean { + if (!this.persistCache) { + return false; + } + + let canHandle = true; + const context: ReconcileChildListContext = { + segIndex: -1, + }; + + // First process added nodes + const addedNode = addedNodes[0]; + + if (addedNodes.length == 1 && isNodeOfType(addedNode, 'TEXT_NODE')) { + canHandle = this.reconcileAddedNode(addedNode, context); + } else if (addedNodes.length > 0) { + canHandle = false; + } + + // Second, process removed nodes + const removedNode = removedNodes[0]; + + if (canHandle && removedNodes.length == 1) { + canHandle = this.reconcileRemovedNode(removedNode, context); + } else if (removedNodes.length > 0) { + canHandle = false; + } + + return canHandle && !context.pendingTextNode; + } + private isCollapsed(selection: RangeSelectionForCache): boolean { const { start, end } = selection; @@ -331,33 +374,6 @@ export class DomIndexerImpl implements DomIndexer { return selectable; } - reconcileChildList(addedNodes: ArrayLike, removedNodes: ArrayLike): boolean { - let canHandle = true; - const context: ReconcileChildListContext = { - segIndex: -1, - }; - - // First process added nodes - const addedNode = addedNodes[0]; - - if (addedNodes.length == 1 && isNodeOfType(addedNode, 'TEXT_NODE')) { - canHandle = this.reconcileAddedNode(addedNode, context); - } else if (addedNodes.length > 0) { - canHandle = false; - } - - // Second, process removed nodes - const removedNode = removedNodes[0]; - - if (canHandle && removedNodes.length == 1) { - canHandle = this.reconcileRemovedNode(removedNode, context); - } else if (removedNodes.length > 0) { - canHandle = false; - } - - return canHandle && !context.pendingTextNode; - } - private reconcileAddedNode(node: Text, context: ReconcileChildListContext): boolean { let segmentItem: SegmentItem | null = null; let index = -1; @@ -409,6 +425,11 @@ export class DomIndexerImpl implements DomIndexer { context.paragraph = segmentItem.paragraph; context.segIndex = segmentItem.paragraph.segments.indexOf(segmentItem.segments[0]); + if (context.segIndex < 0) { + // Indexed segment is not under paragraph, something wrong happens, we cannot keep handling + return false; + } + for (let i = 0; i < segmentItem.segments.length; i++) { const index = segmentItem.paragraph.segments.indexOf(segmentItem.segments[i]); 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 5905e3a26bb..698df5f7afc 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -1,6 +1,6 @@ import * as setSelection from 'roosterjs-content-model-dom/lib/modelApi/selection/setSelection'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; -import { DomIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; +import { DomIndexerImpl, IndexedSegmentNode } from '../../../lib/corePlugin/cache/domIndexerImpl'; import { CacheSelection, ContentModelDocument, @@ -737,3 +737,181 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(model.hasRevertedRangeSelection).toBeFalsy(); }); }); + +describe('domIndexerImpl.reconcileChildList', () => { + it('Empty array', () => { + const domIndexer = new DomIndexerImpl(true); + const result = domIndexer.reconcileChildList([], []); + + expect(result).toBeTrue(); + }); + + it('Removed BR, not indexed', () => { + const domIndexer = new DomIndexerImpl(true); + const br = document.createElement('br'); + const result = domIndexer.reconcileChildList([], [br]); + + expect(result).toBeFalse(); + }); + + it('Removed BR, indexed, segment is not under paragraph', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Node = document.createElement('br'); + + const paragraph = createParagraph(); + const segment = createBr(); + + (br as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment], + }; + + const result = domIndexer.reconcileChildList([], [br]); + + expect(result).toBeFalse(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [], + }); + }); + + it('Removed BR, indexed, segment is under paragraph', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Node = document.createElement('br'); + + const paragraph = createParagraph(); + const segment1 = createText('test1'); + const segment2 = createBr(); + const segment3 = createText('test3'); + + paragraph.segments.push(segment1, segment2, segment3); + + (br as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment2], + }; + + const result = domIndexer.reconcileChildList([], [br]); + + expect(result).toBeTrue(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment3], + }); + }); + + it('Removed two BR, indexed', () => { + const domIndexer = new DomIndexerImpl(true); + const br1: Node = document.createElement('br'); + const br2: Node = document.createElement('br'); + + const paragraph = createParagraph(); + const segment1 = createBr(); + const segment2 = createBr(); + const segment3 = createText('test3'); + + paragraph.segments.push(segment1, segment2, segment3); + + (br1 as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment1], + }; + + (br2 as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment2], + }; + + const result = domIndexer.reconcileChildList([], [br1, br2]); + + expect(result).toBeFalse(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment2, segment3], + }); + }); + + it('Added BR', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Node = document.createElement('br'); + + const result = domIndexer.reconcileChildList([br], []); + + expect(result).toBeFalse(); + }); + + it('Added Text', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Text = document.createTextNode('test'); + + const result = domIndexer.reconcileChildList([], [br]); + + expect(result).toBeFalse(); + }); + + it('Added Text, remove BR', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Node = document.createElement('br'); + const text: Text = document.createTextNode('test'); + + const paragraph = createParagraph(); + const segment = createBr({ + fontSize: '10pt', + }); + + paragraph.segments.push(segment); + + (br as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment], + }; + + const result = domIndexer.reconcileChildList([text], [br]); + + expect(result).toBeTrue(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontSize: '10pt', + }, + }, + ], + }); + }); + + it('Added two Texts, remove BR', () => { + const domIndexer = new DomIndexerImpl(true); + const br: Node = document.createElement('br'); + const text1: Text = document.createTextNode('test1'); + const text2: Text = document.createTextNode('test2'); + + const paragraph = createParagraph(); + const segment = createBr({ + fontSize: '10pt', + }); + + paragraph.segments.push(segment); + + (br as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segment], + }; + + const result = domIndexer.reconcileChildList([text1, text2], [br]); + + expect(result).toBeFalse(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [segment], + }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts index 4187968b893..d784fdd1435 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts @@ -27,6 +27,7 @@ describe('TextMutationObserverImpl', () => { ); expect(onMutation).not.toHaveBeenCalled(); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('not text change', async () => { @@ -50,6 +51,7 @@ describe('TextMutationObserverImpl', () => { expect(onMutation).toHaveBeenCalledTimes(1); expect(onMutation).toHaveBeenCalledWith(false); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('text change', async () => { @@ -59,7 +61,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - const observer = textMutationObserver.createTextMutationObserver( + observer = textMutationObserver.createTextMutationObserver( div, domIndexer, onMutation, @@ -76,6 +78,7 @@ describe('TextMutationObserverImpl', () => { expect(onMutation).toHaveBeenCalledTimes(1); expect(onMutation).toHaveBeenCalledWith(true); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('text change in deeper node', async () => { @@ -87,7 +90,8 @@ describe('TextMutationObserverImpl', () => { div.appendChild(span); const onMutation = jasmine.createSpy('onMutation'); - const observer = textMutationObserver.createTextMutationObserver( + + observer = textMutationObserver.createTextMutationObserver( div, domIndexer, onMutation, @@ -104,6 +108,7 @@ describe('TextMutationObserverImpl', () => { expect(onMutation).toHaveBeenCalledTimes(1); expect(onMutation).toHaveBeenCalledWith(true); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('text and non-text change', async () => { @@ -113,7 +118,8 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - const observer = textMutationObserver.createTextMutationObserver( + + observer = textMutationObserver.createTextMutationObserver( div, domIndexer, onMutation, @@ -131,8 +137,7 @@ describe('TextMutationObserverImpl', () => { expect(onMutation).toHaveBeenCalledTimes(1); expect(onMutation).toHaveBeenCalledWith(false); - - observer.stopObserving(); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('flush mutation', async () => { @@ -142,7 +147,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - const observer = textMutationObserver.createTextMutationObserver( + observer = textMutationObserver.createTextMutationObserver( div, domIndexer, onMutation, @@ -159,6 +164,7 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).toHaveBeenCalledWith(true); + expect(onSkipMutation).not.toHaveBeenCalled(); }); it('flush mutation without change', async () => { @@ -168,7 +174,62 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - const observer = textMutationObserver.createTextMutationObserver( + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).not.toHaveBeenCalled(); + expect(onSkipMutation).not.toHaveBeenCalled(); + }); + + it('flush mutation with a new model', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + + text.nodeValue = '1'; + + const newModel = 'MODEL' as any; + observer.flushMutations(newModel); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).not.toHaveBeenCalled(); + expect(onSkipMutation).toHaveBeenCalledWith(newModel); + }); + + it('flush mutation when type in new line - 1', async () => { + const div = document.createElement('div'); + const br = document.createElement('br'); + const text = document.createTextNode('test'); + + div.appendChild(br); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( div, domIndexer, onMutation, @@ -176,12 +237,159 @@ describe('TextMutationObserverImpl', () => { ); observer.startObserving(); + + div.replaceChild(text, br); + + const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); + observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); + expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); expect(onMutation).not.toHaveBeenCalled(); + expect(onSkipMutation).not.toHaveBeenCalled(); + }); + + it('flush mutation when type in new line - 2', async () => { + const div = document.createElement('div'); + const br = document.createElement('br'); + const text = document.createTextNode(''); + + div.appendChild(br); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + + div.insertBefore(text, br); + div.removeChild(br); + text.nodeValue = 'test'; + + const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); + + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); + expect(onMutation).toHaveBeenCalledWith(true); + expect(onSkipMutation).not.toHaveBeenCalled(); + }); + + it('flush mutation when type in new line, fail to reconcile', async () => { + const div = document.createElement('div'); + const br = document.createElement('br'); + const text = document.createTextNode('test'); + + div.appendChild(br); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + + div.replaceChild(text, br); + + const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( + false + ); + + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); + expect(onMutation).toHaveBeenCalledWith(false); + expect(onSkipMutation).not.toHaveBeenCalled(); + }); + + it('mutation happens in different root', async () => { + const div = document.createElement('div'); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const br = document.createElement('br'); + const text = document.createTextNode('test'); + + div1.appendChild(br); + div.appendChild(div1); + div.appendChild(div2); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + + div1.removeChild(br); + div2.appendChild(text); + + const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( + false + ); + + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(reconcileChildListSpy).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith(false); + expect(onSkipMutation).not.toHaveBeenCalled(); + }); + + it('attribute change', async () => { + const div = document.createElement('div'); + const div1 = document.createElement('div'); + + div.appendChild(div1); + + const onMutation = jasmine.createSpy('onMutation'); + observer = textMutationObserver.createTextMutationObserver( + div, + domIndexer, + onMutation, + onSkipMutation + ); + + observer.startObserving(); + + div1.id = 'div1'; + + const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( + false + ); + + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(reconcileChildListSpy).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith(false); + expect(onSkipMutation).not.toHaveBeenCalled(); }); });