From 1af04a38713d17ea6d1d5d2624fa07dc48c2d9f2 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Thu, 29 Aug 2024 16:12:26 -0600 Subject: [PATCH] Set segmentFormat text color to black when creating the model of the clipboard content and using Keep source formatting paste type (#2773) * init * add a link to tests and make sure it is handled correctly --- .../lib/command/paste/mergePasteContent.ts | 31 +- .../paste/htmlTemplates/ClipboardContent1.ts | 167 +++ .../command/paste/mergePasteContentTest.ts | 980 +++++++++++++++++- .../test/command/paste/pasteTest.ts | 12 +- 4 files changed, 1183 insertions(+), 7 deletions(-) create mode 100644 packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts diff --git a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts index 1985248838c..eda19869afd 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -13,11 +13,16 @@ import type { ClipboardData, CloneModelOptions, ContentModelDocument, + ContentModelSegmentFormat, IEditor, MergeModelOption, + PasteType, ReadonlyContentModelDocument, + ShallowMutableContentModelDocument, } from 'roosterjs-content-model-types'; +const BlackColor = 'rgb(0,0,0)'; + const CloneOption: CloneModelOptions = { includeCachedElement: (node, type) => (type == 'cache' ? undefined : node), }; @@ -46,7 +51,6 @@ export function mergePasteContent( model.blocks = clonedModel.blocks; } - const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; const domToModelContext = createDomToModelContextForSanitizing( editor.getDocument(), undefined /*defaultFormat*/, @@ -54,9 +58,7 @@ export function mergePasteContent( domToModelOption ); - domToModelContext.segmentFormat = selectedSegment - ? getSegmentTextFormat(selectedSegment) - : {}; + domToModelContext.segmentFormat = getSegmentFormatForPaste(model, pasteType); const pasteModel = domToContentModel(fragment, domToModelContext); const mergeOption: MergeModelOption = { @@ -87,6 +89,27 @@ export function mergePasteContent( ); } +function getSegmentFormatForPaste( + model: ShallowMutableContentModelDocument, + pasteType: PasteType +): ContentModelSegmentFormat { + const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; + + if (selectedSegment) { + const result = getSegmentTextFormat(selectedSegment); + if (pasteType == 'normal') { + // When using normal paste (Keep source formatting) set the default text color to black when creating the + // Model from the clipboard content, so the elements that do not contain any text color in their style + // Are set to black. Otherwise, These segments would get the selected segments format or the default text set in the content. + result.textColor = BlackColor; + } + + return result; + } + + return {}; +} + function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { // If model contains a table and a paragraph element after the table with a single BR segment, remove the Paragraph after the table if ( diff --git a/packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts b/packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts new file mode 100644 index 00000000000..2d638bed89b --- /dev/null +++ b/packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts @@ -0,0 +1,167 @@ +export const template: Readonly = ` + + + + + + + + + + + + + + +

+ Red bold +

+ +

+ Red italic +

+ +

+ Red underline +

+ +

+ Unformatted line +

+ +

+ Text underlink +

+ + + + +`; diff --git a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts index a7456ff4fcf..ac16a7284a1 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -1,8 +1,17 @@ import * as createDomToModelContextForSanitizing from '../../../lib/command/createModelFromHtml/createDomToModelContextForSanitizing'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as getSegmentTextFormatFile from 'roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat'; import * as mergeModelFile from 'roosterjs-content-model-dom/lib/modelApi/editing/mergeModel'; -import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { createPasteFragment } from '../../../lib/command/paste/createPasteFragment'; import { mergePasteContent } from '../../../lib/command/paste/mergePasteContent'; +import { template } from './htmlTemplates/ClipboardContent1'; +import { + addBlock, + createContentModelDocument, + createParagraph, + createSelectionMarker, + moveChildNodes, +} from 'roosterjs-content-model-dom'; import { ContentModelDocument, ContentModelFormatter, @@ -446,4 +455,973 @@ describe('mergePasteContent', () => { }, }); }); + + it('Merge paste content | Paste Type = normal | Make undefined text color equal to black', () => { + const html = new DOMParser().parseFromString(template, 'text/html'); + const fragment = document.createDocumentFragment(); + moveChildNodes(fragment, html.body); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(getSegmentTextFormatFile, 'getSegmentTextFormat').and.returnValue({ + fontSize: 'Calibri', + textColor: 'white', + }); + sourceModel = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + marker.format = { + fontSize: 'Calibri', + textColor: 'white', + }; + para.segments.push(marker); + addBlock(sourceModel, para); + + mergePasteContent( + editor, + { + fragment, + domToModelOption: {}, + pasteType: 'normal', + }, + mockedClipboard + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + fontWeight: 'bold', + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + italic: true, + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + underline: true, + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '28pt', + textColor: 'rgb(0,0,0)', + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text underlink', + format: { + fontSize: '28pt', + textColor: 'rgb(0,0,0)', + underline: true, + lineHeight: '115%', + }, + link: { + format: { + underline: true, + href: 'https://github.com/microsoft/roosterjs', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { fontSize: 'Calibri', textColor: 'rgb(0,0,0)' }, + }, + ], + format: { marginTop: '1em', marginBottom: '1em' }, + decorator: { tagName: 'p', format: {} }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: '', + fontSize: 'Calibri', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'white', + underline: false, + }, + }, + { + mergeFormat: 'none', + mergeTable: false, + } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + fontWeight: 'bold', + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + italic: true, + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '28pt', + textColor: 'rgb(192, 0, 0)', + underline: true, + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '28pt', + textColor: 'rgb(0,0,0)', + lineHeight: '115%', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: 'Calibri', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text underlink', + format: { + fontSize: '28pt', + textColor: 'rgb(0,0,0)', + underline: true, + lineHeight: '115%', + }, + link: { + format: { + underline: true, + href: 'https://github.com/microsoft/roosterjs', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { fontSize: 'Calibri', textColor: 'rgb(0,0,0)' }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: 'Calibri', textColor: 'white' }, + }, + ], + format: { marginTop: '1em', marginBottom: '1em' }, + decorator: { tagName: 'p', format: {} }, + }, + ], + }); + }); + + it('Merge paste content | Paste Type = mergeFormat | Use current format', () => { + const html = new DOMParser().parseFromString(template, 'text/html'); + const fragment = document.createDocumentFragment(); + moveChildNodes(fragment, html.body); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(getSegmentTextFormatFile, 'getSegmentTextFormat').and.returnValue({ + fontSize: '14px', + textColor: 'white', + }); + sourceModel = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + marker.format = { + fontSize: '14px', + textColor: 'white', + }; + para.segments.push(marker); + sourceModel.blocks.push(para); + + mergePasteContent( + editor, + { + fragment, + domToModelOption: {}, + pasteType: 'mergeFormat', + } as any, + mockedClipboard + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '14px', + textColor: 'white', + fontWeight: 'bold', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '14px', + textColor: 'white', + italic: true, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '14px', + textColor: 'white', + underline: true, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text underlink', + format: { + fontSize: '14px', + textColor: 'white', + underline: true, + }, + link: { + format: { + href: 'https://github.com/microsoft/roosterjs', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: '', + fontSize: '14px', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'white', + underline: false, + }, + }, + { + mergeFormat: 'keepSourceEmphasisFormat', + mergeTable: false, + } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '14px', + textColor: 'white', + fontWeight: 'bold', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '14px', + textColor: 'white', + italic: true, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '14px', + textColor: 'white', + underline: true, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text underlink', + format: { + fontSize: '14px', + textColor: 'white', + underline: true, + }, + link: { + format: { + href: 'https://github.com/microsoft/roosterjs', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + }); + }); + + it('Merge paste content | Paste Type = asPlainText | Use current format', () => { + const fragment = createPasteFragment( + document, + { text: 'Red bold\r\nRed italic\r\nRed underline\r\nUnformatted line\r\n' } as any, + 'asPlainText', + document.body + ); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(getSegmentTextFormatFile, 'getSegmentTextFormat').and.returnValue({ + fontSize: '14px', + textColor: 'white', + }); + sourceModel = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + marker.format = { + fontSize: '14px', + textColor: 'white', + }; + para.segments.push(marker); + sourceModel.blocks.push(para); + + mergePasteContent( + editor, + { + fragment, + domToModelOption: {}, + pasteType: 'asPlainText', + } as any, + mockedClipboard + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: '', + fontSize: '14px', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'white', + underline: false, + }, + }, + { + mergeFormat: 'none', + mergeTable: false, + } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red bold', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red italic', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Red underline', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Unformatted line', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '14px', + textColor: 'white', + }, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts index 3e6168ebf72..63777d1a211 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts @@ -398,7 +398,13 @@ describe('Paste with clipboardData', () => { blocks: [ { segments: [ - { text: 'Link', segmentType: 'Text', format: {} }, + { + text: 'Link', + segmentType: 'Text', + format: { + textColor: 'rgb(0, 0, 0)', + }, + }, { isSelected: true, segmentType: 'SelectionMarker', @@ -418,6 +424,7 @@ describe('Paste with clipboardData', () => { }, ], blockType: 'Paragraph', + segmentFormat: { textColor: 'rgb(0, 0, 0)' }, format: {}, }, ], @@ -441,7 +448,7 @@ describe('Paste with clipboardData', () => { { text: 'Link', segmentType: 'Text', - format: {}, + format: { textColor: 'rgb(0, 0, 0)' }, link: { format: { underline: true, @@ -476,6 +483,7 @@ describe('Paste with clipboardData', () => { }, ], blockType: 'Paragraph', + segmentFormat: { textColor: 'rgb(0, 0, 0)' }, format: {}, }, ],