diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 9010a248c90..292f44d08e5 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -60,3 +60,4 @@ export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; +export { queryContentModelBlocks } from './modelApi/common/queryContentModelBlocks'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts new file mode 100644 index 00000000000..3b098bfc4ee --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -0,0 +1,77 @@ +import type { + ContentModelBlockType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockBase, + ReadonlyContentModelBlockGroup, +} from 'roosterjs-content-model-types'; + +/** + * Query content model blocks + * @param group The block group to query + * @param type The type of block to query + * @param filter Optional selector to filter the blocks + * @param findFirstOnly True to return the first block only, false to return all blocks + */ +export function queryContentModelBlocks( + group: ReadonlyContentModelBlockGroup, + type: T extends ReadonlyContentModelBlockBase ? U : never, + filter?: (element: T) => element is T, + findFirstOnly?: boolean +): T[] { + const elements: T[] = []; + for (let i = 0; i < group.blocks.length; i++) { + if (findFirstOnly && elements.length > 0) { + return elements; + } + const block = group.blocks[i]; + + switch (block.blockType) { + case 'Paragraph': + case 'Divider': + case 'Entity': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + break; + case 'BlockGroup': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + const results = queryContentModelBlocks(block, type, filter, findFirstOnly); + elements.push(...results); + break; + case 'Table': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + for (const row of block.rows) { + for (const cell of row.cells) { + const results = queryContentModelBlocks( + cell, + type, + filter, + findFirstOnly + ); + elements.push(...results); + } + } + break; + } + } + return elements; +} + +function isExpectedBlockType( + block: ReadonlyContentModelBlock, + type: ContentModelBlockType, + filter?: (element: T) => element is T +): block is T { + return isBlockType(block, type) && (!filter || filter(block)); +} + +function isBlockType( + block: ReadonlyContentModelBlock, + type: ContentModelBlockType +): block is T { + return block.blockType == type; +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts index cca5bceb881..b0ca265b4ed 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts @@ -48,6 +48,11 @@ export function formatTextSegmentBeforeSelectionMarker( if (previousSegment && previousSegment.segmentType === 'Text') { result = true; + + // Preserve pending format if any when format text segment, so if there is pending format (e.g. from paste) + // and some auto action happens after paste, the pending format will still take effect + context.newPendingFormat = 'preserve'; + rewrite = callback( model, previousSegment, diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts new file mode 100644 index 00000000000..64a76d37dde --- /dev/null +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts @@ -0,0 +1,1469 @@ +import { queryContentModelBlocks } from '../../../lib/modelApi/common/queryContentModelBlocks'; +import { + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, + ReadonlyContentModelParagraph, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +describe('queryContentModelBlocksBlocks', () => { + it('should return empty array if no blocks', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [], + }; + + // Act + const result = queryContentModelBlocks(group, 'Paragraph'); + + // Assert + expect(result).toEqual([]); + }); + + it('should return empty array if no blocks match the type', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: {}, + }, + ], + }; + + // Act + const result = queryContentModelBlocks(group, 'Table'); + + // Assert + expect(result).toEqual([]); + }); + + it('should return blocks that match the type', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const expected: ReadonlyContentModelTable[] = [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + ]; + + // Act + const result = queryContentModelBlocks(group, 'Table'); + + // Assert + expect(result).toEqual(expected); + }); + + it('should return blocks that match the type and selector', () => { + const paragraph: ReadonlyContentModelParagraph = { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }; + + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + paragraph, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + // Act + const result = queryContentModelBlocks( + group, + 'Paragraph', + (block): block is ReadonlyContentModelParagraph => block.segments.length == 2 + ); + + // Assert + expect(result).toEqual([paragraph]); + }); + + it('should return all tables that match the type and selector', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + isImplicit: false, + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'not table', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + verticalAlign: 'top', + }, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + + const expected: ReadonlyContentModelTable[] = [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + verticalAlign: 'top', + }, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + ]; + const result = queryContentModelBlocks(model, 'Table'); + expect(result).toEqual(expected); + }); + + it('should return all tables in list', () => { + const table: ReadonlyContentModelTable = { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }; + + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + listStyleType: 'disc', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + table, + ], + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const expected: ReadonlyContentModelTable[] = [table]; + const result = queryContentModelBlocks(model, 'Table'); + expect(result).toEqual(expected); + }); + + it('should return all lists', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + listStyleType: 'disc', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'table', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const listExpected: ReadonlyContentModelListItem[] = [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + listStyleType: 'disc', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'table', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ]; + + const result = queryContentModelBlocks( + model, + 'BlockGroup', + (block): block is ReadonlyContentModelListItem => block.blockGroupType == 'ListItem' + ); + expect(result).toEqual(listExpected); + }); + + it('should return image from a word online table', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [], + rows: [ + { + height: 0, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + tagName: 'div', + blockType: 'BlockGroup', + format: { + textAlign: 'start', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, + blockGroupType: 'FormatContainer', + blocks: [ + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + ], + segmentFormat: { + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + }, + ], + format: { + textAlign: 'start', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + dataset: { + celllook: '0', + }, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + tagName: 'div', + blockType: 'BlockGroup', + format: { + textAlign: 'start', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, + blockGroupType: 'FormatContainer', + blocks: [ + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: { + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + }, + ], + format: { + textAlign: 'start', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + dataset: { + celllook: '0', + }, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + textAlign: 'start', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + width: '0px', + tableLayout: 'fixed', + borderCollapse: true, + }, + dataset: { + tablelook: '1696', + tablestyle: 'MsoTableGrid', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const imageAndParagraph: ReadonlyContentModelParagraph = { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: { + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }; + const result = queryContentModelBlocks( + model, + 'Paragraph', + (block: ReadonlyContentModelParagraph): block is ReadonlyContentModelParagraph => { + for (const segment of block.segments) { + if (segment.segmentType == 'Image' && segment.dataset.isEditing) { + return true; + } + } + return false; + }, + true /* findFirstOnly */ + ); + expect(result).toEqual([imageAndParagraph]); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts index 5c58f674525..7dd4b2f8c2e 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts @@ -23,13 +23,16 @@ describe('formatTextSegmentBeforeSelectionMarker', () => { const formatWithContentModelSpy = jasmine .createSpy('formatWithContentModel') .and.callFake((callback, options) => { - const result = callback(input, { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], canUndoByBackspace: true, - }); + }; + const result = callback(input, context); + expect(result).toBe(expectedResult); + expect(context.newPendingFormat).toBe(expectedResult ? 'preserve' : undefined); }); formatTextSegmentBeforeSelectionMarker( diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 35be053782e..480a7ad3a28 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -136,10 +136,19 @@ function handlePendingFormat( context.newPendingFormat == 'preserve' ? core.format.pendingFormat?.format : context.newPendingFormat; - - if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { + const pendingParagraphFormat = + context.newPendingParagraphFormat == 'preserve' + ? core.format.pendingFormat?.paragraphFormat + : context.newPendingParagraphFormat; + + if ( + (pendingFormat || pendingParagraphFormat) && + selection?.type == 'range' && + selection.range.collapsed + ) { core.format.pendingFormat = { - format: { ...pendingFormat }, + format: pendingFormat ? { ...pendingFormat } : undefined, + paragraphFormat: pendingParagraphFormat ? { ...pendingParagraphFormat } : undefined, insertPoint: { node: selection.range.startContainer, offset: selection.range.startOffset, @@ -148,20 +157,25 @@ function handlePendingFormat( } } -function getChangedEntities(context: FormatContentModelContext, rawEvent?: Event): ChangedEntity[] { - return context.newEntities - .map( - (entity): ChangedEntity => ({ - entity, - operation: 'newEntity', - rawEvent, - }) - ) - .concat( - context.deletedEntities.map(entry => ({ - entity: entry.entity, - operation: entry.operation, - rawEvent, - })) - ); +function getChangedEntities( + context: FormatContentModelContext, + rawEvent?: Event +): ChangedEntity[] | undefined { + return context.autoDetectChangedEntities + ? undefined + : context.newEntities + .map( + (entity): ChangedEntity => ({ + entity, + operation: 'newEntity', + rawEvent, + }) + ) + .concat( + context.deletedEntities.map(entry => ({ + entity: entry.entity, + operation: entry.operation, + rawEvent, + })) + ); } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index 19d3da8e411..0400bf8668e 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -12,8 +12,17 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; * @param core The editor core object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ -export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { +export const setContentModel: SetContentModel = ( + core, + model, + option, + onNodeCreated, + isInitializing +) => { const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); const modelToDomContext = option ? createModelToDomContext( @@ -29,6 +38,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea modelToDomContext.onNodeCreated = onNodeCreated; + core.onFixUpModel?.(model); + const selection = contentModelToDom( core.logicalRoot.ownerDocument, core.logicalRoot, @@ -49,5 +60,20 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea } } + if (isInitializing) { + // When initialize, we should not trigger event until all plugins are initialized, so put these node in lifecycle state temporarily + core.lifecycle.rewriteFromModel = modelToDomContext.rewriteFromModel; + } else { + // Otherwise, trigger RewriteFromModel event immediately + core.api.triggerEvent( + core, + { + eventType: 'rewriteFromModel', + ...modelToDomContext.rewriteFromModel, + }, + true /*broadcast*/ + ); + } + return selection; }; 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 e51c7f888c9..2d2b80723ab 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -592,14 +592,14 @@ export class DomIndexerImpl implements DomIndexer { const { previousSibling, nextSibling } = node; if ( - (segmentItem = getIndexedSegmentItem(previousSibling)) && + (segmentItem = getIndexedSegmentItem(getLastLeaf(previousSibling))) && (existingSegment = segmentItem.segments[segmentItem.segments.length - 1]) && (index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0 ) { // When we can find indexed segment before current one, use it as the insert index this.indexNode(segmentItem.paragraph, index + 1, node, existingSegment.format); } else if ( - (segmentItem = getIndexedSegmentItem(nextSibling)) && + (segmentItem = getIndexedSegmentItem(getFirstLeaf(nextSibling))) && (existingSegment = segmentItem.segments[0]) && (index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0 ) { @@ -691,3 +691,19 @@ export class DomIndexerImpl implements DomIndexer { this.onSegment(textNode, paragraph, [text]); } } + +function getLastLeaf(node: Node | null): Node | null { + while (node?.lastChild) { + node = node.lastChild; + } + + return node; +} + +function getFirstLeaf(node: Node | null): Node | null { + while (node?.firstChild) { + node = node.firstChild; + } + + return node; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts index 872a4dd1d12..eb068b1a113 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -118,13 +118,16 @@ class FormatPlugin implements PluginWithState { break; case 'keyDown': - const isAndroidIME = this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; + const isAndroidIME = + this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; if (isCursorMovingKey(event.rawEvent)) { this.clearPendingFormat(); this.lastCheckedNode = null; } else if ( this.defaultFormatKeys.size > 0 && - (isAndroidIME || isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && + (isAndroidIME || + isCharacterValue(event.rawEvent) || + event.rawEvent.key == ProcessKey) && this.shouldApplyDefaultFormat(this.editor) ) { applyDefaultFormat(this.editor, this.state.defaultFormat); @@ -145,7 +148,12 @@ class FormatPlugin implements PluginWithState { private checkAndApplyPendingFormat(data: string | null) { if (this.editor && data && this.state.pendingFormat) { - applyPendingFormat(this.editor, data, this.state.pendingFormat.format); + applyPendingFormat( + this.editor, + data, + this.state.pendingFormat.format, + this.state.pendingFormat.paragraphFormat + ); this.clearPendingFormat(); } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts index d5086b2b99c..ca8ec9309fc 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts @@ -1,11 +1,16 @@ import { createText, iterateSelections, + mutateBlock, mutateSegment, normalizeContentModel, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockFormat, + ContentModelSegmentFormat, + IEditor, +} from 'roosterjs-content-model-types'; const ANSI_SPACE = '\u0020'; const NON_BREAK_SPACE = '\u00A0'; @@ -19,7 +24,8 @@ const NON_BREAK_SPACE = '\u00A0'; export function applyPendingFormat( editor: IEditor, data: string, - format: ContentModelSegmentFormat + segmentFormat?: ContentModelSegmentFormat, + paragraphFormat?: ContentModelBlockFormat ) { let isChanged = false; @@ -41,24 +47,35 @@ export function applyPendingFormat( // For space, there can be (space) or   ( ), we treat them as the same if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { - mutateSegment(block, previousSegment, previousSegment => { - previousSegment.text = text.substring(0, text.length - data.length); - }); + if (segmentFormat) { + mutateSegment(block, previousSegment, previousSegment => { + previousSegment.text = text.substring( + 0, + text.length - data.length + ); + }); - mutateSegment(block, marker, (marker, block) => { - marker.format = { ...format }; + mutateSegment(block, marker, (marker, block) => { + marker.format = { ...segmentFormat }; - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...segmentFormat, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - }); + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + }); + } + + if (paragraphFormat) { + const mutableParagraph = mutateBlock(block); + + Object.assign(mutableParagraph.format, paragraphFormat); + } isChanged = true; } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index 1e3e72b2aff..c038e5776e7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -6,6 +6,7 @@ import type { PluginEvent, PluginWithState, EditorOptions, + RewriteFromModel, } from 'roosterjs-content-model-types'; const ContentEditableAttributeName = 'contenteditable'; @@ -74,7 +75,13 @@ class LifecyclePlugin implements PluginWithState { this.adjustColor(); // Let other plugins know that we are ready - this.editor.triggerEvent('editorReady', {}, true /*broadcast*/); + const rewriteFromModel: RewriteFromModel = this.state.rewriteFromModel ?? { + addedBlockElements: [], + removedBlockElements: [], + }; + + this.editor.triggerEvent('editorReady', rewriteFromModel, true /*broadcast*/); + delete this.state.rewriteFromModel; // Initialize the Announce container. this.state.announceContainer = createAriaLiveElement(editor.getDocument()); diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index c01aee381c7..6977040e9fa 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -50,7 +50,13 @@ export class Editor implements IEditor { const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); - this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); + this.core.api.setContentModel( + this.core, + initialModel, + { ignoreSelection: true }, + undefined /*onNodeCreated*/, + true /*isInitializing*/ + ); this.core.plugins.forEach(plugin => plugin.initialize(this)); } diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 7dbea590e45..886b03dc7ee 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -47,6 +47,7 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, + onFixUpModel: options.onFixUpModel, experimentalFeatures: options.experimentalFeatures ? [...options.experimentalFeatures] : [], }; } diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 5fdae3d372e..6583eff98a6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -658,6 +658,7 @@ describe('formatContentModel', () => { it('Has pending format, callback returns true, preserve pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -671,6 +672,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -678,9 +680,35 @@ describe('formatContentModel', () => { } as any); }); - it('Has pending format, callback returns false, preserve pending format', () => { + it('Has pending format, callback returns true, preserve paragraph pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns true, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -689,11 +717,39 @@ describe('formatContentModel', () => { formatContentModel(core, (model, context) => { context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns false, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; return false; }); expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -709,6 +765,23 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + + it('No pending format, callback returns true, new paragraph format', () => { + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -724,6 +797,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -747,6 +821,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -770,6 +845,31 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + + it('Has pending format, callback returns false, new paragraph format', () => { + core.format.pendingFormat = { + paragraphFormat: mockedFormat1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -970,4 +1070,138 @@ describe('formatContentModel', () => { expect(announce).toHaveBeenCalledWith(core, mockedData); }); }); + + describe('Changed entities', () => { + it('Callback return true, changed entities are not specified', () => { + const callback = jasmine.createSpy('callback').and.returnValue(true); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: [], + }, + true + ); + }); + + it('Callback return true, changed entities are specified', () => { + const wrapper1 = document.createElement('span'); + const wrapper2 = document.createElement('div'); + const callback = jasmine + .createSpy('callback') + .and.callFake((model: any, context: FormatContentModelContext) => { + context.newEntities.push({ + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { + entityType: 'test', + }, + format: {}, + wrapper: wrapper1, + }); + context.deletedEntities.push({ + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { + entityType: 'test', + }, + format: {}, + wrapper: wrapper2, + }, + operation: 'overwrite', + }); + return true; + }); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: [ + { + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { entityType: 'test' }, + format: {}, + wrapper: wrapper1, + }, + operation: 'newEntity', + rawEvent: undefined, + }, + { + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { entityType: 'test' }, + format: {}, + wrapper: wrapper2, + }, + operation: 'overwrite', + rawEvent: undefined, + }, + ], + }, + true + ); + }); + + it('Callback return true, auto detect entity change', () => { + const callback = jasmine + .createSpy('callback') + .and.callFake((model: any, context: FormatContentModelContext) => { + context.autoDetectChangedEntities = true; + return true; + }); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: undefined, + }, + true + ); + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 201e90ae96c..14b8dc1f2b9 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -1,13 +1,13 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; -import { EditorCore } from 'roosterjs-content-model-types'; +import { ContentModelDocument, EditorCore, ModelToDomContext } from 'roosterjs-content-model-types'; import { setContentModel } from '../../../lib/coreApi/setContentModel/setContentModel'; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; const mockedEditorContext = 'EDITORCONTEXT' as any; -const mockedContext = { name: 'CONTEXT' } as any; +const mockedContext = { name: 'CONTEXT', rewriteFromModel: {} } as any; const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; @@ -21,6 +21,7 @@ describe('setContentModel', () => { let getDOMSelectionSpy: jasmine.Spy; let flushMutationsSpy: jasmine.Spy; let updateCacheSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); @@ -38,6 +39,7 @@ describe('setContentModel', () => { setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); flushMutationsSpy = jasmine.createSpy('flushMutations'); + triggerEventSpy = jasmine.createSpy('triggerEvent'); core = { physicalRoot: mockedDiv, @@ -46,6 +48,7 @@ describe('setContentModel', () => { createEditorContext, setDOMSelection: setDOMSelectionSpy, getDOMSelection: getDOMSelectionSpy, + triggerEvent: triggerEventSpy, }, lifecycle: {}, cache: { @@ -114,10 +117,13 @@ describe('setContentModel', () => { const mockedRange = { type: 'image', } as any; + const mockedOnFixUpModel = jasmine.createSpy('fixupModel'); contentModelToDomSpy.and.returnValue(mockedRange); core.environment.modelToDomSettings.builtIn = defaultOption; + (core as any).onFixUpModel = mockedOnFixUpModel; + setContentModel(core, mockedModel, additionalOption); expect(createModelToDomContextSpy).toHaveBeenCalledWith( @@ -133,6 +139,8 @@ describe('setContentModel', () => { mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(mockedOnFixUpModel).toHaveBeenCalledWith(mockedModel); + expect(mockedOnFixUpModel).toHaveBeenCalledBefore(contentModelToDomSpy); }); it('no default option, with shadow edit', () => { @@ -271,4 +279,62 @@ describe('setContentModel', () => { expect(flushMutationsSpy).toHaveBeenCalledBefore(updateCacheSpy); expect(updateCacheSpy).toHaveBeenCalledBefore(setDOMSelectionSpy); }); + + it('Receive modified DOM elements, not in init', () => { + const mockedAddedNodes = 'ADD' as any; + const mockedRemovedNodes = 'REMOVE' as any; + + contentModelToDomSpy.and.callFake( + ( + doc: Document, + root: Node, + model: ContentModelDocument, + context: ModelToDomContext + ) => { + context.rewriteFromModel.addedBlockElements = mockedAddedNodes; + context.rewriteFromModel.removedBlockElements = mockedRemovedNodes; + return {} as any; + } + ); + + setContentModel(core, mockedModel); + + expect(triggerEventSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'rewriteFromModel', + addedBlockElements: mockedAddedNodes, + removedBlockElements: mockedRemovedNodes, + }, + true + ); + expect(core.lifecycle.rewriteFromModel).toBeUndefined(); + }); + + it('Receive modified DOM elements, in init', () => { + const mockedAddedNodes = 'ADD' as any; + const mockedRemovedNodes = 'REMOVE' as any; + + contentModelToDomSpy.and.callFake( + ( + doc: Document, + root: Node, + model: ContentModelDocument, + context: ModelToDomContext + ) => { + context.rewriteFromModel.addedBlockElements = mockedAddedNodes; + context.rewriteFromModel.removedBlockElements = mockedRemovedNodes; + return {} as any; + } + ); + + setContentModel(core, mockedModel, undefined, undefined, true); + + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.rewriteFromModel).toEqual({ + addedBlockElements: mockedAddedNodes, + removedBlockElements: mockedRemovedNodes, + }); + }); }); 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 478b8db5fde..4937f5205c8 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -17,6 +17,7 @@ import { DOMSelection, } from 'roosterjs-content-model-types'; import { + addLink, createBr, createContentModelDocument, createEntity, @@ -1301,6 +1302,77 @@ describe('domIndexerImpl.reconcileChildList', () => { segments: [segment], }); }); + + it('Added Text after link that contains image and text', () => { + const domIndexer = new DomIndexerImpl(true); + const a = document.createElement('a'); + const img = document.createElement('img'); + const text = document.createTextNode('test'); + const newText = document.createTextNode('a'); + const div = document.createElement('div'); + + a.appendChild(img); + a.appendChild(text); + div.appendChild(a); + div.appendChild(newText); + + const paragraph = createParagraph(); + const segmentImg = createImage('src'); + const segmentText = createText('test'); + + addLink(segmentImg, { + format: { href: 'test' }, + dataset: {}, + }); + addLink(segmentText, { + format: { href: 'test' }, + dataset: {}, + }); + + paragraph.segments.push(segmentImg, segmentText); + + ((img as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segmentImg], + }; + ((text as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segmentText], + }; + + const result = domIndexer.reconcileChildList([newText], []); + + expect(result).toBeTrue(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'src', + format: {}, + dataset: {}, + link: { format: { href: 'test' }, dataset: {} }, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + link: { format: { href: 'test' }, dataset: {} }, + }, + { segmentType: 'Text', text: 'a', format: {} }, + ], + format: {}, + }); + expect(((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.paragraph).toBe( + paragraph + ); + expect( + ((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.segments.length + ).toBe(1); + expect(((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.segments[0]).toBe( + paragraph.segments[2] + ); + }); }); describe('domIndexerImpl.reconcileElementId', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts index 2f79d523656..b3514156042 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts @@ -64,6 +64,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); @@ -85,6 +87,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); @@ -130,6 +134,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts index a240e605aa6..79c3379878e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -8,6 +8,9 @@ describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; + const mockedFormat2 = { + lineSpace: 2, + }; let applyPendingFormatSpy: jasmine.Spy; beforeEach(() => { @@ -49,6 +52,7 @@ describe('FormatPlugin', () => { (state.pendingFormat = { format: mockedFormat, + paragraphFormat: mockedFormat2, } as any), plugin.initialize(editor); @@ -60,7 +64,12 @@ describe('FormatPlugin', () => { plugin.dispose(); expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith( + editor, + 'a', + mockedFormat, + mockedFormat2 + ); expect(state.pendingFormat).toBeNull(); }); @@ -92,7 +101,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat, undefined); expect(state.pendingFormat).toBeNull(); }); @@ -111,7 +120,7 @@ describe('FormatPlugin', () => { const state = plugin.getState(); state.pendingFormat = { - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any; plugin.onPluginEvent({ @@ -122,7 +131,7 @@ describe('FormatPlugin', () => { expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts index 9b2ed118a8c..be044ad6e32 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts @@ -94,6 +94,225 @@ describe('applyPendingFormat', () => { }); }); + it('Has pending paragraph format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c', undefined, { + textIndent: '20pt', + lineHeight: '2', + }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has pending both format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat( + editor, + 'c', + { fontSize: '10px' }, + { + textIndent: '20pt', + lineHeight: '2', + } + ); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'ab', + format: {}, + }, + { + segmentType: 'Text', + text: 'c', + format: { + fontSize: '10px', + }, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has no pending format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: {}, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c'); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + it('Has pending format but wrong text', () => { const text: ContentModelText = { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts index e4b48f607ce..a1981b03077 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts @@ -42,6 +42,10 @@ describe('LifecyclePlugin', () => { expect(div.innerHTML).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -85,6 +89,55 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe('text'); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with rewriteFromModel', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); + + const state = plugin.getState(); + const mockedAddedElements = 'ADD' as any; + const mockedRemovedElements = 'REMOVE' as any; + + state.rewriteFromModel = { + addedBlockElements: mockedAddedElements, + removedBlockElements: mockedRemovedElements, + }; + + plugin.initialize(({ + triggerEvent, + getFocusedPosition: () => null, + getColorManager: () => null, + isDarkMode: () => false, + getDocument, + })); + + expect(state).toEqual({ + isDarkMode: false, + shadowEditFragment: null, + styleElements: {}, + announcerStringGetter: undefined, + announceContainer, + }); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(div.innerHTML).toBe(''); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: mockedAddedElements, + removedBlockElements: mockedRemovedElements, + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -109,6 +162,10 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeTrue(); @@ -133,6 +190,10 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts index 665df07084e..770b017e9aa 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts @@ -104,6 +104,8 @@ describe('UndoPlugin', () => { it('Not handled exclusively for EditorReady event', () => { const result = plugin.willHandleEventExclusively({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(result).toBeFalse(); @@ -285,6 +287,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); @@ -306,6 +310,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(0); @@ -327,6 +333,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index ace32194a31..87707aa74b0 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 } 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, @@ -90,7 +90,9 @@ describe('Editor', () => { expect(setContentModelSpy).toHaveBeenCalledWith( jasmine.anything() /*core*/, mockedInitialModel, - { ignoreSelection: true } + { ignoreSelection: true }, + undefined, + true ); expect(initSpy1).toHaveBeenCalledWith(editor); diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index b4af3645965..197c55c1306 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -101,6 +101,7 @@ describe('createEditorCore', () => { domHelper: mockedDOMHelper, disposeErrorHandler: undefined, experimentalFeatures: [], + onFixUpModel: undefined, ...additionalResult, }); @@ -149,6 +150,7 @@ describe('createEditorCore', () => { const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; + const mockedOnFixUpModel = 'FIXUP' as any; const mockedOptions = { coreApiOverride: { a: 'b', @@ -159,6 +161,7 @@ describe('createEditorCore', () => { disposeErrorHandler: mockedDisposeErrorHandler, generateColorKey: mockedGenerateColorKey, knownColors: mockedKnownColors, + onFixUpModel: mockedOnFixUpModel, } as any; runTest(mockedDiv, mockedOptions, { @@ -181,6 +184,7 @@ describe('createEditorCore', () => { darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, + onFixUpModel: mockedOnFixUpModel, }); expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts b/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts index 471c94ece4a..675661cf8aa 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts @@ -1,15 +1,22 @@ import { isEntityElement } from './entityUtils'; +import { isNodeOfType } from './isNodeOfType'; +import type { RewriteFromModel } from 'roosterjs-content-model-types'; /** * When set a DOM tree into editor, reuse the existing element in editor and no need to change it - * @param param Parent node of the reused element + * @param parent Parent node of the reused element * @param element The element to keep in parent node * @param refNode Reference node, it is point to current node that is being processed. It must be a child of parent node, or null. * We will start processing from this node, if it is not the same with element, remove it and keep processing its next sibling, * until we see an element that is the same with the passed in element or null. * @returns The new reference element */ -export function reuseCachedElement(parent: Node, element: Node, refNode: Node | null): Node | null { +export function reuseCachedElement( + parent: Node, + element: Node, + refNode: Node | null, + context?: RewriteFromModel +): Node | null { if (element.parentNode == parent) { const isEntity = isEntityElement(element); @@ -20,6 +27,11 @@ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | const next = refNode.nextSibling; refNode.parentNode?.removeChild(refNode); + + if (isNodeOfType(refNode, 'ELEMENT_NODE')) { + context?.removedBlockElements.push(refNode); + } + refNode = next; } @@ -34,13 +46,3 @@ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | return refNode; } - -/** - * @internal - */ -export function removeNode(node: Node): Node | null { - const next = node.nextSibling; - node.parentNode?.removeChild(node); - - return next; -} diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts index 1a059cc885e..e71f2a31455 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts @@ -5,6 +5,7 @@ import { defaultFormatKeysPerCategory, } from '../../formatHandlers/defaultFormatHandlers'; import type { + RewriteFromModelContext, EditorContext, FormatApplier, FormatAppliers, @@ -37,12 +38,13 @@ export function createModelToDomContext( export function createModelToDomContextWithConfig( config: ModelToDomSettings, editorContext?: EditorContext -) { +): ModelToDomContext { return Object.assign( {}, editorContext, createModelToDomSelectionContext(), createModelToDomFormatContext(), + createRewriteFromModelContext(), config ); } @@ -68,6 +70,15 @@ function createModelToDomFormatContext(): ModelToDomFormatContext { }; } +function createRewriteFromModelContext(): RewriteFromModelContext { + return { + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, + }; +} + /** * Create Content Model to DOM Config object * @param options All customizations of DOM creation diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts index 8c749f5a039..9b1350668ac 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts @@ -1,3 +1,4 @@ +import { isNodeOfType } from '../../domUtils/isNodeOfType'; import type { ContentModelBlockGroup, ContentModelHandler, @@ -42,6 +43,10 @@ export const handleBlockGroupChildren: ContentModelHandler = ( let element = context.allowCacheElement ? divider.cachedElement : undefined; if (element && !divider.isSelected) { - refNode = reuseCachedElement(parent, element, refNode); + refNode = reuseCachedElement(parent, element, refNode, context.rewriteFromModel); } else { element = doc.createElement(divider.tagName); @@ -28,6 +28,7 @@ export const handleDivider: ContentModelBlockHandler = ( } parent.insertBefore(element, refNode); + context.rewriteFromModel.addedBlockElements.push(element); applyFormat(element, context.formatAppliers.divider, divider.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 3032b63e791..0ecbfcabc68 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -34,7 +34,7 @@ export const handleEntityBlock: ContentModelBlockHandler = ( const isContained = wrapper.parentElement?.classList.contains(BlockEntityContainer); const elementToReuse = isContained && isCursorAroundEntity ? wrapper.parentElement! : wrapper; - refNode = reuseCachedElement(parent, elementToReuse, refNode); + refNode = reuseCachedElement(parent, elementToReuse, refNode, context.rewriteFromModel); if (isCursorAroundEntity) { if (!isContained) { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index 700b6ef3ce9..da761c33f25 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -28,7 +28,7 @@ export const handleFormatContainer: ContentModelBlockHandler { applyFormat(containerNode, context.formatAppliers.container, container.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts index 42dae971d96..ed10002d52e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts @@ -23,7 +23,7 @@ export const handleGeneralBlock: ContentModelBlockHandler = ( // It is possible listParent is the same with parent param. // This happens when outdent a list item to cause it has no list level listParent.insertBefore(li, refNode?.parentNode == listParent ? refNode : null); + context.rewriteFromModel.addedBlockElements.push(li); if (level) { applyFormat(li, context.formatAppliers.segment, listItem.formatHolder.format, context); 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 73054976b03..ef6732c9dde 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -25,7 +25,7 @@ export const handleParagraph: ContentModelBlockHandler = let container = context.allowCacheElement ? paragraph.cachedElement : undefined; if (container && paragraph.segments.every(x => x.segmentType != 'General' && !x.isSelected)) { - refNode = reuseCachedElement(parent, container, refNode); + refNode = reuseCachedElement(parent, container, refNode, context.rewriteFromModel); } else { stackFormat(context, paragraph.decorator?.tagName || null, () => { const needParagraphWrapper = @@ -117,6 +117,8 @@ export const handleParagraph: ContentModelBlockHandler = if (context.allowCacheElement) { paragraph.cachedElement = container; } + + context.rewriteFromModel.addedBlockElements.push(container); } else { unwrap(container); container = undefined; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts index d2dcc7706aa..1a2f2da82da 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts @@ -14,8 +14,7 @@ export const handleSegmentDecorator: ContentModelSegmentHandler { const { code, link } = segment; @@ -27,7 +26,6 @@ export const handleSegmentDecorator: ContentModelSegmentHandler = ( let tableNode = context.allowCacheElement ? table.cachedElement : undefined; if (tableNode) { - refNode = reuseCachedElement(parent, tableNode, refNode); + refNode = reuseCachedElement(parent, tableNode, refNode, context.rewriteFromModel); moveChildNodes(tableNode); } else { @@ -39,6 +39,7 @@ export const handleTable: ContentModelBlockHandler = ( } parent.insertBefore(tableNode, refNode); + context.rewriteFromModel.addedBlockElements.push(tableNode); applyFormat(tableNode, context.formatAppliers.block, table.format, context); applyFormat(tableNode, context.formatAppliers.table, table.format, context); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts index 49d145a3f9c..7a567dc7b6a 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts @@ -6,6 +6,8 @@ describe('cacheGetEventData', () => { it('get cached data', () => { const event: EditorReadyEvent = { eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }; const mockedData = 'DATA'; @@ -21,6 +23,8 @@ describe('cacheGetEventData', () => { eventDataCache: { [cacheKey]: mockedData, }, + addedBlockElements: [], + removedBlockElements: [], }); const data2 = cacheGetEventData(event, cacheKey, mockedGetter); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts index 1f612a8c6cb..3e4ac5916b0 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts @@ -1,16 +1,25 @@ import { reuseCachedElement } from '../../lib/domUtils/reuseCachedElement'; import { setEntityElementClasses } from './entityUtilTest'; +import type { RewriteFromModel } from 'roosterjs-content-model-types'; describe('reuseCachedElement', () => { it('No refNode', () => { const parent = document.createElement('div'); const element = document.createElement('span'); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; - const result = reuseCachedElement(parent, element, null); + const result = reuseCachedElement(parent, element, null, context); expect(parent.outerHTML).toBe('
'); expect(parent.firstChild).toBe(element); expect(result).toBe(null); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is not current element', () => { @@ -20,11 +29,20 @@ describe('reuseCachedElement', () => { parent.appendChild(refNode); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(result).toBe(refNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is current element', () => { @@ -35,30 +53,49 @@ describe('reuseCachedElement', () => { parent.appendChild(element); parent.appendChild(nextNode); - const result = reuseCachedElement(parent, element, element); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, element, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is before current element', () => { const parent = document.createElement('div'); - const refNode = document.createElement('hr'); + const hr = document.createElement('hr'); const element = document.createElement('span'); const nextNode = document.createElement('br'); + const refNode = hr; parent.appendChild(refNode); parent.appendChild(element); parent.appendChild(nextNode); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [hr], + }); }); it('RefNode is entity', () => { @@ -74,7 +111,12 @@ describe('reuseCachedElement', () => { setEntityElementClasses(refNode, 'TestEntity', true); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(removeChildSpy).not.toHaveBeenCalled(); expect(parent.outerHTML).toBe( @@ -83,14 +125,19 @@ describe('reuseCachedElement', () => { expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(refNode); expect(result).toBe(refNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is entity, current element is entity', () => { const parent = document.createElement('div'); - const refNode = document.createElement('div'); + const entity = document.createElement('div'); const element = document.createElement('span'); const nextNode = document.createElement('br'); const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); + const refNode = entity; parent.appendChild(refNode); parent.appendChild(element); @@ -99,7 +146,12 @@ describe('reuseCachedElement', () => { setEntityElementClasses(refNode, 'TestEntity', true); setEntityElementClasses(element, 'TestEntity2', true); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(removeChildSpy).toHaveBeenCalledTimes(1); expect(removeChildSpy).toHaveBeenCalledWith(refNode); @@ -110,5 +162,9 @@ describe('reuseCachedElement', () => { expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [entity], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts index 1e1c8c0f9ee..6d5dff298c7 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts @@ -27,6 +27,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); @@ -55,6 +59,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); @@ -114,6 +122,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts index 2a5673a1af6..6da28672a57 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts @@ -42,6 +42,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
'); expect(context.listFormat.nodeStack).toEqual([]); expect(handleBlock).not.toHaveBeenCalled(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Single child block group', () => { @@ -56,6 +60,10 @@ describe('handleBlockGroupChildren', () => { expect(context.listFormat.nodeStack).toEqual([]); expect(handleBlock).toHaveBeenCalledTimes(1); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Multiple child block group', () => { @@ -73,6 +81,10 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledTimes(2); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph1, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph2, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Multiple child block group with nodeStack and no list', () => { @@ -95,6 +107,10 @@ describe('handleBlockGroupChildren', () => { expect(context.listFormat.nodeStack).toBe(nodeStack); expect(handleBlock).toHaveBeenCalledTimes(1); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Multiple child block group with nodeStack and no list', () => { @@ -133,6 +149,10 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph1, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph2, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, list, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('handle document with cache 1', () => { @@ -168,6 +188,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test1
test2
'); expect(parent.firstChild).toBe(div1); expect(parent.lastChild).toBe(div2); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('handle document with cache 2', () => { @@ -203,6 +227,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test2
test1
'); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(div1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [div1], + }); }); it('handle document with cache 3', () => { @@ -239,6 +267,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test2
test1
'); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(div1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [div1], + }); }); it('handle document with cache 4', () => { @@ -284,6 +316,10 @@ describe('handleBlockGroupChildren', () => { ); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(quote); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [quote], + }); }); it('handle document with cache 5', () => { @@ -327,6 +363,10 @@ describe('handleBlockGroupChildren', () => { '

' ); expect(parent.firstChild).toBe(quote); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild!.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Inline entity is next to a cached paragraph', () => { @@ -379,6 +419,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.innerHTML).toBe( '

' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('child contains entity', () => { @@ -407,5 +451,9 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledWith(document, parent, entity, context, null); expect(onBlockEntity).toHaveBeenCalledTimes(1); expect(onBlockEntity).toHaveBeenCalledWith(entity, group); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts index abddc4827ca..6d2253e05c3 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts @@ -78,8 +78,7 @@ describe('handleSegment', () => { handleBr(document, parent, br, context, newSegments); expect(parent.innerHTML).toBe('
'); - expect(newSegments.length).toBe(2); + expect(newSegments.length).toBe(1); expect((newSegments[0] as HTMLElement).outerHTML).toBe('
'); - expect((newSegments[1] as HTMLElement).outerHTML).toBe('
'); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts index a9520509234..4d401d7e079 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts @@ -23,6 +23,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with format', () => { @@ -38,6 +42,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('DIV with format', () => { @@ -53,6 +61,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with width, size and display', () => { @@ -77,6 +89,10 @@ describe('handleDivider', () => { ].indexOf(parent.innerHTML) >= 0 ).toBeTrue(); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with border and padding', () => { @@ -97,6 +113,10 @@ describe('handleDivider', () => { '
' ); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with refNode', () => { @@ -116,6 +136,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('

'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with refNode, already in target node', () => { @@ -139,6 +163,10 @@ describe('handleDivider', () => { expect(hr.cachedElement).toBe(hrNode); expect(parent.firstChild).toBe(hrNode); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -157,5 +185,9 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(hr); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('hr')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index f4c8c748d43..d03a267af13 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -36,6 +36,10 @@ describe('handleFormatContainer', () => { expect(parent.outerHTML).toBe('
'); expect(quote.cachedElement).toBeUndefined(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Quote with child', () => { @@ -61,6 +65,13 @@ describe('handleFormatContainer', () => { context ); expect(quote.cachedElement).toBe(parent.firstChild as HTMLQuoteElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); it('Quote with child and refNode', () => { @@ -90,6 +101,13 @@ describe('handleFormatContainer', () => { ); expect(quote.cachedElement).toBe(parent.firstChild as HTMLQuoteElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -114,5 +132,12 @@ describe('handleFormatContainer', () => { expect(onNodeCreated).toHaveBeenCalledTimes(3); expect(onNodeCreated.calls.argsFor(2)[0]).toBe(quote); expect(onNodeCreated.calls.argsFor(2)[1]).toBe(parent.querySelector('blockquote')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts index dd4f1b3b26f..b8b712bebef 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts @@ -61,6 +61,10 @@ describe('handleBlockGroup', () => { context ); expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [clonedChild], + removedBlockElements: [], + }); }); it('General block with color', () => { @@ -75,6 +79,10 @@ describe('handleBlockGroup', () => { expect(parent.outerHTML).toBe( '
' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('General segment: empty element', () => { @@ -230,6 +238,10 @@ describe('handleBlockGroup', () => { expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); expect(result).toBe(br); expect(group.element).toBe(clonedChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [clonedChild], + removedBlockElements: [], + }); }); it('General block with refNode, already in target node', () => { @@ -249,6 +261,10 @@ describe('handleBlockGroup', () => { expect(handleBlockGroupChildren).toHaveBeenCalledWith(document, node, group, context); expect(result).toBe(br); expect(group.element).toBe(node); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts index 0c2b0ac6e3c..114c55af239 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts @@ -77,6 +77,10 @@ describe('handleListItem without format handler', () => { context ); expect(paragraph.isImplicit).toBeFalse(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('OL parent', () => { @@ -140,6 +144,10 @@ describe('handleListItem without format handler', () => { listItem, context ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL parent', () => { @@ -203,6 +211,10 @@ describe('handleListItem without format handler', () => { listItem, context ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL with refNode', () => { @@ -226,6 +238,10 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL with same format on list and segment', () => { @@ -263,6 +279,15 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(null); + + const li = parent.firstChild!.firstChild as HTMLElement; + + expect(li.tagName).toBe('LI'); + + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [li, li.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('UL with different format on list and segment', () => { @@ -300,6 +325,15 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(null); + + const li = parent.firstChild!.firstChild as HTMLElement; + + expect(li.tagName).toBe('LI'); + + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [li, li.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -334,5 +368,9 @@ describe('handleListItem without format handler', () => { expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('ol')); expect(onNodeCreated.calls.argsFor(1)[0]).toBe(listItem); expect(onNodeCreated.calls.argsFor(1)[1]).toBe(parent.querySelector('li')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); }); 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 32daefdcfd1..8a0c2ca2d5e 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -1,3 +1,4 @@ +import * as reuseCachedElement from '../../../lib/domUtils/reuseCachedElement'; import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; import * as unwrap from '../../../lib/domUtils/unwrap'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; @@ -56,6 +57,10 @@ describe('handleParagraph', () => { '
', 0 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle empty implicit paragraph', () => { @@ -69,6 +74,10 @@ describe('handleParagraph', () => { '', 0 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Handle paragraph with single text segment', () => { @@ -94,6 +103,10 @@ describe('handleParagraph', () => { context, [] ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle implicit paragraph single text segment', () => { @@ -114,6 +127,10 @@ describe('handleParagraph', () => { ); expect(handleSegment).toHaveBeenCalledWith(document, parent, segment, context, []); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Handle multiple segments', () => { @@ -154,6 +171,10 @@ describe('handleParagraph', () => { context, [] ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle p without margin', () => { @@ -178,6 +199,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle p with margin', () => { @@ -202,6 +227,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers', () => { @@ -226,6 +255,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers with default format override', () => { @@ -250,6 +283,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers without default format', () => { @@ -274,6 +311,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers that has non-bold text', () => { @@ -305,6 +346,10 @@ describe('handleParagraph', () => { '

test 1test 2

', 2 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers with implicit block and other inline format', () => { @@ -330,6 +375,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle implicit paragraph with segments and format', () => { @@ -353,6 +402,10 @@ describe('handleParagraph', () => { '
test
', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('call stackFormat', () => { @@ -382,6 +435,10 @@ describe('handleParagraph', () => { expect(stackFormat.stackFormat).toHaveBeenCalledTimes(2); expect((stackFormat.stackFormat).calls.argsFor(0)[1]).toBe('h1'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle paragraph with refNode', () => { @@ -404,6 +461,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe('

'); expect(paragraph.cachedElement).toBe(parent.firstChild as HTMLElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle paragraph with PRE', () => { @@ -440,6 +501,13 @@ describe('handleParagraph', () => { expect(para1.cachedElement?.outerHTML).toBe('
test1
'); expect(para2.cachedElement).toBe(parent.firstChild?.nextSibling as HTMLElement); expect(para2.cachedElement?.outerHTML).toBe('
test2
'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.nextSibling as HTMLElement, + ], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -465,6 +533,10 @@ describe('handleParagraph', () => { expect(onNodeCreated).toHaveBeenCalledTimes(1); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(paragraph); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('div')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('With onNodeCreated on implicit paragraph', () => { @@ -489,6 +561,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe(''); expect(onNodeCreated).toHaveBeenCalled(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Paragraph with only selection marker and BR', () => { @@ -531,6 +607,10 @@ describe('handleParagraph', () => { start: { block: div, segment: txt }, end: { block: div, segment: txt }, }); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Paragraph with inline format', () => { @@ -557,6 +637,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe( '
test
' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Paragraph with domIndexer', () => { @@ -603,6 +687,10 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledWith(parent.firstChild!.lastChild, paragraph, [ segment2, ]); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Implicit paragraph with domIndexer', () => { @@ -650,6 +738,10 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledTimes(2); expect(onSegmentSpy).toHaveBeenCalledWith(parent.firstChild, paragraph, [segment1]); expect(onSegmentSpy).toHaveBeenCalledWith(parent.lastChild, paragraph, [segment2]); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); }); @@ -686,6 +778,10 @@ describe('Handle paragraph and adjust selections', () => { segment: parent.firstChild!.firstChild, }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at beginning, followed by Text', () => { @@ -724,6 +820,10 @@ describe('Handle paragraph and adjust selections', () => { }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text', () => { @@ -767,6 +867,10 @@ describe('Handle paragraph and adjust selections', () => { }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at end of text', () => { @@ -813,6 +917,10 @@ describe('Handle paragraph and adjust selections', () => { 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); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text, expanded', () => { @@ -858,6 +966,10 @@ describe('Handle paragraph and adjust selections', () => { 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); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in front of text, expanded', () => { @@ -897,6 +1009,10 @@ describe('Handle paragraph and adjust selections', () => { 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); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at the end of text, expanded', () => { @@ -936,6 +1052,10 @@ describe('Handle paragraph and adjust selections', () => { 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); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text and BR, expanded', () => { @@ -991,5 +1111,39 @@ describe('Handle paragraph and adjust selections', () => { 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); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); + }); + + it('Has cache', () => { + const div = document.createElement('div'); + const parent = document.createElement('div'); + const paraModel = createParagraph(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + const context = createModelToDomContext(); + + div.id = 'div1'; + paraModel.cachedElement = div; + context.allowCacheElement = true; + + handleParagraph(document, parent, paraModel, context, null); + + expect(parent.innerHTML).toBe('
'); + expect(parent.firstChild).toBe(div); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).toHaveBeenCalledWith( + parent, + div, + null, + context.rewriteFromModel + ); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 022733f2116..748e1d4b39b 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,5 +1,4 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from '../../testUtils'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; import { ContentModelCode, @@ -20,8 +19,7 @@ describe('handleSegmentDecorator', () => { function runTest( link: ContentModelLink | undefined, code: ContentModelCode | undefined, - expectedInnerHTML: string, - expectedSegmentNodesHTML: (string | string[])[] + expectedInnerHTML: string ) { parent = document.createElement('span'); parent.textContent = 'test'; @@ -37,10 +35,7 @@ describe('handleSegmentDecorator', () => { handleSegmentDecorator(document, parent, segment, context, segmentNodes); expect(parent.innerHTML).toBe(expectedInnerHTML); - expect(segmentNodes.length).toBe(expectedSegmentNodesHTML.length); - expectedSegmentNodesHTML.forEach((expectedHTML, i) => { - expectHtml((segmentNodes[i] as HTMLElement).outerHTML, expectedHTML); - }); + expect(segmentNodes.length).toBe(0); } it('simple link', () => { @@ -52,9 +47,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('link with color', () => { @@ -67,9 +60,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('link without underline', () => { @@ -84,8 +75,7 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test', - ['test'] + 'test' ); }); @@ -101,9 +91,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('simple code', () => { @@ -113,7 +101,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', ['test']); + runTest(undefined, code, 'test'); }); it('code with font', () => { @@ -123,9 +111,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', [ - 'test', - ]); + runTest(undefined, code, 'test'); }); it('link and code', () => { @@ -142,10 +128,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, code, 'test', [ - 'test', - 'test', - ]); + runTest(link, code, 'test'); }); it('Link with onNodeCreated', () => { @@ -194,12 +177,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest( - link, - undefined, - 'test', - ['test'] - ); + runTest(link, undefined, 'test'); }); it('code with display: block', () => { @@ -210,9 +188,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', [ - 'test', - ]); + runTest(undefined, code, 'test'); }); it('link with background color', () => { @@ -228,8 +204,7 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test', - ['test'] + 'test' ); }); }); 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 75cc441f0c3..8e60ecf31a1 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -1,4 +1,5 @@ import * as handleBlock from '../../../lib/modelToDom/handlers/handleBlock'; +import * as reuseCachedElement from '../../../lib/domUtils/reuseCachedElement'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; @@ -22,6 +23,14 @@ describe('handleTable', () => { const div = document.createElement('div'); handleTable(document, div, model, context, null); expect(div.innerHTML).toBe(expectedInnerHTML); + + if (expectedInnerHTML) { + expect((div.firstChild as HTMLElement).tagName).toBe('TABLE'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [div.firstChild as HTMLElement], + removedBlockElements: [], + }); + } } it('Empty table', () => { @@ -616,4 +625,59 @@ describe('handleTable', () => { expect(div.innerHTML).toBe('
'); expect(onTableSpy).toHaveBeenCalledWith(div.firstChild, tableModel); }); + + it('handleTable with cache', () => { + const cachedTable = document.createElement('table'); + const parent = document.createElement('div'); + const tableModel = createTable(1); + const cell = createTableCell(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + + cachedTable.id = 'table1'; + tableModel.rows[0].cells.push(cell); + tableModel.cachedElement = cachedTable; + context.allowCacheElement = true; + + handleTable(document, parent, tableModel, context, null); + + expect(parent.innerHTML).toBe( + '
' + ); + expect(parent.firstChild).toBe(cachedTable); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).toHaveBeenCalledWith( + parent, + cachedTable, + null, + context.rewriteFromModel + ); + }); + + it('handleTable without cache', () => { + const parent = document.createElement('div'); + const tableModel = createTable(1); + const cell = createTableCell(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + + tableModel.rows[0].cells.push(cell); + context.allowCacheElement = true; + + handleTable(document, parent, tableModel, context, null); + + expect(parent.innerHTML).toBe('
'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 8a3b3c923a4..8292a138235 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -33,9 +33,8 @@ describe('handleSegmentCommon', () => { 'test' ); expect(onNodeCreated).toHaveBeenCalledWith(segment, txt); - expect(segmentNodes.length).toBe(2); + expect(segmentNodes.length).toBe(1); expect(segmentNodes[0]).toBe(txt); - expect(segmentNodes[1]).toBe(txt.parentNode!); }); it('element with child', () => { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index 4fff6e96286..a3b257c9d94 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -1,6 +1,7 @@ +import { queryContentModelBlocks } from 'roosterjs-content-model-api'; import type { ReadonlyContentModelBlockGroup, - ReadonlyContentModelTable, + ReadonlyContentModelParagraph, } from 'roosterjs-content-model-types'; import type { ImageAndParagraph } from '../types/ImageAndParagraph'; @@ -11,65 +12,24 @@ export function findEditingImage( group: ReadonlyContentModelBlockGroup, imageId?: string ): ImageAndParagraph | null { - for (let i = 0; i < group.blocks.length; i++) { - const block = group.blocks[i]; - - switch (block.blockType) { - case 'BlockGroup': - const result = findEditingImage(block, imageId); - - if (result) { - return result; - } - break; - - case 'Paragraph': - for (let j = 0; j < block.segments.length; j++) { - const segment = block.segments[j]; - switch (segment.segmentType) { - case 'Image': - if ( - (imageId && segment.format.id == imageId) || - segment.dataset.isEditing - ) { - return { - paragraph: block, - image: segment, - }; - } - break; - - case 'General': - const result = findEditingImage(segment, imageId); - - if (result) { - return result; - } - break; - } + let imageAndParagraph: ImageAndParagraph | null = null; + queryContentModelBlocks( + group, + 'Paragraph', + (paragraph: ReadonlyContentModelParagraph): paragraph is ReadonlyContentModelParagraph => { + for (const segment of paragraph.segments) { + if ( + segment.segmentType == 'Image' && + ((imageId && segment.format.id == imageId) || segment.dataset.isEditing) + ) { + imageAndParagraph = { image: segment, paragraph }; + return true; } - break; - case 'Table': - const imageInTable = findEditingImageOnTable(block, imageId); - - if (imageInTable) { - return imageInTable; - } - break; - } - } + } + return false; + }, + true /*findFirstOnly*/ + ); - return null; + return imageAndParagraph; } - -const findEditingImageOnTable = (table: ReadonlyContentModelTable, imageId?: string) => { - for (const row of table.rows) { - for (const cell of row.cells) { - const result = findEditingImage(cell, imageId); - if (result) { - return result; - } - } - } - return null; -}; diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 884e0472dbc..0ce5c79b0fe 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -29,6 +29,7 @@ export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutComma export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase'; export { WatermarkPlugin } from './watermark/WatermarkPlugin'; export { WatermarkFormat } from './watermark/WatermarkFormat'; +export { isModelEmptyFast } from './watermark/isModelEmptyFast'; export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; export { HyperlinkPlugin } from './hyperlink/HyperlinkPlugin'; export { HyperlinkToolTip } from './hyperlink/HyperlinkToolTip'; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts index b17d285d964..5089e328461 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -1,10 +1,9 @@ -import type { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** - * @internal * A fast way to check if content model is empty */ -export function isModelEmptyFast(model: ReadonlyContentModelDocument): boolean { +export function isModelEmptyFast(model: ReadonlyContentModelBlockGroup): boolean { const firstBlock = model.blocks[0]; if (model.blocks.length > 1) { diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts index 220c26ca261..94b3775f614 100644 --- a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -36,7 +36,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -65,7 +69,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).not.toHaveBeenCalled(); @@ -97,7 +105,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -130,7 +142,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -196,7 +212,11 @@ describe('WatermarkPlugin dark mode', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts index 93293d56b97..f9cdf73b010 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts @@ -1,3 +1,4 @@ +import type { RewriteFromModelContext } from './RewriteFromModel'; import type { EditorContext } from './EditorContext'; import type { ModelToDomFormatContext } from './ModelToDomFormatContext'; import type { ModelToDomSelectionContext } from './ModelToDomSelectionContext'; @@ -10,4 +11,5 @@ export interface ModelToDomContext extends EditorContext, ModelToDomSelectionContext, ModelToDomFormatContext, - ModelToDomSettings {} + ModelToDomSettings, + RewriteFromModelContext {} diff --git a/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts b/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts new file mode 100644 index 00000000000..b14e45d2438 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts @@ -0,0 +1,24 @@ +/** + * Represents added and removed block elements during content model to dom conversion + */ +export interface RewriteFromModel { + /** + * Added block elements + */ + addedBlockElements: HTMLElement[]; + + /** + * Removed block elements + */ + removedBlockElements: HTMLElement[]; +} + +/** + * Context object used by contentModelToDom to record added and removed block elements + */ +export interface RewriteFromModelContext { + /** + * DOM modification object + */ + rewriteFromModel: RewriteFromModel; +} diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 46d00bd3733..42144b97f61 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -7,7 +7,10 @@ import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { Snapshot } from '../parameter/Snapshot'; import type { EntityState } from '../parameter/FormatContentModelContext'; import type { DarkColorHandler } from '../context/DarkColorHandler'; -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; +import type { + ContentModelDocument, + ReadonlyContentModelDocument, +} from '../contentModel/blockGroup/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOptionForCreateModel } from '../context/DomToModelOption'; import type { EditorContext } from '../context/EditorContext'; @@ -53,12 +56,15 @@ export type GetDOMSelection = (core: EditorCore) => DOMSelection | null; * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ export type SetContentModel = ( core: EditorCore, model: ContentModelDocument, option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated + onNodeCreated?: OnNodeCreated, + isInitializing?: boolean ) => DOMSelection | null; /** @@ -211,6 +217,9 @@ export interface CoreApiMap { * @param core The EditorCore object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ setContentModel: SetContentModel; @@ -370,6 +379,13 @@ export interface EditorCore extends PluginState { */ readonly disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + /** + * An optional callback function that will be invoked before write content model back to editor. + * This is used for make sure model can satisfy some customized requirement + * @param model The model to fix up + */ + readonly onFixUpModel?: (model: ReadonlyContentModelDocument) => void; + /** * Enabled experimental features */ diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 4bc635e03f1..aad4ea14219 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -7,7 +7,10 @@ import type { ContentModelSegmentFormat } from '../contentModel/format/ContentMo import type { CoreApiMap } from './EditorCore'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; +import type { + ContentModelDocument, + ReadonlyContentModelDocument, +} from '../contentModel/blockGroup/ContentModelDocument'; import type { Snapshots } from '../parameter/Snapshot'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; @@ -68,6 +71,13 @@ export interface ContentModelOptions { */ defaultSegmentFormat?: ContentModelSegmentFormat; + /** + * An optional callback function that will be invoked before write content model back to editor. + * This is used for make sure model can satisfy some customized requirement + * @param model The model to fix up + */ + onFixUpModel?: (model: ReadonlyContentModelDocument) => void; + /** * @deprecated */ diff --git a/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts b/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts index 611343fe51d..ea970d9d098 100644 --- a/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts @@ -1,6 +1,7 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; import type { BasePluginEvent } from './BasePluginEvent'; /** * Provides a chance for plugin to change the content before it is pasted into editor. */ -export interface EditorReadyEvent extends BasePluginEvent<'editorReady'> {} +export interface EditorReadyEvent extends RewriteFromModel, BasePluginEvent<'editorReady'> {} diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts index f76b5091740..b6c9834c422 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts @@ -5,6 +5,7 @@ import type { BeforePasteEvent } from './BeforePasteEvent'; import type { BeforeSetContentEvent } from './BeforeSetContentEvent'; import type { ContentChangedEvent } from './ContentChangedEvent'; import type { ContextMenuEvent } from './ContextMenuEvent'; +import type { RewriteFromModelEvent } from './RewriteFromModelEvent'; import type { EditImageEvent } from './EditImageEvent'; import type { EditorInputEvent } from './EditorInputEvent'; import type { EditorReadyEvent } from './EditorReadyEvent'; @@ -30,6 +31,7 @@ export type PluginEvent = | CompositionEndEvent | ContentChangedEvent | ContextMenuEvent + | RewriteFromModelEvent | EditImageEvent | EditorReadyEvent | EnterShadowEditEvent diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts index bd3681f5adb..da6e6155c23 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts @@ -111,6 +111,11 @@ export type PluginEventType = */ | 'zoomChanged' + /** + * Rewrite result information from Content Model + */ + | 'rewriteFromModel' + /** * EXPERIMENTAL FEATURE * Editor changed the selection. diff --git a/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts b/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts new file mode 100644 index 00000000000..77ff6c9bba4 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts @@ -0,0 +1,9 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; +import type { BasePluginEvent } from './BasePluginEvent'; + +/** + * The event triggered when Content Model modifies editor DOM tree, provides added and removed block level elements + */ +export interface RewriteFromModelEvent + extends RewriteFromModel, + BasePluginEvent<'rewriteFromModel'> {} diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 7ecc0e7d9be..215f43d77df 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -298,6 +298,7 @@ export { ModelToDomListContext, ModelToDomFormatContext, } from './context/ModelToDomFormatContext'; +export { RewriteFromModel, RewriteFromModelContext } from './context/RewriteFromModel'; export { ContentModelHandler, ContentModelSegmentHandler, @@ -455,6 +456,7 @@ export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEve export { BeforeSetContentEvent } from './event/BeforeSetContentEvent'; export { ContentChangedEvent, ChangedEntity } from './event/ContentChangedEvent'; export { ContextMenuEvent } from './event/ContextMenuEvent'; +export { RewriteFromModelEvent } from './event/RewriteFromModelEvent'; export { EditImageEvent } from './event/EditImageEvent'; export { EditorReadyEvent } from './event/EditorReadyEvent'; export { EntityOperationEvent, Entity } from './event/EntityOperationEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 0cc6e7f63c2..7ee2b7f1894 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -3,6 +3,7 @@ import type { ContentModelEntity } from '../contentModel/entity/ContentModelEnti import type { ContentModelImage } from '../contentModel/segment/ContentModelImage'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * State for an entity. This is used for storing entity undo snapshot @@ -46,12 +47,13 @@ export interface DeletedEntity { */ export interface FormatContentModelContext { /** - * New entities added during the format process + * New entities added during the format process. This value is only respected when autoDetectChangedEntities is not set to true */ readonly newEntities: ContentModelEntity[]; /** * Entities got deleted during formatting. Need to be set by the formatter function + * This value is only respected when autoDetectChangedEntities is not set to true */ readonly deletedEntities: DeletedEntity[]; @@ -87,6 +89,15 @@ export interface FormatContentModelContext { */ newPendingFormat?: ContentModelSegmentFormat | 'preserve'; + /** + * @optional + * Specify new pending format for paragraph + * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position + * To set a new pending format, set this property to the format object + * Otherwise, leave it there and editor will automatically decide if the original pending format is still available + */ + newPendingParagraphFormat?: ContentModelBlockFormat | 'preserve'; + /** * @optional Entity states related to the format API that will be added together with undo snapshot. * When entity states are set, each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState @@ -103,4 +114,9 @@ export interface FormatContentModelContext { * @optional Set this value to tell AnnouncePlugin to announce the given information */ announceData?: AnnounceData | null; + + /** + * @optional When set to true, EntityPlugin will detect any entity changes during this process, newEntities and deletedEntities will be ignored + */ + autoDetectChangedEntities?: boolean; } diff --git a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 5286fc09d71..3dda933222d 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -1,5 +1,6 @@ import type { DOMInsertPoint } from '../selection/DOMSelection'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * Pending format holder interface @@ -8,7 +9,12 @@ export interface PendingFormat { /** * The pending format */ - format: ContentModelSegmentFormat; + format?: ContentModelSegmentFormat; + + /** + * Customized format for paragraph + */ + paragraphFormat?: ContentModelBlockFormat; /** * Insert point of pending format diff --git a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts index ce69c682d00..d261effa622 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -1,3 +1,4 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; import type { KnownAnnounceStrings } from '../parameter/AnnounceData'; /** @@ -19,6 +20,11 @@ export interface LifecyclePluginState { */ announceContainer?: HTMLElement; + /** + * added and removed block elements when initialize + */ + rewriteFromModel?: RewriteFromModel; + /** * A callback to help get string template to announce, used for accessibility * @param key The key of known announce data diff --git a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts index 248adf32c9b..447c1b68929 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts @@ -214,9 +214,12 @@ export function oldEventToNewEvent( }; case PluginEventType.EditorReady: + const refEditorReadyEvent = refEvent?.eventType == 'editorReady' ? refEvent : undefined; return { eventType: 'editorReady', eventDataCache: input.eventDataCache, + addedBlockElements: refEditorReadyEvent?.addedBlockElements ?? [], + removedBlockElements: refEditorReadyEvent?.removedBlockElements ?? [], }; case PluginEventType.EnteredShadowEdit: diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index 482701720eb..7011ce96419 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -99,7 +99,11 @@ describe('BridgePlugin', () => { expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); @@ -186,7 +190,11 @@ describe('BridgePlugin', () => { expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); @@ -473,7 +481,11 @@ describe('BridgePlugin', () => { expect(disposeSpy).not.toHaveBeenCalled(); expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts index 0860987e5bd..18bc59c88a5 100644 --- a/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts @@ -361,6 +361,8 @@ describe('oldEventToNewEvent', () => { { eventType: 'editorReady', eventDataCache: mockedDataCache, + addedBlockElements: [], + removedBlockElements: [], } ); }); @@ -930,6 +932,8 @@ describe('newEventToOldEvent', () => { { eventType: 'editorReady', eventDataCache: mockedDataCache, + addedBlockElements: [], + removedBlockElements: [], }, undefined, { diff --git a/packages/roosterjs/test/createEditorTest.ts b/packages/roosterjs/test/createEditorTest.ts index 4d41c1a362d..d5c5eb2860d 100644 --- a/packages/roosterjs/test/createEditorTest.ts +++ b/packages/roosterjs/test/createEditorTest.ts @@ -32,6 +32,8 @@ describe('createEditor', () => { expect(dispose).not.toHaveBeenCalled(); expect(onPluginEvent).toHaveBeenCalledWith({ eventType: 'editorReady', + addedBlockElements: [div.firstChild], + removedBlockElements: [], }); editor.dispose();