diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index e0149de6e89..fc8fd1ff319 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -62,7 +62,7 @@ const initialState: OptionState = { codeFormat: {}, }, markdownPasteOptions: { - autoConversion: false, + autoConversion: true, undoConversion: false, }, editPluginOptions: { 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 26e795e5b3b..5f629eb6650 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -72,10 +72,16 @@ export function mergeModel( if (mergeFormat && mergeFormat != 'none') { const newFormat: ContentModelSegmentFormat = { ...(target.format || {}), + ...(options?.preserveParagraphDecorator + ? { + ...insertPosition.paragraph.decorator?.format, + ...insertPosition.paragraph.segmentFormat, + } + : undefined), ...insertPosition.marker.format, }; - applyDefaultFormat(source, newFormat, mergeFormat); + applyDefaultFormat(source, newFormat, mergeFormat, options?.preserveParagraphDecorator); } for (let i = 0; i < source.blocks.length; i++) { @@ -476,7 +482,8 @@ function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { function applyDefaultFormat( group: ReadonlyContentModelBlockGroup, format: ContentModelSegmentFormat, - applyDefaultFormatOption: MergeFormatTypes + applyDefaultFormatOption: MergeFormatTypes, + preserveParagraphDecorator?: boolean ) { group.blocks.forEach(block => { mergeBlockFormat(applyDefaultFormatOption, block); @@ -490,13 +497,23 @@ function applyDefaultFormat( block.formatHolder.format ); } - applyDefaultFormat(block, format, applyDefaultFormatOption); + applyDefaultFormat( + block, + format, + applyDefaultFormatOption, + preserveParagraphDecorator + ); break; case 'Table': block.rows.forEach(row => row.cells.forEach(cell => { - applyDefaultFormat(cell, format, applyDefaultFormatOption); + applyDefaultFormat( + cell, + format, + applyDefaultFormatOption, + preserveParagraphDecorator + ); }) ); break; @@ -504,16 +521,30 @@ function applyDefaultFormat( case 'Paragraph': const paragraphFormat = block.decorator?.format || {}; const paragraph = mutateBlock(block); + const paragraphTargetFormat = + applyDefaultFormatOption === 'keepSourceEmphasisFormat' && + preserveParagraphDecorator + ? { ...format, ...paragraphFormat } + : format; paragraph.segments.forEach(segment => { if (segment.segmentType == 'General') { - applyDefaultFormat(segment, format, applyDefaultFormatOption); + applyDefaultFormat( + segment, + format, + applyDefaultFormatOption, + preserveParagraphDecorator + ); } - segment.format = mergeSegmentFormat(applyDefaultFormatOption, format, { - ...paragraphFormat, - ...segment.format, - }); + segment.format = mergeSegmentFormat( + applyDefaultFormatOption, + paragraphTargetFormat, + { + ...paragraphFormat, + ...segment.format, + } + ); if (segment.link) { segment.link.format = mergeLinkFormat( @@ -524,7 +555,10 @@ function applyDefaultFormat( } }); - if (applyDefaultFormatOption === 'keepSourceEmphasisFormat') { + if ( + applyDefaultFormatOption === 'keepSourceEmphasisFormat' && + !preserveParagraphDecorator + ) { delete paragraph.decorator; } break; @@ -607,7 +641,7 @@ function mergeSegmentFormat( function getSemanticFormat(segmentFormat: ContentModelSegmentFormat): ContentModelSegmentFormat { const result: ContentModelSegmentFormat = {}; - const { fontWeight, italic, underline } = segmentFormat; + const { fontWeight, italic, underline, strikethrough } = segmentFormat; if (fontWeight && fontWeight != 'normal') { result.fontWeight = fontWeight; @@ -618,6 +652,9 @@ function getSemanticFormat(segmentFormat: ContentModelSegmentFormat): ContentMod if (underline) { result.underline = underline; } + if (strikethrough) { + result.strikethrough = strikethrough; + } return result; } 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 a8bb611614f..dc16d5a6b0b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -2789,6 +2789,78 @@ describe('mergeModel', () => { }); }); + [undefined, false, true].forEach(preserveParagraphDecorator => { + it(`inherits target paragraph formatting only when preserveParagraphDecorator is enabled: ${preserveParagraphDecorator}`, () => { + const target = createContentModelDocument({ textColor: 'black', fontSize: '12px' }); + const targetParagraph = createParagraph(); + targetParagraph.decorator = { + tagName: 'h3', + format: { textColor: 'blue', fontSize: '24px' }, + }; + targetParagraph.segmentFormat = { textColor: 'green', fontSize: '18px' }; + targetParagraph.segments.push(createSelectionMarker({ fontFamily: 'Arial' })); + target.blocks.push(targetParagraph); + + const source = createContentModelDocument(); + const sourceParagraph = createParagraph(); + sourceParagraph.segments.push(createText('Text')); + source.blocks.push(sourceParagraph); + + mergeModel(target, source, undefined, { + mergeFormat: 'keepSourceEmphasisFormat', + preserveParagraphDecorator, + }); + + expect(targetParagraph.segments[0]).toEqual( + createText('Text', { + textColor: preserveParagraphDecorator ? 'green' : 'black', + fontSize: preserveParagraphDecorator ? '18px' : '12px', + fontFamily: 'Arial', + }) + ); + }); + }); + + it('preserves heading tags and sizes when preserveParagraphDecorator is enabled', () => { + const target = createContentModelDocument({ fontFamily: 'Arial' }); + const targetParagraph = createParagraph(); + targetParagraph.decorator = { + tagName: 'h3', + format: { textColor: 'blue' }, + }; + targetParagraph.segmentFormat = { textColor: 'green' }; + targetParagraph.segments.push( + createSelectionMarker({ fontSize: '20px', fontWeight: 'normal', textColor: 'red' }) + ); + target.blocks.push(targetParagraph); + + const source = createContentModelDocument(); + const sourceParagraph = createParagraph(); + sourceParagraph.decorator = { + tagName: 'h2', + format: { fontSize: '1.5em', fontWeight: 'bold' }, + }; + sourceParagraph.segments.push(createText('Heading')); + source.blocks.push(sourceParagraph); + + mergeModel(target, source, undefined, { + mergeFormat: 'keepSourceEmphasisFormat', + preserveParagraphDecorator: true, + }); + + const expectedFormat = { + fontFamily: 'Arial', + fontSize: '1.5em', + fontWeight: 'bold', + textColor: 'red', + }; + expect(targetParagraph.decorator).toEqual({ + tagName: 'h2', + format: { fontSize: '1.5em', fontWeight: 'bold' }, + }); + expect(targetParagraph.segments[0]).toEqual(createText('Heading', expectedFormat)); + }); + it('Merge with default format paragraph and paragraph with decorator, mergeAll', () => { const MockedFormat = { fontFamily: 'mocked', diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index e79ce50cc53..efcb9f347ee 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -95,7 +95,8 @@ export class MarkdownPastePlugin implements EditorPlugin { }), undefined /*context*/, { - mergeFormat: 'preferTarget', + mergeFormat: 'keepSourceEmphasisFormat', + preserveParagraphDecorator: true, } ); if (this.options.undoConversion) { diff --git a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts index 68b69e574af..2e0b501b6f4 100644 --- a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts +++ b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts @@ -1,5 +1,9 @@ import { MarkdownPastePlugin } from '../../lib/plugins/MarkdownPastePlugin'; -import { ChangeSource } from 'roosterjs-content-model-dom'; +import { + ChangeSource, + contentModelToDom, + createModelToDomContext, +} from 'roosterjs-content-model-dom'; import type { BeforePasteEvent, ClipboardData, @@ -180,60 +184,223 @@ describe('MarkdownPastePlugin', () => { const target = createEmptyModel(); expect(callback(target)).toBe(true); - // The converted heading should have been merged into the target model - const hasHeadingBlock = target.blocks.some( - b => b.blockType === 'Paragraph' && b.decorator?.tagName === 'h1' - ); - expect(hasHeadingBlock).toBe(true); + const paragraph = target.blocks[0]; + expect(paragraph.blockType).toBe('Paragraph'); + if (paragraph.blockType === 'Paragraph') { + expect(paragraph.decorator?.tagName).toBe('h1'); + expect(paragraph.segments[0]).toEqual({ + segmentType: 'Text', + text: 'Heading', + format: { fontWeight: 'bold', fontSize: '2em' }, + }); + } plugin.dispose(); }); - it('preserves the target format when converting markdown on contentChanged Paste', () => { - const plugin = createPlugin(true /*autoConversion*/); - const modelBeforePaste = createEmptyModel(); - const paragraph = modelBeforePaste.blocks[0]; + ['2em', '1.5em', '1.17em', '1em', '0.83em', '0.67em'].forEach((fontSize, index) => { + const level = index + 1; + it(`preserves the h${level} tag and font size while inheriting other target formatting`, () => { + const plugin = createPlugin(true /*autoConversion*/); + const modelBeforePaste = createEmptyModel(); + const paragraph = modelBeforePaste.blocks[0]; - modelBeforePaste.format = { - fontFamily: 'Arial', - }; - if (paragraph.blockType === 'Paragraph') { - paragraph.segments[0].format = { - fontSize: '20px', - textColor: 'red', + modelBeforePaste.format = { + fontFamily: 'Arial', }; - } + if (paragraph.blockType === 'Paragraph') { + paragraph.segments[0].format = { + fontSize: '20px', + textColor: 'red', + fontWeight: 'normal', + strikethrough: true, + }; + } + + const event = createContentChangedPasteEvent({ + text: `${'#'.repeat(level)} Heading`, + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste, + }); - const event = createContentChangedPasteEvent({ - text: '# Heading', - rawHtml: '', - customValues: {}, - pasteNativeEvent: true, - modelBeforePaste, + plugin.onPluginEvent(event); + + const [callback] = formatContentModelSpy.calls.mostRecent().args as [ + (model: ContentModelDocument) => boolean, + FormatContentModelOptions + ]; + const target = createEmptyModel(); + expect(callback(target)).toBe(true); + + const heading = target.blocks[0]; + expect(heading?.blockType).toBe('Paragraph'); + if (heading?.blockType === 'Paragraph') { + expect(heading.decorator).toEqual({ + tagName: `h${level}`, + format: { + fontSize, + fontWeight: 'bold', + }, + }); + expect(heading.segments[0]).toEqual({ + segmentType: 'Text', + text: 'Heading', + format: { + fontFamily: 'Arial', + fontSize, + fontWeight: 'bold', + textColor: 'red', + strikethrough: true, + }, + }); + } + + const fragment = doc.createDocumentFragment(); + contentModelToDom(doc, fragment, target, createModelToDomContext()); + const headingElement = fragment.querySelector(`h${level}`); + expect(headingElement).not.toBeNull(); + expect(headingElement?.textContent).toBe('Heading'); + expect( + Array.from(headingElement?.querySelectorAll('[style]') || []).some( + element => !!element.style.fontSize + ) + ).toBe(false); + const host = document.createElement('div'); + host.style.fontSize = '20px'; + host.appendChild(fragment); + document.body.appendChild(host); + try { + if (headingElement) { + expect( + parseFloat(window.getComputedStyle(headingElement).fontSize) + ).toBeCloseTo(parseFloat(fontSize) * 20, 2); + } + } finally { + host.remove(); + } + + plugin.dispose(); }); + }); - plugin.onPluginEvent(event); + ['decorator', 'segmentFormat'].forEach(colorSource => { + it(`inherits heading text color from the target paragraph ${colorSource}`, () => { + const plugin = createPlugin(true /*autoConversion*/); + const modelBeforePaste = createEmptyModel(); + const paragraph = modelBeforePaste.blocks[0]; + modelBeforePaste.format = { textColor: 'black' }; + + if (paragraph.blockType === 'Paragraph') { + if (colorSource === 'decorator') { + paragraph.decorator = { + tagName: 'h3', + format: { textColor: 'red', fontSize: '1.17em', fontWeight: 'bold' }, + }; + } else { + paragraph.segmentFormat = { textColor: 'red' }; + } + } + + plugin.onPluginEvent( + createContentChangedPasteEvent({ + text: '# First\n## Second', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste, + }) + ); + + const [callback] = formatContentModelSpy.calls.mostRecent().args as [ + (model: ContentModelDocument) => boolean + ]; + const target = createEmptyModel(); + expect(callback(target)).toBe(true); + const headings = target.blocks.filter(block => block.blockType === 'Paragraph'); + expect(headings.length).toBe(2); + headings.forEach(heading => { + if (heading.blockType === 'Paragraph') { + expect(heading.segments[0].format.textColor).toBe('red'); + } + }); - const [callback] = formatContentModelSpy.calls.mostRecent().args as [ - (model: ContentModelDocument) => boolean, - FormatContentModelOptions - ]; - const target = createEmptyModel(); - expect(callback(target)).toBe(true); + const host = document.createElement('div'); + contentModelToDom(document, host, target, createModelToDomContext()); + document.body.appendChild(host); + try { + ['h1', 'h2'].forEach(tagName => { + const heading = host.querySelector(tagName); + expect(heading).not.toBeNull(); + const textParent = heading?.querySelector('span') || heading; + if (textParent) { + expect(window.getComputedStyle(textParent).color).toBe('rgb(255, 0, 0)'); + } + }); + } finally { + host.remove(); + } + + plugin.dispose(); + }); + }); - const heading = target.blocks.find( - block => block.blockType === 'Paragraph' && block.decorator?.tagName === 'h1' - ); - expect(heading?.blockType).toBe('Paragraph'); - if (heading?.blockType === 'Paragraph') { - expect(heading.segments[0].format).toEqual({ + [false, true].forEach(strikethrough => { + it(`preserves markdown emphasis while inheriting target strikethrough=${strikethrough}`, () => { + const plugin = createPlugin(true /*autoConversion*/); + const modelBeforePaste = createEmptyModel(); + const paragraph = modelBeforePaste.blocks[0]; + const targetFormat = { fontFamily: 'Arial', fontSize: '20px', - fontWeight: 'bold', textColor: 'red', - }); - } + fontWeight: 'normal', + italic: false, + strikethrough, + }; - plugin.dispose(); + if (paragraph.blockType === 'Paragraph') { + paragraph.segments[0].format = { ...targetFormat }; + } + + plugin.onPluginEvent( + createContentChangedPasteEvent({ + text: 'plain **bold** *italic* ~~strike~~', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste, + }) + ); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + const [callback] = formatContentModelSpy.calls.mostRecent().args as [ + (model: ContentModelDocument) => boolean + ]; + const target = createEmptyModel(); + expect(callback(target)).toBe(true); + const result = target.blocks[0]; + + expect(result.blockType).toBe('Paragraph'); + if (result.blockType === 'Paragraph') { + expect( + result.segments + .filter(segment => segment.segmentType === 'Text') + .map(segment => segment.format) + ).toEqual([ + targetFormat, + { ...targetFormat, fontWeight: 'bold' }, + targetFormat, + { ...targetFormat, italic: true }, + targetFormat, + { ...targetFormat, strikethrough: true }, + ]); + } + if (paragraph.blockType === 'Paragraph') { + expect(paragraph.segments[0].format).toEqual(targetFormat); + } + plugin.dispose(); + }); }); it('merges converted markdown into a modelBeforePaste that already has content', () => { @@ -275,8 +442,6 @@ describe('MarkdownPastePlugin', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - // The callback should produce a model that contains both the pre-existing - // text and the converted markdown heading const [callback] = formatContentModelSpy.calls.mostRecent().args as [ (model: ContentModelDocument) => boolean, FormatContentModelOptions @@ -284,19 +449,24 @@ describe('MarkdownPastePlugin', () => { const target = createEmptyModel(); expect(callback(target)).toBe(true); - // The pre-existing text segment should still be present after merging - const hasExistingText = target.blocks.some( - b => - b.blockType === 'Paragraph' && - b.segments.some(s => s.segmentType === 'Text' && s.text === 'existing ') - ); - expect(hasExistingText).toBe(true); - - // The converted heading should have been merged into the model - const hasHeadingBlock = target.blocks.some( - b => b.blockType === 'Paragraph' && b.decorator?.tagName === 'h1' - ); - expect(hasHeadingBlock).toBe(true); + const paragraph = target.blocks[0]; + expect(paragraph.blockType).toBe('Paragraph'); + if (paragraph.blockType === 'Paragraph') { + expect(paragraph.decorator?.tagName).toBe('h1'); + expect(paragraph.segments.filter(segment => segment.segmentType === 'Text')).toEqual([ + { + segmentType: 'Text', + text: 'existing ', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 'Heading', + format: { fontWeight: 'bold', fontSize: '2em' }, + }, + ]); + } plugin.dispose(); }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts index 94bb0aee2e9..876b71bcc54 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -37,6 +37,15 @@ export interface MergeModelOption { | 'preferSource' | 'preferTarget'; + /** + * Preserve source paragraph decorators, including heading tags and sizes, when + * using 'keepSourceEmphasisFormat'. Decorator formatting takes precedence over + * target formatting on source segments. + * Also include target paragraph decorator and segment formatting in the inherited format. + * @default false + */ + preserveParagraphDecorator?: boolean; + /** * Whether to add a paragraph after the merged content. */