From eaf810ba514aaa1c45007aea8b55057516bc2499 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 25 Oct 2024 13:53:46 -0600 Subject: [PATCH] Add `preferSource` and `preferTarget` merge options to mergeModel API. (#2844) * initial * init --- .../lib/modelApi/editing/mergeModel.ts | 64 +- .../test/modelApi/editing/mergeModelTest.ts | 1755 +++++++++++++++++ .../lib/parameter/MergeModelOption.ts | 11 +- 3 files changed, 1803 insertions(+), 27 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index 08497fea2a2..b8d566e1336 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -33,6 +33,8 @@ import type { const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; const KeysOfSegmentFormat = getObjectKeys(EmptySegmentFormat); +type MergeFormatTypes = 'mergeAll' | 'keepSourceEmphasisFormat' | 'preferSource' | 'preferTarget'; + /** * Merge source model into target mode * @param target Target Content Model that will merge content into @@ -333,7 +335,7 @@ function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { function applyDefaultFormat( group: ReadonlyContentModelBlockGroup, format: ContentModelSegmentFormat, - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat' + applyDefaultFormatOption: MergeFormatTypes ) { group.blocks.forEach(block => { mergeBlockFormat(applyDefaultFormatOption, block); @@ -414,39 +416,51 @@ function getSegmentFormatInLinkFormat( } function mergeLinkFormat( - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', + applyDefaultFormatOption: MergeFormatTypes, targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelHyperLinkFormat ) { - return applyDefaultFormatOption == 'mergeAll' - ? { ...getSegmentFormatInLinkFormat(targetFormat), ...sourceFormat } - : { - // Hyperlink segment format contains other attributes such as LinkFormat - // so we have to retain them - ...getFormatWithoutSegmentFormat(sourceFormat), - // Link format only have Text color, background color, Underline, but only - // text color + background color should be merged from the target - ...getSegmentFormatInLinkFormat(targetFormat), - // Get the semantic format of the source - ...getSemanticFormat(sourceFormat), - // The text color of the hyperlink should not be merged and - // we should always retain the source text color - ...getHyperlinkTextColor(sourceFormat), - }; + switch (applyDefaultFormatOption) { + case 'mergeAll': + case 'preferSource': + return { ...getSegmentFormatInLinkFormat(targetFormat), ...sourceFormat }; + case 'keepSourceEmphasisFormat': + return { + // Hyperlink segment format contains other attributes such as LinkFormat + // so we have to retain them + ...getFormatWithoutSegmentFormat(sourceFormat), + // Link format only have Text color, background color, Underline, but only + // text color + background color should be merged from the target + ...getSegmentFormatInLinkFormat(targetFormat), + // Get the semantic format of the source + ...getSemanticFormat(sourceFormat), + // The text color of the hyperlink should not be merged and + // we should always retain the source text color + ...getHyperlinkTextColor(sourceFormat), + }; + case 'preferTarget': + return { ...sourceFormat, ...getSegmentFormatInLinkFormat(targetFormat) }; + } } function mergeSegmentFormat( - applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', + applyDefaultFormatOption: MergeFormatTypes, targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { - return applyDefaultFormatOption == 'mergeAll' - ? { ...targetFormat, ...sourceFormat } - : { - ...getFormatWithoutSegmentFormat(sourceFormat), - ...targetFormat, - ...getSemanticFormat(sourceFormat), - }; + switch (applyDefaultFormatOption) { + case 'mergeAll': + case 'preferSource': + return { ...targetFormat, ...sourceFormat }; + case 'preferTarget': + return { ...sourceFormat, ...targetFormat }; + case 'keepSourceEmphasisFormat': + return { + ...getFormatWithoutSegmentFormat(sourceFormat), + ...targetFormat, + ...getSemanticFormat(sourceFormat), + }; + } } function getSemanticFormat(segmentFormat: ContentModelSegmentFormat): ContentModelSegmentFormat { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 6d55c59b8d7..f806f20e84a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -4052,4 +4052,1759 @@ describe('mergeModel', () => { ], }); }); + + // #region preferTarget + + it('Use customized insert position', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker({ fontSize: '10pt' }); + const marker3 = createSelectionMarker(); + + para1.segments.push(marker1, text1, marker2, text2, marker3); + majorModel.blocks.push(para1); + + const newPara = createParagraph(); + const newText = createText('new text'); + + newPara.segments.push(newText); + sourceModel.blocks.push(newPara); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', + format: {}, + }, + marker2, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker: marker2, + paragraph, + path: [majorModel], + }); + }); + + it('Merge with default format paragraph and paragraph with decorator, preferTarget', () => { + const MockedFormat = { + fontFamily: 'Target', + fontWeight: 'Target', + italic: 'Target', + underline: 'Target', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourcefontSize', + }, + }, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferTarget', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'Target', + fontWeight: 'Target', + italic: 'Target' as any, + underline: 'Target' as any, + fontSize: 'sourcefontSize', + }, + }, + marker, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + format: MockedFormat, + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Table + Paragraph', () => { + const majorModel = createContentModelDocument(); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferTarget', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + marker, + ], + format: {}, + segmentFormat: { fontFamily: 'Calibri', fontSize: '11pt', textColor: 'black' }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + paragraph, + ], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge two Images', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const newImage1: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage, newImage1], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + newImage1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage, newImage1], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge into a paragraph with image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const image: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const marker = createSelectionMarker(); + + para1.segments.push(image, marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [image], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferTarget', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + image, + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [image, newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Link Format with preferTarget option', () => { + const newTarget: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const mergeLinkSourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + mergeModel(newTarget, mergeLinkSourceModel, undefined, { + mergeFormat: 'preferTarget', + }); + + const para = newTarget.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + underline: true, + }, + dataset: {}, + }); + }); + + it('Merge Link Format with preferTarget option 2', () => { + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'preferTarget', + }); + + const para = targetModel.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + backgroundColor: 'rgb(32, 31, 30)', + }, + dataset: {}, + }); + }); + // #endregion + + // #region preferSource + + it('Use customized insert position', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker({ fontSize: '10pt' }); + const marker3 = createSelectionMarker(); + + para1.segments.push(marker1, text1, marker2, text2, marker3); + majorModel.blocks.push(para1); + + const newPara = createParagraph(); + const newText = createText('new text'); + + newPara.segments.push(newText); + sourceModel.blocks.push(newPara); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', + format: {}, + }, + marker2, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker: marker2, + paragraph, + path: [majorModel], + }); + }); + + it('Merge with default format paragraph and paragraph with decorator, preferSource', () => { + const MockedFormat = { + fontFamily: 'mocked', + fontWeight: 'ToBeRemoved', + italic: 'ToBeRemoved', + underline: 'ToBeRemoved', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourcefontSize', + }, + }, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferSource', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontWeight: 'sourceDecoratorFontWeight', + italic: true, + underline: true, + fontFamily: 'sourceFontFamily', + fontSize: 'sourcefontSize', + }, + }, + marker, + ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + format: MockedFormat, + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Table + Paragraph', () => { + const majorModel = createContentModelDocument(); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'preferSource', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + marker, + ], + format: {}, + segmentFormat: { fontFamily: 'Calibri', fontSize: '11pt', textColor: 'black' }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + width: '120px', + height: '22px', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120, 120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + paragraph, + ], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge two Images', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const newImage1: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage, newImage1], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newImages: [], + newEntities: [], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + newImage1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [newImage, newImage1], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge into a paragraph with image', () => { + const majorModel = createContentModelDocument(); + const newImage: ContentModelImage = { + segmentType: 'Image', + src: 'test', + format: {}, + dataset: {}, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [newImage], + format: {}, + }, + ], + format: {}, + }; + const para1 = createParagraph(); + const image: ContentModelImage = { + segmentType: 'Image', + src: 'test1', + format: {}, + dataset: {}, + }; + const marker = createSelectionMarker(); + + para1.segments.push(image, marker); + majorModel.blocks.push(para1); + + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [image], + }; + + const result = mergeModel(majorModel, sourceModel, context, { + mergeFormat: 'preferSource', + }); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + image, + newImage, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [image, newImage], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + + it('Merge Link Format with preferSource option', () => { + const newTarget: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const mergeLinkSourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + mergeModel(newTarget, mergeLinkSourceModel, undefined, { + mergeFormat: 'preferSource', + }); + + const para = newTarget.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + underline: true, + }, + dataset: {}, + }); + }); + + it('Merge Link Format with preferSource option 2', () => { + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'preferSource', + }); + + const para = targetModel.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + backgroundColor: 'rgb(32, 31, 30)', + }, + dataset: {}, + }); + }); + + // #endregion }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts index 25a24267e52..50d5945272f 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -19,14 +19,21 @@ export interface MergeModelOption { /** * Use this to decide whether to change the source model format when doing the merge. - * 'mergeAll': segment format of the insert position will be merged into the content that is merged into current model. + * 'mergeAll': (deprecated) Use PreferSource Instead, segment format of the insert position will be merged into the content that is merged into current model. * If the source model already has some format, it will not be overwritten. * 'keepSourceEmphasisFormat': format of the insert position will be set into the content that is merged into current model. * If the source model already has emphasis format, such as, fontWeight, Italic or underline different than the default style, it will not be overwritten. * 'none' the source segment format will not be modified. + * 'preferSource' Will merge both formatting, but source will overwrite target + * 'preferTarget' Will merge both formatting, but target will overwrite source * @default undefined */ - mergeFormat?: 'mergeAll' | 'keepSourceEmphasisFormat' | 'none'; + mergeFormat?: + | 'mergeAll' + | 'keepSourceEmphasisFormat' + | 'none' + | 'preferSource' + | 'preferTarget'; /** * Whether to add a paragraph after the merged content.