From 6f57f45208a236c534fb325870b65e38fd82cf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 23 Jun 2026 19:42:50 -0300 Subject: [PATCH 1/7] markdown --- .../editorOptions/EditorOptionsPlugin.ts | 1 + .../sidePane/editorOptions/Plugins.tsx | 8 + .../editorOptions/codes/MarkdownPasteCode.ts | 1 + demo/scripts/utils/readClipboardData.ts | 5 +- .../corePlugin/copyPaste/CopyPastePlugin.ts | 3 +- .../domUtils/event/extractClipboardItems.ts | 5 +- .../lib/plugins/MarkdownPasteOptions.ts | 6 + .../lib/plugins/MarkdownPastePlugin.ts | 59 ++++- .../test/plugins/MarkdownPastePluginTest.ts | 250 +++++++++++++++++- 9 files changed, 320 insertions(+), 18 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 14fd696450c9..c0c09e7298a8 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -62,6 +62,7 @@ const initialState: OptionState = { }, markdownPasteOptions: { autoConversion: false, + undoConversion: false, }, editPluginOptions: { handleTabKey: { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 3ff2d26fb772..792c14bc2cad 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -119,6 +119,7 @@ export class Plugins extends PluginsBase { private markdownStrikethrough = React.createRef(); private markdownCode = React.createRef(); private markdownPasteAutoConversion = React.createRef(); + private markdownPasteUndoConversion = React.createRef(); private linkTitle = React.createRef(); private disableSideResize = React.createRef(); @@ -337,6 +338,13 @@ export class Plugins extends PluginsBase { (state, value) => (state.markdownPasteOptions.autoConversion = value) )} + {this.renderCheckBox( + 'Undo auto-converted markdown', + this.markdownPasteUndoConversion, + this.props.state.markdownPasteOptions.undoConversion, + (state, value) => + (state.markdownPasteOptions.undoConversion = value) + )} )} {this.renderPluginItem( diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownPasteCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownPasteCode.ts index afe6e58b46d2..2933e01a45ca 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownPasteCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownPasteCode.ts @@ -9,6 +9,7 @@ export class MarkdownPasteCode extends CodeElement { getCode() { return `new roosterjs.MarkdownPastePlugin({ autoConversion: ${this.markdownPasteOptions.autoConversion}, + undoConversion: ${this.markdownPasteOptions.undoConversion}, })`; } } diff --git a/demo/scripts/utils/readClipboardData.ts b/demo/scripts/utils/readClipboardData.ts index 762f6b365546..8a875b83dcbb 100644 --- a/demo/scripts/utils/readClipboardData.ts +++ b/demo/scripts/utils/readClipboardData.ts @@ -21,7 +21,10 @@ export async function readClipboardData(doc: Document): Promise { event.preventDefault(); extractClipboardItems( toArray(dataTransfer!.items), - this.state.allowedCustomPasteType + this.state.allowedCustomPasteType, + true /* isPasteNative */ ).then((clipboardData: ClipboardData) => { if (!editor.isDisposed()) { paste(editor, clipboardData, this.state.defaultPasteType); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/event/extractClipboardItems.ts b/packages/roosterjs-content-model-dom/lib/domUtils/event/extractClipboardItems.ts index 5e6f8af9824f..13a935c4ae51 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/event/extractClipboardItems.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/event/extractClipboardItems.ts @@ -19,7 +19,8 @@ const ContentHandlers: { */ export function extractClipboardItems( items: DataTransferItem[], - allowedCustomPasteType?: string[] + allowedCustomPasteType?: string[], + isPasteNative?: boolean ): Promise { const data: ClipboardData = { types: [], @@ -28,7 +29,7 @@ export function extractClipboardItems( files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: !!isPasteNative, }; return Promise.all( diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPasteOptions.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPasteOptions.ts index c03b34c28f7f..4aa8b9353f55 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPasteOptions.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPasteOptions.ts @@ -9,4 +9,10 @@ export interface MarkdownPasteOptions { * @default false */ autoConversion: boolean; + + /** + * When true, the plugin will undo the markdown conversion when the user undoes the action. + * @default false + */ + undoConversion: boolean; } diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index 5e51fe3c4a49..f83f41bc5ffb 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -1,11 +1,22 @@ -import { contentModelToDom, createModelToDomContext } from 'roosterjs-content-model-dom'; import { convertMarkdownToContentModel } from '../markdownToModel/convertMarkdownToContentModel'; import { isPastedContentMarkdown } from '../publicApi/isPastedContentMarkdown'; +import { + contentModelToDom, + createModelToDomContext, + mergeModel, +} from 'roosterjs-content-model-dom'; + import type { MarkdownPasteOptions } from './MarkdownPasteOptions'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; +import type { + ClipboardData, + EditorPlugin, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; const DefaultOptions: MarkdownPasteOptions = { autoConversion: false, + undoConversion: false, }; /** @@ -58,14 +69,46 @@ export class MarkdownPastePlugin implements EditorPlugin { * @param event The event to handle: */ onPluginEvent(event: PluginEvent) { - if (!this.editor || event.eventType != 'beforePaste') { + if (!this.editor) { return; } - - const shouldConvert = event.pasteType === 'asMarkdown' || this.options.autoConversion; - - if (shouldConvert && isPastedContentMarkdown(this.editor, event.clipboardData)) { - convertPastedTextToMarkdown(this.editor, event.fragment, event.clipboardData.text); + if ( + event.eventType == 'contentChanged' && + event.source == 'Paste' && + this.options.autoConversion + ) { + const clipboardData = event.data as ClipboardData; + const shouldConvert = this.options.autoConversion && clipboardData.pasteNativeEvent; + if ( + shouldConvert && + isPastedContentMarkdown(this.editor, clipboardData) && + clipboardData.modelBeforePaste + ) { + mergeModel( + clipboardData.modelBeforePaste, + convertMarkdownToContentModel(clipboardData.text, { emptyLine: 'merge' }) + ); + if (this.options.undoConversion) { + this.editor.takeSnapshot(); + } + this.editor.formatContentModel( + model => { + if (!clipboardData.modelBeforePaste) { + return false; + } + model.blocks = clipboardData.modelBeforePaste.blocks; + return true; + }, + { + apiName: 'MarkdownConversion', + } + ); + } + } else if (event.eventType == 'beforePaste' && !event.clipboardData.pasteNativeEvent) { + const shouldConvert = event.pasteType === 'asMarkdown'; + if (shouldConvert && isPastedContentMarkdown(this.editor, event.clipboardData)) { + convertPastedTextToMarkdown(this.editor, event.fragment, event.clipboardData.text); + } } } } diff --git a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts index b5ecaa1fb3b6..5833f2d64e21 100644 --- a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts +++ b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts @@ -2,7 +2,10 @@ import { MarkdownPastePlugin } from '../../lib/plugins/MarkdownPastePlugin'; import type { BeforePasteEvent, ClipboardData, + ContentChangedEvent, + ContentModelDocument, DOMCreator, + FormatContentModelOptions, IEditor, PasteType, PluginEvent, @@ -12,23 +15,32 @@ describe('MarkdownPastePlugin', () => { let doc: Document; let trustedHTMLHandler: DOMCreator; let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let takeSnapshotSpy: jasmine.Spy; beforeEach(() => { doc = document.implementation.createHTMLDocument('test'); trustedHTMLHandler = { htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), }; + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); editor = ({ getDocument: () => doc, getDOMCreator: () => trustedHTMLHandler, + formatContentModel: formatContentModelSpy, + takeSnapshot: takeSnapshotSpy, } as unknown) as IEditor; }); - function createPlugin(autoConversion?: boolean): MarkdownPastePlugin { + function createPlugin(autoConversion?: boolean, undoConversion?: boolean): MarkdownPastePlugin { const plugin = - autoConversion === undefined + autoConversion === undefined && undoConversion === undefined ? new MarkdownPastePlugin() - : new MarkdownPastePlugin({ autoConversion }); + : new MarkdownPastePlugin({ + autoConversion: !!autoConversion, + undoConversion: !!undoConversion, + }); plugin.initialize(editor); return plugin; } @@ -50,6 +62,39 @@ describe('MarkdownPastePlugin', () => { } as unknown) as BeforePasteEvent; } + function createEmptyModel(): ContentModelDocument { + return { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }; + } + + function createContentChangedPasteEvent( + clipboardData: Partial + ): ContentChangedEvent { + return ({ + eventType: 'contentChanged', + source: 'Paste', + data: clipboardData as ClipboardData, + } as unknown) as ContentChangedEvent; + } + it('has the expected name', () => { const plugin = createPlugin(); expect(plugin.getName()).toBe('MarkdownPaste'); @@ -95,13 +140,206 @@ describe('MarkdownPastePlugin', () => { plugin.dispose(); }); - it('converts markdown on normal paste when autoConversion is true', () => { + it('does not run the beforePaste conversion when the paste came from a native event', () => { + const plugin = createPlugin(); + const event = createEvent( + { text: '# Heading', rawHtml: '', pasteNativeEvent: true }, + 'asMarkdown' + ); + + plugin.onPluginEvent(event); + + expect(event.fragment.querySelector('h1')).toBeNull(); + plugin.dispose(); + }); + + it('converts markdown on contentChanged Paste when autoConversion is true', () => { const plugin = createPlugin(true /*autoConversion*/); - const event = createEvent({ text: '# Heading', rawHtml: '' }, 'normal'); + const modelBeforePaste = createEmptyModel(); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste, + }); plugin.onPluginEvent(event); - expect(event.fragment.querySelector('h1')).not.toBeNull(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + const [callback, options] = formatContentModelSpy.calls.mostRecent().args as [ + (model: ContentModelDocument) => boolean, + FormatContentModelOptions + ]; + expect(options).toEqual({ apiName: 'MarkdownConversion' }); + + const target = createEmptyModel(); + expect(callback(target)).toBe(true); + expect(target.blocks).toBe(modelBeforePaste.blocks); + expect(modelBeforePaste.blocks.length).toBeGreaterThan(0); + plugin.dispose(); + }); + + it('merges converted markdown into a modelBeforePaste that already has content', () => { + const plugin = createPlugin(true /*autoConversion*/); + const modelBeforePaste: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'existing ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }; + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste, + }); + + plugin.onPluginEvent(event); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + + // The pre-existing text segment should still be present after merging + const firstBlock = modelBeforePaste.blocks[0]; + expect(firstBlock.blockType).toBe('Paragraph'); + if (firstBlock.blockType === 'Paragraph') { + const hasExistingText = firstBlock.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 = modelBeforePaste.blocks.some( + b => b.blockType === 'Paragraph' && b.decorator?.tagName === 'h1' + ); + expect(hasHeadingBlock).toBe(true); + + // The callback should swap the formatted model's blocks for the merged ones + const [callback] = formatContentModelSpy.calls.mostRecent().args as [ + (model: ContentModelDocument) => boolean, + FormatContentModelOptions + ]; + const target = createEmptyModel(); + expect(callback(target)).toBe(true); + expect(target.blocks).toBe(modelBeforePaste.blocks); + + plugin.dispose(); + }); + + it('does not convert on contentChanged Paste when pasteNativeEvent is missing', () => { + const plugin = createPlugin(true /*autoConversion*/); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + modelBeforePaste: createEmptyModel(), + }); + + plugin.onPluginEvent(event); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + plugin.dispose(); + }); + + it('does not convert on contentChanged Paste when modelBeforePaste is missing', () => { + const plugin = createPlugin(true /*autoConversion*/); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + }); + + plugin.onPluginEvent(event); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + plugin.dispose(); + }); + + it('does not convert non-markdown content on contentChanged Paste when autoConversion is true', () => { + const plugin = createPlugin(true /*autoConversion*/); + const event = createContentChangedPasteEvent({ + text: 'just plain text', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste: createEmptyModel(), + }); + + plugin.onPluginEvent(event); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + plugin.dispose(); + }); + + it('does not convert on contentChanged Paste when autoConversion is false', () => { + const plugin = createPlugin(false /*autoConversion*/); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste: createEmptyModel(), + }); + + plugin.onPluginEvent(event); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + plugin.dispose(); + }); + + it('takes an undo snapshot when undoConversion is true', () => { + const plugin = createPlugin(true /*autoConversion*/, true /*undoConversion*/); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste: createEmptyModel(), + }); + + plugin.onPluginEvent(event); + + expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + plugin.dispose(); + }); + + it('does not take an undo snapshot when undoConversion is false', () => { + const plugin = createPlugin(true /*autoConversion*/, false /*undoConversion*/); + const event = createContentChangedPasteEvent({ + text: '# Heading', + rawHtml: '', + customValues: {}, + pasteNativeEvent: true, + modelBeforePaste: createEmptyModel(), + }); + + plugin.onPluginEvent(event); + + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); plugin.dispose(); }); From 066004e7dc5b8df3d627bb1bcb4ac42faf0ee181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 23 Jun 2026 19:58:27 -0300 Subject: [PATCH 2/7] markdown fix test --- .../copyPaste/CopyPastePluginTest.ts | 24 ++++++---- .../event/extractClipboardItemsTest.ts | 44 +++++++++++++------ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts index 1ee6ccaa9845..fd2562259940 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts @@ -995,7 +995,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, undefined); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1025,7 +1026,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, undefined); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1056,7 +1058,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, 'mergeFormat'); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1087,7 +1090,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, 'asImage'); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1118,7 +1122,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, 'asPlainText'); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1149,7 +1154,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, 'normal'); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1179,7 +1185,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).not.toHaveBeenCalled(); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); @@ -1210,7 +1217,8 @@ describe('CopyPastePlugin |', () => { expect(pasteSpy).toHaveBeenCalledWith(editor, clipboardData, cb); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), - allowedCustomPasteType + allowedCustomPasteType, + true ); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/event/extractClipboardItemsTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/event/extractClipboardItemsTest.ts index 585a63b8c915..f6a52802a5a5 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/event/extractClipboardItemsTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/event/extractClipboardItemsTest.ts @@ -48,7 +48,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -61,7 +61,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -75,7 +75,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: html, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -89,7 +89,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -106,7 +106,7 @@ describe('extractClipboardItems', () => { imageDataUri: `data:${type};base64,${stringValue}`, rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -122,7 +122,7 @@ describe('extractClipboardItems', () => { files: [file], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -143,7 +143,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -165,7 +165,7 @@ describe('extractClipboardItems', () => { files: [pdfFile, textFile], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -192,7 +192,7 @@ describe('extractClipboardItems', () => { imageDataUri: `data:${imageType};base64,${stringValue1}`, rawHtml: html, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -230,7 +230,7 @@ describe('extractClipboardItems', () => { imageDataUri: `data:${imageType};base64,${stringValue1}`, rawHtml: html, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -249,7 +249,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: html, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -275,7 +275,7 @@ describe('extractClipboardItems', () => { customValues: { known: customInput, }, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -289,7 +289,7 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, - pasteNativeEvent: true, + pasteNativeEvent: false, }); }); @@ -305,6 +305,24 @@ describe('extractClipboardItems', () => { files: [], rawHtml: null, customValues: {}, + pasteNativeEvent: false, + }); + }); + + it('sets pasteNativeEvent to true when isPasteNative is true', async () => { + const text = 'This is a test'; + const clipboardData = await extractClipboardItems( + [createStringItem('text/plain', text)], + undefined, + true + ); + expect(clipboardData).toEqual({ + types: ['text/plain'], + text: text, + image: null, + files: [], + rawHtml: null, + customValues: {}, pasteNativeEvent: true, }); }); From 139083d9530d712f6a7c2884b21ae08fe73f9596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 24 Jun 2026 11:00:17 -0300 Subject: [PATCH 3/7] nit --- .../lib/plugins/MarkdownPastePlugin.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index f83f41bc5ffb..758caa627130 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -4,8 +4,8 @@ import { contentModelToDom, createModelToDomContext, mergeModel, + ChangeSource, } from 'roosterjs-content-model-dom'; - import type { MarkdownPasteOptions } from './MarkdownPasteOptions'; import type { ClipboardData, @@ -73,8 +73,8 @@ export class MarkdownPastePlugin implements EditorPlugin { return; } if ( - event.eventType == 'contentChanged' && - event.source == 'Paste' && + event.eventType === 'contentChanged' && + event.source === ChangeSource.Paste && this.options.autoConversion ) { const clipboardData = event.data as ClipboardData; From ae0cfcafcf69fb5d62dda606f4a72353a7fb2086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 24 Jun 2026 11:00:53 -0300 Subject: [PATCH 4/7] nit --- .../lib/plugins/MarkdownPastePlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index 758caa627130..2b3d12eaabce 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -104,7 +104,7 @@ export class MarkdownPastePlugin implements EditorPlugin { } ); } - } else if (event.eventType == 'beforePaste' && !event.clipboardData.pasteNativeEvent) { + } else if (event.eventType === 'beforePaste' && !event.clipboardData.pasteNativeEvent) { const shouldConvert = event.pasteType === 'asMarkdown'; if (shouldConvert && isPastedContentMarkdown(this.editor, event.clipboardData)) { convertPastedTextToMarkdown(this.editor, event.fragment, event.clipboardData.text); From 2c4571baecacdbb363a793b8c28cabc69aef22dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 24 Jun 2026 15:09:03 -0300 Subject: [PATCH 5/7] fixes --- .../lib/plugins/MarkdownPastePlugin.ts | 14 +++--- .../test/plugins/MarkdownPastePluginTest.ts | 43 ++++++++++--------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index 2b3d12eaabce..f7faeb122434 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -1,10 +1,12 @@ import { convertMarkdownToContentModel } from '../markdownToModel/convertMarkdownToContentModel'; import { isPastedContentMarkdown } from '../publicApi/isPastedContentMarkdown'; + import { contentModelToDom, createModelToDomContext, mergeModel, ChangeSource, + cloneModel, } from 'roosterjs-content-model-dom'; import type { MarkdownPasteOptions } from './MarkdownPasteOptions'; import type { @@ -75,7 +77,8 @@ export class MarkdownPastePlugin implements EditorPlugin { if ( event.eventType === 'contentChanged' && event.source === ChangeSource.Paste && - this.options.autoConversion + this.options.autoConversion && + event.data ) { const clipboardData = event.data as ClipboardData; const shouldConvert = this.options.autoConversion && clipboardData.pasteNativeEvent; @@ -84,8 +87,9 @@ export class MarkdownPastePlugin implements EditorPlugin { isPastedContentMarkdown(this.editor, clipboardData) && clipboardData.modelBeforePaste ) { + const modelBeforePaste = cloneModel(clipboardData.modelBeforePaste); mergeModel( - clipboardData.modelBeforePaste, + modelBeforePaste, convertMarkdownToContentModel(clipboardData.text, { emptyLine: 'merge' }) ); if (this.options.undoConversion) { @@ -93,14 +97,12 @@ export class MarkdownPastePlugin implements EditorPlugin { } this.editor.formatContentModel( model => { - if (!clipboardData.modelBeforePaste) { - return false; - } - model.blocks = clipboardData.modelBeforePaste.blocks; + model.blocks = modelBeforePaste.blocks; return true; }, { apiName: 'MarkdownConversion', + scrollCaretIntoView: true, } ); } diff --git a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts index 5833f2d64e21..adb7388033db 100644 --- a/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts +++ b/packages/roosterjs-content-model-markdown/test/plugins/MarkdownPastePluginTest.ts @@ -171,12 +171,15 @@ describe('MarkdownPastePlugin', () => { (model: ContentModelDocument) => boolean, FormatContentModelOptions ]; - expect(options).toEqual({ apiName: 'MarkdownConversion' }); + expect(options).toEqual({ apiName: 'MarkdownConversion', scrollCaretIntoView: true }); const target = createEmptyModel(); expect(callback(target)).toBe(true); - expect(target.blocks).toBe(modelBeforePaste.blocks); - expect(modelBeforePaste.blocks.length).toBeGreaterThan(0); + // 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); plugin.dispose(); }); @@ -219,30 +222,28 @@ describe('MarkdownPastePlugin', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - // The pre-existing text segment should still be present after merging - const firstBlock = modelBeforePaste.blocks[0]; - expect(firstBlock.blockType).toBe('Paragraph'); - if (firstBlock.blockType === 'Paragraph') { - const hasExistingText = firstBlock.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 = modelBeforePaste.blocks.some( - b => b.blockType === 'Paragraph' && b.decorator?.tagName === 'h1' - ); - expect(hasHeadingBlock).toBe(true); - - // The callback should swap the formatted model's blocks for the merged ones + // 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 ]; const target = createEmptyModel(); expect(callback(target)).toBe(true); - expect(target.blocks).toBe(modelBeforePaste.blocks); + + // 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); plugin.dispose(); }); From fa32ce45b83cc6377fa055da41193d1be6583933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 25 Jun 2026 11:40:13 -0300 Subject: [PATCH 6/7] verifdy clipboard data --- .../lib/plugins/MarkdownPastePlugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index f7faeb122434..c57f6d44bf1f 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -81,7 +81,8 @@ export class MarkdownPastePlugin implements EditorPlugin { event.data ) { const clipboardData = event.data as ClipboardData; - const shouldConvert = this.options.autoConversion && clipboardData.pasteNativeEvent; + const shouldConvert = + clipboardData && clipboardData.pasteNativeEvent && this.options.autoConversion; if ( shouldConvert && isPastedContentMarkdown(this.editor, clipboardData) && From 0bb5831cb0b76efd6c01c7f90b4c526c57f071d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 25 Jun 2026 16:20:30 -0300 Subject: [PATCH 7/7] cloneModelForPaste --- .../lib/command/paste/mergePasteContent.ts | 15 +-------------- .../lib/command/paste/paste.ts | 3 ++- .../test/command/paste/mergePasteContentTest.ts | 7 ++----- packages/roosterjs-content-model-dom/lib/index.ts | 2 +- .../lib/modelApi/editing/cloneModel.ts | 13 +++++++++++++ .../lib/plugins/MarkdownPastePlugin.ts | 5 ++--- 6 files changed, 21 insertions(+), 24 deletions(-) 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 9bac8b393fae..9b80f37aef2e 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -2,37 +2,24 @@ import { createDomToModelContextForSanitizing } from '../createModelFromHtml/cre import { ChangeSource, EmptySegmentFormat, - cloneModel, domToContentModel, getSegmentTextFormat, getSelectedSegments, mergeModel, + cloneModelForPaste, } from 'roosterjs-content-model-dom'; import type { BeforePasteEvent, - 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), -}; - -/** - * @internal - */ -export function cloneModelForPaste(model: ReadonlyContentModelDocument) { - return cloneModel(model, CloneOption); -} - /** * @internal */ 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 977c34c9b10b..e348910e4f5f 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/paste.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/paste.ts @@ -1,8 +1,9 @@ import { cleanHtmlComments } from './cleanHtmlComments'; -import { cloneModelForPaste, mergePasteContent } from './mergePasteContent'; +import { cloneModelForPaste } from 'roosterjs-content-model-dom'; import { convertInlineCss } from '../createModelFromHtml/convertInlineCss'; import { createPasteFragment } from './createPasteFragment'; import { generatePasteOptionFromPlugins } from './generatePasteOptionFromPlugins'; +import { mergePasteContent } from './mergePasteContent'; import { retrieveHtmlInfo } from './retrieveHtmlInfo'; import type { PasteTypeOrGetter, 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 709edcb2a807..d56a2b01e55c 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -1890,7 +1890,7 @@ describe('mergePasteContent', () => { document.body ); const div = document.createElement('div'); - const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callThrough(); + const cloneModelSpy = spyOn(cloneModel, 'cloneModelForPaste').and.callThrough(); sourceModel = { blockGroupType: 'Document', @@ -1973,7 +1973,7 @@ describe('mergePasteContent', () => { document.body ); const div = document.createElement('div'); - const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callThrough(); + const cloneModelSpy = spyOn(cloneModel, 'cloneModelForPaste').and.callThrough(); sourceModel = { blockGroupType: 'Document', @@ -2042,8 +2042,5 @@ describe('mergePasteContent', () => { format: {}, }); expect(cloneModelSpy).toHaveBeenCalledTimes(1); - expect(cloneModelSpy).toHaveBeenCalledWith(modelBeforePaste, { - includeCachedElement: jasmine.anything(), - } as any); }); }); diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index df09746614b0..75f0b7c808d8 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -149,7 +149,7 @@ export { hasSelectionInSegment } from './modelApi/selection/hasSelectionInSegmen export { hasSelectionInBlockGroup } from './modelApi/selection/hasSelectionInBlockGroup'; export { setSelection } from './modelApi/selection/setSelection'; -export { cloneModel } from './modelApi/editing/cloneModel'; +export { cloneModel, cloneModelForPaste } from './modelApi/editing/cloneModel'; export { mergeModel } from './modelApi/editing/mergeModel'; export { deleteSelection } from './modelApi/editing/deleteSelection'; export { deleteSegment } from './modelApi/editing/deleteSegment'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts index b874b04a9731..1a8183e0183f 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts @@ -48,6 +48,19 @@ import type { ReadonlyContentModelText, } from 'roosterjs-content-model-types'; +const CloneOption: CloneModelOptions = { + includeCachedElement: (node, type) => (type == 'cache' ? undefined : node), +}; + +/** + * Clone a content model for paste operations, ensuring that cached elements are handled appropriately. + * @param model The content model to clone + * @returns A cloned content model suitable for paste operations + */ +export function cloneModelForPaste(model: ReadonlyContentModelDocument) { + return cloneModel(model, CloneOption); +} + /** * Clone a content model * @param model The content model to clone diff --git a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts index c57f6d44bf1f..a7968332bfda 100644 --- a/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts +++ b/packages/roosterjs-content-model-markdown/lib/plugins/MarkdownPastePlugin.ts @@ -1,12 +1,11 @@ import { convertMarkdownToContentModel } from '../markdownToModel/convertMarkdownToContentModel'; import { isPastedContentMarkdown } from '../publicApi/isPastedContentMarkdown'; - import { contentModelToDom, createModelToDomContext, mergeModel, ChangeSource, - cloneModel, + cloneModelForPaste, } from 'roosterjs-content-model-dom'; import type { MarkdownPasteOptions } from './MarkdownPasteOptions'; import type { @@ -88,7 +87,7 @@ export class MarkdownPastePlugin implements EditorPlugin { isPastedContentMarkdown(this.editor, clipboardData) && clipboardData.modelBeforePaste ) { - const modelBeforePaste = cloneModel(clipboardData.modelBeforePaste); + const modelBeforePaste = cloneModelForPaste(clipboardData.modelBeforePaste); mergeModel( modelBeforePaste, convertMarkdownToContentModel(clipboardData.text, { emptyLine: 'merge' })