diff --git a/packages/roosterjs-content-model-core/lib/command/paste/generatePasteOptionFromPlugins.ts b/packages/roosterjs-content-model-core/lib/command/paste/generatePasteOptionFromPlugins.ts index 3b8fd283fb8..3cd712671cc 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/generatePasteOptionFromPlugins.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/generatePasteOptionFromPlugins.ts @@ -36,6 +36,7 @@ export function generatePasteOptionFromPlugins( htmlAttributes: htmlFromClipboard.metadata, pasteType: pasteType, domToModelOption, + containsBlockElements: !!htmlFromClipboard.containsBlockElements, }; return pasteType == 'asPlainText' 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 eda19869afd..fb0e61e966a 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -10,7 +10,6 @@ import { } from 'roosterjs-content-model-dom'; import type { BeforePasteEvent, - ClipboardData, CloneModelOptions, ContentModelDocument, ContentModelSegmentFormat, @@ -37,12 +36,15 @@ export function cloneModelForPaste(model: ReadonlyContentModelDocument) { /** * @internal */ -export function mergePasteContent( - editor: IEditor, - eventResult: BeforePasteEvent, - clipboardData: ClipboardData -) { - const { fragment, domToModelOption, customizedMerge, pasteType } = eventResult; +export function mergePasteContent(editor: IEditor, eventResult: BeforePasteEvent) { + const { + fragment, + domToModelOption, + customizedMerge, + pasteType, + clipboardData, + containsBlockElements, + } = eventResult; editor.formatContentModel( (model, context) => { @@ -64,6 +66,7 @@ export function mergePasteContent( const mergeOption: MergeModelOption = { mergeFormat: pasteType == 'mergeFormat' ? 'keepSourceEmphasisFormat' : 'none', mergeTable: shouldMergeTable(pasteModel), + addParagraphAfterMergedContent: containsBlockElements, }; const insertPoint = customizedMerge @@ -74,7 +77,9 @@ export function mergePasteContent( context.newPendingFormat = { ...EmptySegmentFormat, ...model.format, - ...insertPoint.marker.format, + ...(pasteType == 'normal' && !containsBlockElements + ? getLastSegmentFormat(pasteModel) + : insertPoint.marker.format), }; } @@ -124,3 +129,19 @@ function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined // Only merge table when the document contain a single table. return pasteModel.blocks.length === 1 && pasteModel.blocks[0].blockType === 'Table'; } + +function getLastSegmentFormat(pasteModel: ContentModelDocument): ContentModelSegmentFormat { + if (pasteModel.blocks.length == 1) { + const [firstBlock] = pasteModel.blocks; + + if (firstBlock.blockType == 'Paragraph') { + const segment = firstBlock.segments[firstBlock.segments.length - 1]; + + return { + ...segment.format, + }; + } + } + + return {}; +} diff --git a/packages/roosterjs-content-model-core/lib/command/paste/paste.ts b/packages/roosterjs-content-model-core/lib/command/paste/paste.ts index aca1944187a..69cd08340d5 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/paste.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/paste.ts @@ -67,7 +67,7 @@ export function paste( convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); // 6. Merge pasted content into main Content Model - mergePasteContent(editor, eventResult, clipboardData); + mergePasteContent(editor, eventResult); } function createDOMFromHtml( diff --git a/packages/roosterjs-content-model-core/lib/command/paste/retrieveHtmlInfo.ts b/packages/roosterjs-content-model-core/lib/command/paste/retrieveHtmlInfo.ts index 95b86bee134..bf69907bd9f 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/retrieveHtmlInfo.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/retrieveHtmlInfo.ts @@ -1,4 +1,4 @@ -import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { isBlockElement, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; import { retrieveCssRules } from '../createModelFromHtml/convertInlineCss'; import type { ClipboardData } from 'roosterjs-content-model-types'; import type { CssRule } from '../createModelFromHtml/convertInlineCss'; @@ -14,6 +14,7 @@ export interface HtmlFromClipboard { globalCssRules: CssRule[]; htmlBefore?: string; htmlAfter?: string; + containsBlockElements?: boolean; } /** @@ -33,6 +34,7 @@ export function retrieveHtmlInfo( ...retrieveHtmlStrings(clipboardData), globalCssRules: retrieveCssRules(doc), metadata: retrieveMetadata(doc), + containsBlockElements: checkBlockElements(doc), }; clipboardData.htmlFirstLevelChildTags = retrieveTopLevelTags(doc); @@ -96,3 +98,9 @@ function retrieveHtmlStrings( return { htmlBefore, htmlAfter }; } + +function checkBlockElements(doc: Document): boolean { + const elements = toArray(doc.body.querySelectorAll('*')); + + return elements.some(el => isNodeOfType(el, 'ELEMENT_NODE') && isBlockElement(el)); +} diff --git a/packages/roosterjs-content-model-core/test/command/paste/generatePasteOptionFromPluginsTest.ts b/packages/roosterjs-content-model-core/test/command/paste/generatePasteOptionFromPluginsTest.ts index 185b30bf072..7c434d19259 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/generatePasteOptionFromPluginsTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/generatePasteOptionFromPluginsTest.ts @@ -55,6 +55,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlBefore, htmlAfter, htmlAttributes: mockedMetadata, + containsBlockElements: false, } as any); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(originalEvent).toEqual({ @@ -74,6 +75,7 @@ describe('generatePasteOptionFromPlugins', () => { styleSanitizers: {}, attributeSanitizers: {}, }, + containsBlockElements: false, }); expect(triggerPluginEventSpy).toHaveBeenCalledWith( 'beforePaste', @@ -86,6 +88,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlAttributes: mockedMetadata, pasteType: 'TypeResult', domToModelOption: 'OptionResult', + containsBlockElements: false, }, true ); @@ -126,6 +129,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlBefore, htmlAfter, htmlAttributes: mockedMetadata, + containsBlockElements: false, } as any); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith( @@ -140,6 +144,7 @@ describe('generatePasteOptionFromPlugins', () => { pasteType: 'TypeResult', domToModelOption: 'OptionResult', customizedMerge: mockedCustomizedMerge, + containsBlockElements: false, }, true ); @@ -174,6 +179,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlBefore: '', htmlAfter: '', htmlAttributes: mockedMetadata, + containsBlockElements: false, } as any); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith( @@ -187,6 +193,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlAttributes: mockedMetadata, pasteType: 'TypeResult', domToModelOption: 'OptionResult', + containsBlockElements: false, }, true ); @@ -207,6 +214,7 @@ describe('generatePasteOptionFromPlugins', () => { styleSanitizers: {}, attributeSanitizers: {}, }, + containsBlockElements: false, }); }); @@ -244,6 +252,7 @@ describe('generatePasteOptionFromPlugins', () => { htmlBefore, htmlAfter, htmlAttributes: mockedMetadata, + containsBlockElements: false, }); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); }); 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 index 2d638bed89b..d943eb2fdbb 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/htmlTemplates/ClipboardContent1.ts @@ -165,3 +165,9 @@ export const template: Readonly = ` `; + +export const inlineTemplate: Readonly = ` + + + Inline text +`; 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 ac16a7284a1..71efb5798c0 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -3,13 +3,15 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d 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 { createPasteFragment } from '../../../lib/command/paste/createPasteFragment'; +import { inlineTemplate, template } from './htmlTemplates/ClipboardContent1'; import { mergePasteContent } from '../../../lib/command/paste/mergePasteContent'; -import { template } from './htmlTemplates/ClipboardContent1'; + import { addBlock, createContentModelDocument, createParagraph, createSelectionMarker, + createText, moveChildNodes, } from 'roosterjs-content-model-dom'; import { @@ -162,15 +164,17 @@ describe('mergePasteContent', () => { const eventResult = { pasteType: 'normal', domToModelOption: { additionalAllowedTags: [] }, + clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult, mockedClipboard); + mergePasteContent(editor, eventResult); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: true, + addParagraphAfterMergedContent: undefined, }); expect(context).toEqual({ newEntities: [], @@ -267,9 +271,10 @@ describe('mergePasteContent', () => { pasteType: 'normal', domToModelOption: { additionalAllowedTags: [] }, customizedMerge, + clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult, mockedClipboard); + mergePasteContent(editor, eventResult); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -288,9 +293,10 @@ describe('mergePasteContent', () => { const eventResult = { pasteType: 'mergeFormat', domToModelOption: { additionalAllowedTags: [] }, + clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult, mockedClipboard); + mergePasteContent(editor, eventResult); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -301,6 +307,7 @@ describe('mergePasteContent', () => { { mergeFormat: 'keepSourceEmphasisFormat', mergeTable: false, + addParagraphAfterMergedContent: undefined, } ); }); @@ -370,14 +377,11 @@ describe('mergePasteContent', () => { }, }); - mergePasteContent( - editor, - { - fragment: mockedFragment, - domToModelOption: mockedDefaultDomToModelOptions, - } as any, - mockedClipboard - ); + mergePasteContent(editor, { + fragment: mockedFragment, + domToModelOption: mockedDefaultDomToModelOptions, + clipboardData: mockedClipboard, + } as any); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -403,6 +407,7 @@ describe('mergePasteContent', () => { expect(mergeModelSpy).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: false, + addParagraphAfterMergedContent: undefined, }); expect(createDomToModelContextSpy).toHaveBeenCalledWith( document, @@ -430,9 +435,11 @@ describe('mergePasteContent', () => { const eventResult = { pasteType: 'normal', domToModelOption: { additionalAllowedTags: [] }, + clipboardData: mockedClipboard, + containsBlockElements: true, } as any; - mergePasteContent(editor, eventResult, mockedClipboard); + mergePasteContent(editor, eventResult); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -463,28 +470,26 @@ describe('mergePasteContent', () => { spyOn(mergeModelFile, 'mergeModel').and.callThrough(); spyOn(getSegmentTextFormatFile, 'getSegmentTextFormat').and.returnValue({ - fontSize: 'Calibri', + fontSize: '14px', textColor: 'white', }); sourceModel = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); marker.format = { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'white', }; para.segments.push(marker); addBlock(sourceModel, para); - mergePasteContent( - editor, - { - fragment, - domToModelOption: {}, - pasteType: 'normal', - }, - mockedClipboard - ); + mergePasteContent(editor, { + fragment, + containsBlockElements: true, + domToModelOption: {}, + pasteType: 'normal', + clipboardData: mockedClipboard, + }); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -508,7 +513,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -539,7 +544,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -570,7 +575,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -600,7 +605,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -637,12 +642,18 @@ describe('mergePasteContent', () => { { segmentType: 'Text', text: '\n', - format: { fontSize: 'Calibri', textColor: 'rgb(0,0,0)' }, + format: { fontSize: '14px', textColor: 'rgb(0,0,0)' }, }, ], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: { fontSize: '14px', textColor: 'white' }, + }, ], }, { @@ -652,7 +663,7 @@ describe('mergePasteContent', () => { newPendingFormat: { backgroundColor: '', fontFamily: '', - fontSize: 'Calibri', + fontSize: '14px', fontWeight: '', italic: false, letterSpacing: '', @@ -666,6 +677,7 @@ describe('mergePasteContent', () => { { mergeFormat: 'none', mergeTable: false, + addParagraphAfterMergedContent: true, } ); @@ -689,7 +701,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -720,7 +732,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -751,7 +763,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -781,7 +793,7 @@ describe('mergePasteContent', () => { segmentType: 'Text', text: '\n', format: { - fontSize: 'Calibri', + fontSize: '14px', textColor: 'rgb(0,0,0)', }, }, @@ -818,16 +830,27 @@ describe('mergePasteContent', () => { { segmentType: 'Text', text: '\n', - format: { fontSize: 'Calibri', textColor: 'rgb(0,0,0)' }, + format: { fontSize: '14px', textColor: 'rgb(0,0,0)' }, }, + ], + format: { marginTop: '1em', marginBottom: '1em' }, + decorator: { tagName: 'p', format: {} }, + }, + { + blockType: 'Paragraph', + segments: [ { segmentType: 'SelectionMarker', isSelected: true, - format: { fontSize: 'Calibri', textColor: 'white' }, + format: { fontSize: '14px', textColor: 'white' }, + }, + { + segmentType: 'Br', + format: { fontSize: '14px', textColor: 'white' }, }, ], - format: { marginTop: '1em', marginBottom: '1em' }, - decorator: { tagName: 'p', format: {} }, + format: {}, + segmentFormat: { fontSize: '14px', textColor: 'white' }, }, ], }); @@ -853,15 +876,12 @@ describe('mergePasteContent', () => { para.segments.push(marker); sourceModel.blocks.push(para); - mergePasteContent( - editor, - { - fragment, - domToModelOption: {}, - pasteType: 'mergeFormat', - } as any, - mockedClipboard - ); + mergePasteContent(editor, { + fragment, + domToModelOption: {}, + pasteType: 'mergeFormat', + clipboardData: mockedClipboard, + } as any); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1027,6 +1047,7 @@ describe('mergePasteContent', () => { { mergeFormat: 'keepSourceEmphasisFormat', mergeTable: false, + addParagraphAfterMergedContent: undefined, } ); @@ -1224,15 +1245,12 @@ describe('mergePasteContent', () => { para.segments.push(marker); sourceModel.blocks.push(para); - mergePasteContent( - editor, - { - fragment, - domToModelOption: {}, - pasteType: 'asPlainText', - } as any, - mockedClipboard - ); + mergePasteContent(editor, { + fragment, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + } as any); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1335,6 +1353,7 @@ describe('mergePasteContent', () => { { mergeFormat: 'none', mergeTable: false, + addParagraphAfterMergedContent: undefined, } ); @@ -1424,4 +1443,403 @@ describe('mergePasteContent', () => { ], }); }); + + it('Merge paste content | Paste Type = normal | Paste content inline', () => { + const html = new DOMParser().parseFromString(inlineTemplate, '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({ + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const para = createParagraph(undefined, undefined, { + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const marker = createSelectionMarker(); + marker.format = { + fontSize: '14px', + textColor: 'white', + }; + para.segments.push(createText('Text in source'), marker); + addBlock(sourceModel, para); + + mergePasteContent(editor, { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'normal', + clipboardData: mockedClipboard, + }); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Inline text', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Aptos', + fontSize: '14px', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'rgb(0,0,0)', + underline: false, + }, + }, + { + mergeFormat: 'none', + mergeTable: false, + addParagraphAfterMergedContent: false, + } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + format: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text in source', + format: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, + }, + { + segmentType: 'Text', + text: 'Inline text', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontSize: '14px', + textColor: 'white', + fontFamily: 'Aptos', + }, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Merge paste content | Paste Type = mergeFormat | Paste content inline', () => { + const html = new DOMParser().parseFromString(inlineTemplate, '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({ + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const para = createParagraph(undefined, undefined, { + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const marker = createSelectionMarker(); + marker.format = { + fontSize: '14px', + textColor: 'white', + }; + para.segments.push(createText('Text in source'), marker); + addBlock(sourceModel, para); + + mergePasteContent(editor, { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'mergeFormat', + clipboardData: mockedClipboard, + }); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Inline text', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { fontSize: '14px', textColor: 'white' }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Aptos', + fontSize: '14px', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'white', + underline: false, + }, + }, + { + mergeFormat: 'keepSourceEmphasisFormat', + mergeTable: false, + addParagraphAfterMergedContent: false, + } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Text in source', format: {} }, + { + segmentType: 'Text', + text: 'Inline text', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '14px', textColor: 'white' }, + }, + ], + format: {}, + segmentFormat: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, + }, + ], + format: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, + }); + }); + + it('Merge paste content | Paste Type = mergeFormat | Paste content inline', () => { + const fragment = createPasteFragment( + document, + { text: 'Inline text\r\n' } as any, + 'asPlainText', + document.body + ); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(getSegmentTextFormatFile, 'getSegmentTextFormat').and.returnValue({ + fontSize: '14px', + textColor: 'white', + }); + sourceModel = createContentModelDocument({ + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const para = createParagraph(undefined, undefined, { + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }); + const marker = createSelectionMarker(); + marker.format = { + fontSize: '14px', + textColor: 'white', + }; + para.segments.push(createText('Text in source'), marker); + addBlock(sourceModel, para); + + mergePasteContent(editor, { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + }); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Inline text', + format: { fontSize: '14px', textColor: 'white' }, + }, + { segmentType: 'Br', format: { fontSize: '14px', textColor: 'white' } }, + ], + format: {}, + isImplicit: true, + segmentFormat: { fontSize: '14px', textColor: 'white' }, + }, + ], + }, + { + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Aptos', + fontSize: '14px', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: 'white', + underline: false, + }, + }, + { mergeFormat: 'none', mergeTable: false, addParagraphAfterMergedContent: false } + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text in source', + format: { + fontFamily: 'Aptos', + fontSize: '10pt', + textColor: 'blue', + }, + }, + { + segmentType: 'Text', + text: 'Inline text', + format: { fontSize: '14px', textColor: 'white' }, + }, + { segmentType: 'Br', format: { fontSize: '14px', textColor: 'white' } }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Br', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + ], + format: {}, + }, + ], + format: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, + }); + }); }); 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 63777d1a211..a6aab524bb7 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts @@ -353,11 +353,22 @@ describe('Paste with clipboardData', () => { textColor: 'rgb(0, 0, 0)', }, }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ { - segmentType: 'SelectionMarker', isSelected: true, + segmentType: 'SelectionMarker', format: { - textColor: '', backgroundColor: '', fontFamily: '', fontSize: '', @@ -367,18 +378,14 @@ describe('Paste with clipboardData', () => { lineHeight: '', strikethrough: false, superOrSubScriptSequence: '', + textColor: '', underline: false, }, }, + { segmentType: 'Br', format: {} }, ], - format: { - marginTop: '1em', - marginBottom: '1em', - }, - decorator: { - tagName: 'p', - format: {}, - }, + blockType: 'Paragraph', + format: {}, }, ], format: {}, @@ -418,7 +425,7 @@ describe('Paste with clipboardData', () => { lineHeight: '', strikethrough: false, superOrSubScriptSequence: '', - textColor: '', + textColor: 'rgb(0,0,0)', underline: false, }, }, @@ -470,7 +477,7 @@ describe('Paste with clipboardData', () => { lineHeight: '', strikethrough: false, superOrSubScriptSequence: '', - textColor: '', + textColor: 'rgb(0,0,0)', underline: false, }, link: { diff --git a/packages/roosterjs-content-model-core/test/command/paste/retrieveHtmlInfoTest.ts b/packages/roosterjs-content-model-core/test/command/paste/retrieveHtmlInfoTest.ts index 542a9664180..2e6938eff6a 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/retrieveHtmlInfoTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/retrieveHtmlInfoTest.ts @@ -44,6 +44,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: false, }, { htmlFirstLevelChildTags: [], @@ -61,6 +62,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: false, }, { htmlFirstLevelChildTags: [''], @@ -78,6 +80,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], @@ -95,6 +98,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['', 'DIV', 'SPAN', ''], @@ -112,6 +116,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], @@ -129,6 +134,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], @@ -146,6 +152,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], @@ -163,6 +170,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: { a: 'b', 'c:d': 'e', f: 'g', h: 'i' }, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], @@ -181,6 +189,7 @@ describe('retrieveHtmlInfo', () => { htmlAfter: '', globalCssRules: [], metadata: {}, + containsBlockElements: true, }, { htmlFirstLevelChildTags: ['DIV'], 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 d0f148387eb..3ea33badf72 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -1,3 +1,4 @@ +import { addBlock } from '../common/addBlock'; import { addSegment } from '../common/addSegment'; import { applyTableFormat } from './applyTableFormat'; import { createListItem } from '../creators/createListItem'; @@ -49,6 +50,12 @@ export function mergeModel( const insertPosition = options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; + if (options?.addParagraphAfterMergedContent) { + const { paragraph, marker } = insertPosition || {}; + const newPara = createParagraph(false /* isImplicit */, paragraph?.format, marker?.format); + addBlock(source, newPara); + } + if (insertPosition) { if (options?.mergeFormat && options.mergeFormat != 'none') { const newFormat: 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 72c4023c39e..f6e6b58e40b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -3835,4 +3835,39 @@ describe('mergeModel', () => { verticalAlign: 'top', }); }); + + it('Merge model with addParagraphAfterMergedContent', () => { + const source = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('Merge')); + source.blocks.push(para); + + const target = createContentModelDocument(); + const paraTarget = createParagraph(); + paraTarget.segments.push(createSelectionMarker()); + target.blocks.push(paraTarget); + + mergeModel(target, source, undefined, { + addParagraphAfterMergedContent: true, + }); + + expect(target).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'Merge', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index 28a3b6c712a..5e4853c83c5 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -65,6 +65,15 @@ describe(ID, () => { isSelectedAsImageSelection: undefined, isSelected: undefined, }, + ], + format: {}, + cachedElement: undefined, + isImplicit: undefined, + segmentFormat: undefined, + }, + { + blockType: 'Paragraph', + segments: [ { segmentType: 'SelectionMarker', isSelected: true, @@ -82,6 +91,7 @@ describe(ID, () => { underline: false, }, }, + { segmentType: 'Br', isSelected: undefined, format: {} }, ], format: {}, cachedElement: undefined, diff --git a/packages/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts b/packages/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts index 2fbe371b9cc..870a81afcf7 100644 --- a/packages/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts @@ -62,4 +62,9 @@ export interface BeforePasteEvent extends BasePluginEvent<'beforePaste'> { * customizedMerge Customized merge function to use when merging the paste fragment into the editor */ customizedMerge?: MergePastedContentFunc; + + /** + * Whether the current clipboard contains at least a block element. + */ + readonly containsBlockElements?: boolean; } diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts index 90f84d91f52..25a24267e52 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -27,4 +27,9 @@ export interface MergeModelOption { * @default undefined */ mergeFormat?: 'mergeAll' | 'keepSourceEmphasisFormat' | 'none'; + + /** + * Whether to add a paragraph after the merged content. + */ + addParagraphAfterMergedContent?: boolean; }