From ce4298ed629df213283f22c12bfdf164005e4737 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 3 Dec 2024 18:00:32 -0300 Subject: [PATCH 1/8] refactor trustedHTML --- .../createModelFromHtml.ts | 5 +-- .../lib/command/paste/paste.ts | 13 +++---- .../restoreSnapshotHTML.ts | 5 +-- .../lib/editor/Editor.ts | 16 +++++++- .../lib/editor/core/createEditorCore.ts | 15 +++---- .../lib/utils/domCreator.ts | 37 ++++++++++++++++++ .../restoreSnapshotHTMLTest.ts | 16 +++++--- .../test/editor/core/createEditorCoreTest.ts | 12 +++--- .../test/utils/domCreatorTest.ts | 39 +++++++++++++++++++ .../Excel/processPastedContentFromExcel.ts | 10 ++--- .../lib/paste/PastePlugin.ts | 6 +-- .../processPastedContentFromPowerPoint.ts | 9 ++--- .../lib/paste/WordDesktop/getStyleMetadata.ts | 9 ++--- .../processPastedContentFromWordDesktop.ts | 8 ++-- .../test/paste/ContentModelPastePluginTest.ts | 16 +++++--- .../test/paste/getStyleMetadataTest.ts | 6 ++- .../processPastedContentFromExcelTest.ts | 9 +++-- .../processPastedContentFromPowerPointTest.ts | 10 ++--- ...processPastedContentFromWordDesktopTest.ts | 6 ++- .../lib/editor/EditorCore.ts | 17 ++++++-- .../lib/editor/IEditor.ts | 13 ++++++- .../lib/index.ts | 6 ++- .../lib/parameter/TrustedHTMLHandler.ts | 14 ++++++- .../lib/editor/EditorAdapter.ts | 6 +-- 24 files changed, 209 insertions(+), 94 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/utils/domCreator.ts create mode 100644 packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts index 68b34cbc3a9..1505427ae3c 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts @@ -1,6 +1,7 @@ import { convertInlineCss, retrieveCssRules } from './convertInlineCss'; import { createDomToModelContextForSanitizing } from './createDomToModelContextForSanitizing'; import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-content-model-dom'; +import { domCreator } from '../../utils/domCreator'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -21,9 +22,7 @@ export function createModelFromHtml( trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat ): ContentModelDocument { - const doc = html - ? new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html') - : null; + const doc = html ? domCreator(trustedHTMLHandler).htmlToDOM(html) : null; if (doc?.body) { const context = createDomToModelContextForSanitizing( 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 69cd08340d5..1028bc20765 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/paste.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/paste.ts @@ -6,8 +6,8 @@ import { retrieveHtmlInfo } from './retrieveHtmlInfo'; import type { PasteTypeOrGetter, ClipboardData, - TrustedHTMLHandler, IEditor, + DOMCreator, } from 'roosterjs-content-model-types'; /** @@ -22,9 +22,6 @@ export function paste( pasteTypeOrGetter: PasteTypeOrGetter = 'normal' ) { editor.focus(); - - const trustedHTMLHandler = editor.getTrustedHTMLHandler(); - if (!clipboardData.modelBeforePaste) { editor.formatContentModel(model => { clipboardData.modelBeforePaste = cloneModelForPaste(model); @@ -34,7 +31,7 @@ export function paste( } // 1. Prepare variables - const doc = createDOMFromHtml(clipboardData.rawHtml, trustedHTMLHandler); + const doc = createDOMFromHtml(clipboardData.rawHtml, editor.getDOMCreator()); const pasteType = typeof pasteTypeOrGetter == 'function' ? pasteTypeOrGetter(doc, clipboardData) @@ -50,7 +47,7 @@ export function paste( pasteType, (clipboardData.rawHtml == clipboardData.html ? doc - : createDOMFromHtml(clipboardData.html, trustedHTMLHandler) + : createDOMFromHtml(clipboardData.html, editor.getDOMCreator()) )?.body ); @@ -72,7 +69,7 @@ export function paste( function createDOMFromHtml( html: string | null | undefined, - trustedHTMLHandler: TrustedHTMLHandler + domCreator: DOMCreator ): Document | null { - return html ? new DOMParser().parseFromString(trustedHTMLHandler(html), 'text/html') : null; + return html ? domCreator.htmlToDOM(html) : null; } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts index 96556c0e296..b8f8a76b34d 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts @@ -18,10 +18,7 @@ export function restoreSnapshotHTML(core: EditorCore, snapshot: Snapshot) { } = core; let refNode: Node | null = physicalRoot.firstChild; - const body = new DOMParser().parseFromString( - core.trustedHTMLHandler?.(snapshot.html) ?? snapshot.html, - 'text/html' - ).body; + const body = core.domCreator.htmlToDOM(snapshot.html).body; for (let currentNode = body.firstChild; currentNode; ) { const next = currentNode.nextSibling; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 6977040e9fa..84d4d653e8e 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -25,13 +25,14 @@ import type { SnapshotsManager, EditorCore, EditorOptions, - TrustedHTMLHandler, Rect, EntityState, CachedElementHandler, DomToModelOptionForCreateModel, AnnounceData, ExperimentalFeature, + LegacyTrustedHTMLHandler, + DOMCreator, } from 'roosterjs-content-model-types'; /** @@ -359,15 +360,26 @@ export class Editor implements IEditor { } /** + * @deprecated * Get a function to convert HTML string to trusted HTML string. * By default it will just return the input HTML directly. To override this behavior, * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types */ - getTrustedHTMLHandler(): TrustedHTMLHandler { + getTrustedHTMLHandler(): LegacyTrustedHTMLHandler { return this.getCore().trustedHTMLHandler; } + /** + * Get a function to convert HTML string to a trust Document. + * By default it will just convert the original HTML string into a Document object directly. + * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + getDOMCreator(): DOMCreator { + return this.getCore().domCreator; + } + /** * Get the scroll container of the editor */ diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 886b03dc7ee..daabda5152d 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -3,6 +3,7 @@ import { createDarkColorHandler } from './DarkColorHandlerImpl'; import { createDOMHelper } from './DOMHelperImpl'; import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; import { createEditorCorePlugins } from '../../corePlugin/createEditorCorePlugins'; +import { defaultTrustHtmlHandler, domCreator, isDOMCreator } from '../../utils/domCreator'; import type { EditorEnvironment, PluginState, @@ -18,7 +19,6 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); - return { physicalRoot: contentDiv, logicalRoot: contentDiv, @@ -43,7 +43,11 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti options.knownColors, options.generateColorKey ), - trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, + trustedHTMLHandler: + options.trustedHTMLHandler && !isDOMCreator(options.trustedHTMLHandler) + ? options.trustedHTMLHandler + : defaultTrustHtmlHandler, + domCreator: domCreator(options.trustedHTMLHandler), domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, @@ -90,13 +94,6 @@ function getIsMobileOrTablet(userAgent: string) { return false; } -/** - * @internal export for test only - */ -export function defaultTrustHtmlHandler(html: string) { - return html; -} - function getPluginState(corePlugins: EditorCorePlugins): PluginState { return { domEvent: corePlugins.domEvent.getState(), diff --git a/packages/roosterjs-content-model-core/lib/utils/domCreator.ts b/packages/roosterjs-content-model-core/lib/utils/domCreator.ts new file mode 100644 index 00000000000..a45f509e027 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/utils/domCreator.ts @@ -0,0 +1,37 @@ +import type { + DOMCreator, + LegacyTrustedHTMLHandler, + TrustedHTMLHandler, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function domCreator(trustedHTMLHandler?: TrustedHTMLHandler): DOMCreator { + return trustedHTMLHandler && isDOMCreator(trustedHTMLHandler) + ? trustedHTMLHandler + : trustedHTMLHandlerToDOMCreator(trustedHTMLHandler as LegacyTrustedHTMLHandler); +} + +/** + * @internal + */ +export function isDOMCreator( + trustedHTMLHandler: TrustedHTMLHandler +): trustedHTMLHandler is DOMCreator { + return typeof (trustedHTMLHandler as DOMCreator).htmlToDOM === 'function'; +} + +/** + * @internal + */ +export const defaultTrustHtmlHandler: LegacyTrustedHTMLHandler = (html: string) => { + return html; +}; + +function trustedHTMLHandlerToDOMCreator(trustedHTMLHandler?: LegacyTrustedHTMLHandler): DOMCreator { + const handler = trustedHTMLHandler || defaultTrustHtmlHandler; + return { + htmlToDOM: (html: string) => new DOMParser().parseFromString(handler(html), 'text/html'), + }; +} diff --git a/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts index a238d818a6b..6bccf1c0b66 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts @@ -1,7 +1,11 @@ -import { EditorCore, Snapshot } from 'roosterjs-content-model-types'; +import { DOMCreator, EditorCore, Snapshot } from 'roosterjs-content-model-types'; import { restoreSnapshotHTML } from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML'; import { wrap } from 'roosterjs-content-model-dom'; +const domCreator: DOMCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +}; + describe('restoreSnapshotHTML', () => { let core: EditorCore; let div: HTMLDivElement; @@ -15,6 +19,7 @@ describe('restoreSnapshotHTML', () => { entity: { entityMap: {}, }, + domCreator: domCreator, } as any; }); @@ -39,18 +44,17 @@ describe('restoreSnapshotHTML', () => { }); it('Simple HTML, no entity, with trustHTMLHandler', () => { - const trustedHTMLHandler = jasmine - .createSpy('trustedHTMLHandler') - .and.callFake((html: string) => html + html); const snapshot: Snapshot = { html: '
test1
', } as any; - (core).trustedHTMLHandler = trustedHTMLHandler; + const htmlToDOMSpy = spyOn(core.domCreator, 'htmlToDOM').and.callFake((html: string) => + new DOMParser().parseFromString(html + html, 'text/html') + ); restoreSnapshotHTML(core, snapshot); - expect(trustedHTMLHandler).toHaveBeenCalledWith('
test1
'); + expect(htmlToDOMSpy).toHaveBeenCalledWith('
test1
'); expect(div.innerHTML).toBe('
test1
test1
'); }); diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index 197c55c1306..3731f579c9f 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -1,14 +1,11 @@ import * as createDefaultSettings from '../../../lib/editor/core/createEditorDefaultSettings'; import * as createEditorCorePlugins from '../../../lib/corePlugin/createEditorCorePlugins'; import * as DarkColorHandlerImpl from '../../../lib/editor/core/DarkColorHandlerImpl'; +import * as domCreator from '../../../lib/utils/domCreator'; import * as DOMHelperImpl from '../../../lib/editor/core/DOMHelperImpl'; import { coreApiMap } from '../../../lib/coreApi/coreApiMap'; import { EditorCore, EditorOptions } from 'roosterjs-content-model-types'; -import { - createEditorCore, - defaultTrustHtmlHandler, - getDarkColorFallback, -} from '../../../lib/editor/core/createEditorCore'; +import { createEditorCore, getDarkColorFallback } from '../../../lib/editor/core/createEditorCore'; describe('createEditorCore', () => { function createMockedPlugin(stateName: string): any { @@ -41,6 +38,7 @@ describe('createEditorCore', () => { const mockedDomToModelSettings = 'DOMTOMODEL' as any; const mockedModelToDomSettings = 'MODELTODOM' as any; const mockedDOMHelper = 'DOMHELPER' as any; + const mockedHtmlToDOM = 'HTMLTODOM' as any; beforeEach(() => { spyOn(createEditorCorePlugins, 'createEditorCorePlugins').and.returnValue(mockedPlugins); @@ -54,6 +52,7 @@ describe('createEditorCore', () => { mockedModelToDomSettings ); spyOn(DOMHelperImpl, 'createDOMHelper').and.returnValue(mockedDOMHelper); + spyOn(domCreator, 'domCreator').and.returnValue(mockedHtmlToDOM); }); function runTest( @@ -88,7 +87,8 @@ describe('createEditorCore', () => { modelToDomSettings: mockedModelToDomSettings, }, darkColorHandler: mockedDarkColorHandler, - trustedHTMLHandler: defaultTrustHtmlHandler, + trustedHTMLHandler: domCreator.defaultTrustHtmlHandler, + domCreator: mockedHtmlToDOM, cache: 'cache' as any, format: 'format' as any, copyPaste: 'copyPaste' as any, diff --git a/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts new file mode 100644 index 00000000000..943a47f5afb --- /dev/null +++ b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts @@ -0,0 +1,39 @@ +import { domCreator, isDOMCreator } from '../../lib/utils/domCreator'; + +describe('domCreator', () => { + it('isDOMCreator - True', () => { + const trustedHTMLHandler = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), + }; + expect(isDOMCreator(trustedHTMLHandler)).toBe(true); + }); + + it('isDOMCreator - False', () => { + const trustedHTMLHandler = (html: string) => html; + expect(isDOMCreator(trustedHTMLHandler)).toBe(false); + }); + + it('domCreator - isDOMCreator', () => { + const trustedHTMLHandler = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), + }; + const result = domCreator(trustedHTMLHandler); + expect(result).toEqual(trustedHTMLHandler); + }); + + it('domCreator - undefined', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.appendChild(document.createTextNode('test')); + doc.ownerDocument; + const result = domCreator(undefined).htmlToDOM('test'); + expect(result.lastChild).toEqual(doc.lastChild); + }); + + it('domCreator - trustedHTML', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.appendChild(document.createTextNode('test trusted')); + const trustedHTMLHandler = (html: string) => html + ' trusted'; + const result = domCreator(trustedHTMLHandler).htmlToDOM('test'); + expect(result.lastChild).toEqual(doc.lastChild); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts b/packages/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts index f13491b0556..5bbc167fe76 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts @@ -1,11 +1,7 @@ import { addParser } from '../utils/addParser'; import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; import { setProcessor } from '../utils/setProcessor'; -import type { - BeforePasteEvent, - ElementProcessor, - TrustedHTMLHandler, -} from 'roosterjs-content-model-types'; +import type { BeforePasteEvent, DOMCreator, ElementProcessor } from 'roosterjs-content-model-types'; const LAST_TD_END_REGEX = /<\/\s*td\s*>((?!<\/\s*tr\s*>)[\s\S])*$/i; const LAST_TR_END_REGEX = /<\/\s*tr\s*>((?!<\/\s*table\s*>)[\s\S])*$/i; @@ -21,14 +17,14 @@ const DEFAULT_BORDER_STYLE = 'solid 1px #d4d4d4'; export function processPastedContentFromExcel( event: BeforePasteEvent, - trustedHTMLHandler: TrustedHTMLHandler, + domCreator: DOMCreator, allowExcelNoBorderTable?: boolean ) { const { fragment, htmlBefore, clipboardData } = event; const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined; if (html && clipboardData.html != html) { - const doc = new DOMParser().parseFromString(trustedHTMLHandler(html), 'text/html'); + const doc = domCreator.htmlToDOM(html); moveChildNodes(fragment, doc?.body); } diff --git a/packages/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts b/packages/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts index d3a3d5fa7cb..5fea7005843 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts @@ -99,7 +99,7 @@ export class PastePlugin implements EditorPlugin { switch (pasteSource) { case 'wordDesktop': - processPastedContentFromWordDesktop(event, this.editor.getTrustedHTMLHandler()); + processPastedContentFromWordDesktop(event, this.editor.getDOMCreator()); break; case 'wacComponents': processPastedContentWacComponents(event); @@ -110,7 +110,7 @@ export class PastePlugin implements EditorPlugin { // Handle HTML copied from Excel processPastedContentFromExcel( event, - this.editor.getTrustedHTMLHandler(), + this.editor.getDOMCreator(), this.allowExcelNoBorderTable ); } @@ -121,7 +121,7 @@ export class PastePlugin implements EditorPlugin { ); break; case 'powerPointDesktop': - processPastedContentFromPowerPoint(event, this.editor.getTrustedHTMLHandler()); + processPastedContentFromPowerPoint(event, this.editor.getDOMCreator()); break; } diff --git a/packages/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts b/packages/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts index dad6ea6d2f5..2482c87758e 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts @@ -1,5 +1,5 @@ import { moveChildNodes } from 'roosterjs-content-model-dom'; -import type { BeforePasteEvent, TrustedHTMLHandler } from 'roosterjs-content-model-types'; +import type { BeforePasteEvent, DOMCreator } from 'roosterjs-content-model-types'; /** * @internal @@ -9,17 +9,14 @@ import type { BeforePasteEvent, TrustedHTMLHandler } from 'roosterjs-content-mod export function processPastedContentFromPowerPoint( event: BeforePasteEvent, - trustedHTMLHandler: TrustedHTMLHandler + domCreator: DOMCreator ) { const { fragment, clipboardData } = event; if (clipboardData.html && !clipboardData.text && clipboardData.image) { // It is possible that PowerPoint copied both image and HTML but not plain text. // We always prefer HTML if any. - const doc = new DOMParser().parseFromString( - trustedHTMLHandler(clipboardData.html), - 'text/html' - ); + const doc = domCreator.htmlToDOM(clipboardData.html); moveChildNodes(fragment, doc?.body); } diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts index 72709b51b40..114914a902d 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts @@ -1,6 +1,6 @@ import { getObjectKeys } from 'roosterjs-content-model-dom'; import type { WordMetadata } from './WordMetadata'; -import type { BeforePasteEvent } from 'roosterjs-content-model-types'; +import type { BeforePasteEvent, DOMCreator } from 'roosterjs-content-model-types'; const FORMATING_REGEX = /[\n\t'{}"]+/g; @@ -24,12 +24,9 @@ const FORMATING_REGEX = /[\n\t'{}"]+/g; * 5. Save data in record and only use the required information. * */ -export function getStyleMetadata( - ev: BeforePasteEvent, - trustedHTMLHandler: (val: string) => string -) { +export function getStyleMetadata(ev: BeforePasteEvent, domCreator: DOMCreator) { const metadataMap: Map = new Map(); - const doc = new DOMParser().parseFromString(trustedHTMLHandler(ev.htmlBefore), 'text/html'); + const doc = domCreator.htmlToDOM(ev.htmlBefore); const styles = doc.querySelectorAll('style'); styles.forEach(style => { diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 403127175cf..33f904b9935 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -11,6 +11,7 @@ import type { ContentModelBlockFormat, ContentModelListItemLevelFormat, ContentModelTableFormat, + DOMCreator, DomToModelContext, ElementProcessor, FormatParser, @@ -25,11 +26,8 @@ const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 1.2; * Handles Pasted content when source is Word Desktop * @param ev BeforePasteEvent */ -export function processPastedContentFromWordDesktop( - ev: BeforePasteEvent, - trustedHTMLHandler: (text: string) => string -) { - const metadataMap: Map = getStyleMetadata(ev, trustedHTMLHandler); +export function processPastedContentFromWordDesktop(ev: BeforePasteEvent, domCreator: DOMCreator) { + const metadataMap: Map = getStyleMetadata(ev, domCreator); setProcessor(ev.domToModelOption, 'element', wordDesktopElementProcessor(metadataMap)); addParser(ev.domToModelOption, 'block', adjustPercentileLineHeight); diff --git a/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index e78f0beb1eb..56f955ebafb 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -4,11 +4,14 @@ import * as getPasteSource from '../../lib/paste/pasteSourceValidations/getPaste import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessor from '../../lib/paste/utils/setProcessor'; import * as WacFile from '../../lib/paste/WacComponents/processPastedContentWacComponents'; -import { BeforePasteEvent, IEditor } from 'roosterjs-content-model-types'; +import { BeforePasteEvent, DOMCreator, IEditor } from 'roosterjs-content-model-types'; import { PastePlugin } from '../../lib/paste/PastePlugin'; import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; const trustedHTMLHandler = (val: string) => val; +const domCreator: DOMCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +}; const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Content Model Paste Plugin Test', () => { @@ -17,6 +20,7 @@ describe('Content Model Paste Plugin Test', () => { beforeEach(() => { editor = ({ getTrustedHTMLHandler: () => trustedHTMLHandler, + getDOMCreator: () => domCreator, } as any) as IEditor; spyOn(addParser, 'addParser').and.callThrough(); spyOn(setProcessor, 'setProcessor').and.callThrough(); @@ -72,7 +76,7 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler, + domCreator, undefined /*allowExcelNoBorderTable*/ ); expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); @@ -89,7 +93,7 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).not.toHaveBeenCalledWith( event, - trustedHTMLHandler, + domCreator, undefined /*allowExcelNoBorderTable*/ ); expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); @@ -105,7 +109,7 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler, + domCreator, undefined /*allowExcelNoBorderTable*/ ); expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -121,7 +125,7 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler, + domCreator, undefined /*allowExcelNoBorderTable*/ ); expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -137,7 +141,7 @@ describe('Content Model Paste Plugin Test', () => { expect(PowerPointFile.processPastedContentFromPowerPoint).toHaveBeenCalledWith( event, - trustedHTMLHandler + domCreator ); expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts b/packages/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts index 21e7ac1e9ae..0aae54bd19c 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts @@ -1,13 +1,17 @@ import { BeforePasteEvent } from 'roosterjs-content-model-types'; import { getStyleMetadata } from '../../lib/paste/WordDesktop/getStyleMetadata'; +const domCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +}; + describe('getStyleMetadata', () => { it('Extract metadata from style element', () => { const event = ({ htmlBefore: '', }); - const result = getStyleMetadata(event, (val: string) => val); + const result = getStyleMetadata(event, domCreator); expect(result.get('l0:level1')).toEqual({ 'mso-level-number-format': 'roman-upper', diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index 449cb65591b..42cd593c605 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -1,5 +1,5 @@ import * as PastePluginFile from '../../lib/paste/Excel/processPastedContentFromExcel'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, DOMCreator } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { processPastedContentFromExcel } from '../../lib/paste/Excel/processPastedContentFromExcel'; import { @@ -13,6 +13,9 @@ import { let div: HTMLElement; let fragment: DocumentFragment; +const domCreator: DOMCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +}; describe('processPastedContentFromExcelTest', () => { function runTest(source?: string, expected?: string, expectedModel?: ContentModelDocument) { @@ -26,7 +29,7 @@ describe('processPastedContentFromExcelTest', () => { const event = createBeforePasteEventMock(fragment); event.clipboardData.html = source; - processPastedContentFromExcel(event, (s: string) => s); + processPastedContentFromExcel(event, domCreator); const model = domToContentModel( fragment, @@ -349,7 +352,7 @@ describe('Do not run scenarios', () => { if (excelHandler) { spyOn(PastePluginFile, 'excelHandler').and.returnValue(excelHandler); } - processPastedContentFromExcel(event, (s: string) => s); + processPastedContentFromExcel(event, domCreator); // Assert while (div.firstChild) { diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts index ed7bbef3432..73fc764fb6e 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts @@ -1,10 +1,6 @@ import * as moveChildNodes from 'roosterjs-content-model-dom/lib/domUtils/moveChildNodes'; import { processPastedContentFromPowerPoint } from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; -import type { - BeforePasteEvent, - ClipboardData, - TrustedHTMLHandler, -} from 'roosterjs-content-model-types'; +import type { BeforePasteEvent, ClipboardData, DOMCreator } from 'roosterjs-content-model-types'; const getPasteEvent = (): BeforePasteEvent => { return { @@ -29,7 +25,9 @@ const getPasteEvent = (): BeforePasteEvent => { describe('processPastedContentFromPowerPointTest |', () => { let ev: BeforePasteEvent; - let trustedHTMLHandlerMock: TrustedHTMLHandler = (html: string) => html; + let trustedHTMLHandlerMock: DOMCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), + }; let image: HTMLImageElement; let doc: Document; diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 07f17b4039d..7b301c86fe8 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -9,6 +9,10 @@ import { moveChildNodes, } from 'roosterjs-content-model-dom'; +const domCreator = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +}; + describe('processPastedContentFromWordDesktopTest', () => { let div: HTMLElement; let fragment: DocumentFragment; @@ -27,7 +31,7 @@ describe('processPastedContentFromWordDesktopTest', () => { moveChildNodes(fragment, div); } const event = createBeforePasteEventMock(fragment, htmlBefore); - processPastedContentFromWordDesktop(event, (val: string) => val); + processPastedContentFromWordDesktop(event, domCreator); const model = domToContentModel( fragment, diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 42144b97f61..965ebb00722 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -17,7 +17,7 @@ import type { EditorContext } from '../context/EditorContext'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { OnNodeCreated } from '../context/ModelToDomSettings'; -import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; +import type { DOMCreator, LegacyTrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { ContentModelFormatter, @@ -361,11 +361,20 @@ export interface EditorCore extends PluginState { readonly darkColorHandler: DarkColorHandler; /** - * A handler to convert HTML string to a trust HTML string. - * By default it will just return the original HTML string directly. + * @deprecated + * @see DOMCreator + * A handler to convert HTML string to a trust string. + * By default it will just convert the original HTML string into a string directly. * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler */ - readonly trustedHTMLHandler: TrustedHTMLHandler; + readonly trustedHTMLHandler: LegacyTrustedHTMLHandler; + + /** + * A handler to convert HTML string to a trust Document. + * By default it will just convert the original HTML string into a Document object directly. + * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + */ + readonly domCreator: DOMCreator; /** * A helper class to provide DOM access APIs diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 39c5e08e7ae..b34d2f24fec 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -15,7 +15,7 @@ import type { FormatContentModelOptions, } from '../parameter/FormatContentModelOptions'; import type { DarkColorHandler } from '../context/DarkColorHandler'; -import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; +import type { DOMCreator, LegacyTrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { EntityState } from '../parameter/FormatContentModelContext'; import type { ExperimentalFeature } from './ExperimentalFeature'; @@ -193,12 +193,21 @@ export interface IEditor { hasFocus(): boolean; /** + * @deprecated use getDOMCreator instead * Get a function to convert HTML string to trusted HTML string. * By default it will just return the input HTML directly. To override this behavior, * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types */ - getTrustedHTMLHandler(): TrustedHTMLHandler; + getTrustedHTMLHandler(): LegacyTrustedHTMLHandler; + + /** + * Get a function to convert HTML string to a trust Document. + * By default it will just convert the original HTML string into a Document object directly. + * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + getDOMCreator(): DOMCreator; /** * Get the scroll container of the editor diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 215f43d77df..241660c5d01 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -424,7 +424,11 @@ export { DOMEventHandlerFunction, DOMEventRecord } from './parameter/DOMEventRec export { EdgeLinkPreview } from './parameter/EdgeLinkPreview'; export { ClipboardData } from './parameter/ClipboardData'; export { AnnounceData, KnownAnnounceStrings } from './parameter/AnnounceData'; -export { TrustedHTMLHandler } from './parameter/TrustedHTMLHandler'; +export { + TrustedHTMLHandler, + DOMCreator, + LegacyTrustedHTMLHandler, +} from './parameter/TrustedHTMLHandler'; export { Rect } from './parameter/Rect'; export { ValueSanitizer } from './parameter/ValueSanitizer'; export { DOMHelper } from './parameter/DOMHelper'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts b/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts index 05785843669..80db39130a3 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts @@ -1,4 +1,16 @@ /** * A handler type to convert HTML string to a trust HTML string */ -export type TrustedHTMLHandler = (html: string) => string; +export type LegacyTrustedHTMLHandler = (html: string) => string; + +/** + * A handler type to convert HTML string to a DOM object + */ +export interface DOMCreator { + htmlToDOM: (html: string) => Document; +} + +/** + * A handler type to convert HTML string to a trust HTML string or a DOM object + */ +export type TrustedHTMLHandler = DOMCreator | LegacyTrustedHTMLHandler; diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index b80589167e9..d6ff22bcb70 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -421,10 +421,8 @@ export class EditorAdapter extends Editor implements ILegacyEditor { insertContent(content: string, option?: InsertOption) { if (content) { const doc = this.getDocument(); - const body = new DOMParser().parseFromString( - this.getCore().trustedHTMLHandler(content), - 'text/html' - )?.body; + const body = this.getCore().domCreator.htmlToDOM(content).body; + let allNodes = body?.childNodes ? toArray(body.childNodes) : []; // If it is to insert on new line, and there are more than one node in the collection, wrap all nodes with From 87a13e999f9744c5da4d7e7a0a9d55ed8b827cdc Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 3 Dec 2024 18:04:39 -0300 Subject: [PATCH 2/8] nit --- .../roosterjs-content-model-core/test/utils/domCreatorTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts index 943a47f5afb..c3393bc4783 100644 --- a/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts +++ b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts @@ -24,7 +24,6 @@ describe('domCreator', () => { it('domCreator - undefined', () => { const doc = document.implementation.createHTMLDocument(); doc.body.appendChild(document.createTextNode('test')); - doc.ownerDocument; const result = domCreator(undefined).htmlToDOM('test'); expect(result.lastChild).toEqual(doc.lastChild); }); From cb829f2b0726354b27d42a580aa95bc8e58b95e3 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 3 Dec 2024 18:11:26 -0300 Subject: [PATCH 3/8] nit --- packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index d6ff22bcb70..082368f5489 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -421,7 +421,7 @@ export class EditorAdapter extends Editor implements ILegacyEditor { insertContent(content: string, option?: InsertOption) { if (content) { const doc = this.getDocument(); - const body = this.getCore().domCreator.htmlToDOM(content).body; + const body = this.getCore().domCreator.htmlToDOM(content)?.body; let allNodes = body?.childNodes ? toArray(body.childNodes) : []; From de05dc5ad49c3da1788f582b4122769f51bd5e60 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 5 Dec 2024 15:44:54 -0300 Subject: [PATCH 4/8] trusted HTML refactor --- .../createModelFromHtml/createModelFromHtml.ts | 4 ++-- .../lib/editor/core/createEditorCore.ts | 12 ++++++------ .../lib/utils/domCreator.ts | 9 ++++++++- .../test/editor/core/createEditorCoreTest.ts | 18 ++++++++++++------ .../test/utils/domCreatorTest.ts | 14 +++++++------- .../lib/parameter/TrustedHTMLHandler.ts | 1 + 6 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts index 1505427ae3c..714cad17fb6 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createModelFromHtml.ts @@ -1,7 +1,7 @@ import { convertInlineCss, retrieveCssRules } from './convertInlineCss'; +import { createDOMCreator } from '../../utils/domCreator'; import { createDomToModelContextForSanitizing } from './createDomToModelContextForSanitizing'; import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-content-model-dom'; -import { domCreator } from '../../utils/domCreator'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -22,7 +22,7 @@ export function createModelFromHtml( trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat ): ContentModelDocument { - const doc = html ? domCreator(trustedHTMLHandler).htmlToDOM(html) : null; + const doc = html ? createDOMCreator(trustedHTMLHandler).htmlToDOM(html) : null; if (doc?.body) { const context = createDomToModelContextForSanitizing( diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index daabda5152d..73e33595118 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -1,9 +1,9 @@ import { coreApiMap } from '../../coreApi/coreApiMap'; import { createDarkColorHandler } from './DarkColorHandlerImpl'; +import { createDOMCreator, createTrustedHTMLHandler } from '../../utils/domCreator'; import { createDOMHelper } from './DOMHelperImpl'; import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; import { createEditorCorePlugins } from '../../corePlugin/createEditorCorePlugins'; -import { defaultTrustHtmlHandler, domCreator, isDOMCreator } from '../../utils/domCreator'; import type { EditorEnvironment, PluginState, @@ -19,6 +19,9 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); + const domCreator = createDOMCreator(options.trustedHTMLHandler); + const trustedHTMLHandler = createTrustedHTMLHandler(domCreator); + return { physicalRoot: contentDiv, logicalRoot: contentDiv, @@ -43,11 +46,8 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti options.knownColors, options.generateColorKey ), - trustedHTMLHandler: - options.trustedHTMLHandler && !isDOMCreator(options.trustedHTMLHandler) - ? options.trustedHTMLHandler - : defaultTrustHtmlHandler, - domCreator: domCreator(options.trustedHTMLHandler), + trustedHTMLHandler: trustedHTMLHandler, + domCreator: domCreator, domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, diff --git a/packages/roosterjs-content-model-core/lib/utils/domCreator.ts b/packages/roosterjs-content-model-core/lib/utils/domCreator.ts index a45f509e027..d9432630d52 100644 --- a/packages/roosterjs-content-model-core/lib/utils/domCreator.ts +++ b/packages/roosterjs-content-model-core/lib/utils/domCreator.ts @@ -7,7 +7,14 @@ import type { /** * @internal */ -export function domCreator(trustedHTMLHandler?: TrustedHTMLHandler): DOMCreator { +export const createTrustedHTMLHandler = (domCreator: DOMCreator): LegacyTrustedHTMLHandler => { + return (html: string) => domCreator.htmlToDOM(html).body.innerHTML; +}; + +/** + * @internal + */ +export function createDOMCreator(trustedHTMLHandler?: TrustedHTMLHandler): DOMCreator { return trustedHTMLHandler && isDOMCreator(trustedHTMLHandler) ? trustedHTMLHandler : trustedHTMLHandlerToDOMCreator(trustedHTMLHandler as LegacyTrustedHTMLHandler); diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index 3731f579c9f..ce01f1e758c 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -4,8 +4,8 @@ import * as DarkColorHandlerImpl from '../../../lib/editor/core/DarkColorHandler import * as domCreator from '../../../lib/utils/domCreator'; import * as DOMHelperImpl from '../../../lib/editor/core/DOMHelperImpl'; import { coreApiMap } from '../../../lib/coreApi/coreApiMap'; -import { EditorCore, EditorOptions } from 'roosterjs-content-model-types'; import { createEditorCore, getDarkColorFallback } from '../../../lib/editor/core/createEditorCore'; +import { DOMCreator, EditorCore, EditorOptions } from 'roosterjs-content-model-types'; describe('createEditorCore', () => { function createMockedPlugin(stateName: string): any { @@ -38,7 +38,10 @@ describe('createEditorCore', () => { const mockedDomToModelSettings = 'DOMTOMODEL' as any; const mockedModelToDomSettings = 'MODELTODOM' as any; const mockedDOMHelper = 'DOMHELPER' as any; - const mockedHtmlToDOM = 'HTMLTODOM' as any; + const mockedDOMCreator: DOMCreator = { + htmlToDOM: mockedDOMHelper, + }; + const mockedTrustHtmlHandler = 'TRUSTED' as any; beforeEach(() => { spyOn(createEditorCorePlugins, 'createEditorCorePlugins').and.returnValue(mockedPlugins); @@ -52,7 +55,8 @@ describe('createEditorCore', () => { mockedModelToDomSettings ); spyOn(DOMHelperImpl, 'createDOMHelper').and.returnValue(mockedDOMHelper); - spyOn(domCreator, 'domCreator').and.returnValue(mockedHtmlToDOM); + spyOn(domCreator, 'createDOMCreator').and.returnValue(mockedDOMCreator); + spyOn(domCreator, 'createTrustedHTMLHandler').and.returnValue(mockedTrustHtmlHandler); }); function runTest( @@ -87,8 +91,8 @@ describe('createEditorCore', () => { modelToDomSettings: mockedModelToDomSettings, }, darkColorHandler: mockedDarkColorHandler, - trustedHTMLHandler: domCreator.defaultTrustHtmlHandler, - domCreator: mockedHtmlToDOM, + trustedHTMLHandler: mockedTrustHtmlHandler, + domCreator: mockedDOMCreator, cache: 'cache' as any, format: 'format' as any, copyPaste: 'copyPaste' as any, @@ -146,7 +150,7 @@ describe('createEditorCore', () => { const mockedPlugin1 = 'P1' as any; const mockedPlugin2 = 'P2' as any; const mockedGetDarkColor = 'DARK' as any; - const mockedTrustHtmlHandler = 'TRUST' as any; + const mockedTrustHtmlHandler = 'OPTIONAL TRUSTED' as any; const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; @@ -164,6 +168,8 @@ describe('createEditorCore', () => { onFixUpModel: mockedOnFixUpModel, } as any; + spyOn(domCreator, 'createTrustedHTMLHandler').and.returnValue(mockedTrustHtmlHandler); + runTest(mockedDiv, mockedOptions, { physicalRoot: mockedDiv, logicalRoot: mockedDiv, diff --git a/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts index c3393bc4783..48d35e60e84 100644 --- a/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts +++ b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts @@ -1,4 +1,4 @@ -import { domCreator, isDOMCreator } from '../../lib/utils/domCreator'; +import { createDOMCreator, isDOMCreator } from '../../lib/utils/domCreator'; describe('domCreator', () => { it('isDOMCreator - True', () => { @@ -13,26 +13,26 @@ describe('domCreator', () => { expect(isDOMCreator(trustedHTMLHandler)).toBe(false); }); - it('domCreator - isDOMCreator', () => { + it('createDOMCreator - isDOMCreator', () => { const trustedHTMLHandler = { htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), }; - const result = domCreator(trustedHTMLHandler); + const result = createDOMCreator(trustedHTMLHandler); expect(result).toEqual(trustedHTMLHandler); }); - it('domCreator - undefined', () => { + it('createDOMCreator - undefined', () => { const doc = document.implementation.createHTMLDocument(); doc.body.appendChild(document.createTextNode('test')); - const result = domCreator(undefined).htmlToDOM('test'); + const result = createDOMCreator(undefined).htmlToDOM('test'); expect(result.lastChild).toEqual(doc.lastChild); }); - it('domCreator - trustedHTML', () => { + it('createDOMCreator - trustedHTML', () => { const doc = document.implementation.createHTMLDocument(); doc.body.appendChild(document.createTextNode('test trusted')); const trustedHTMLHandler = (html: string) => html + ' trusted'; - const result = domCreator(trustedHTMLHandler).htmlToDOM('test'); + const result = createDOMCreator(trustedHTMLHandler).htmlToDOM('test'); expect(result.lastChild).toEqual(doc.lastChild); }); }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts b/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts index 80db39130a3..16c678ee01e 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts @@ -1,4 +1,5 @@ /** + * @deprecated Use DOMCreator instead * A handler type to convert HTML string to a trust HTML string */ export type LegacyTrustedHTMLHandler = (html: string) => string; From 304a15bb736c825b54c31ba94053f3701a54fe8a Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 5 Dec 2024 15:53:11 -0300 Subject: [PATCH 5/8] fix test --- .../test/editor/core/createEditorCoreTest.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index ce01f1e758c..4bc20b76df3 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -150,7 +150,7 @@ describe('createEditorCore', () => { const mockedPlugin1 = 'P1' as any; const mockedPlugin2 = 'P2' as any; const mockedGetDarkColor = 'DARK' as any; - const mockedTrustHtmlHandler = 'OPTIONAL TRUSTED' as any; + const mockedTrustHtmlHandler = 'TRUSTED' as any; const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; @@ -168,8 +168,6 @@ describe('createEditorCore', () => { onFixUpModel: mockedOnFixUpModel, } as any; - spyOn(domCreator, 'createTrustedHTMLHandler').and.returnValue(mockedTrustHtmlHandler); - runTest(mockedDiv, mockedOptions, { physicalRoot: mockedDiv, logicalRoot: mockedDiv, From 72dfb20bfd98d7f0883d777a20dca33d9c0066af Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 6 Dec 2024 19:50:23 -0300 Subject: [PATCH 6/8] fixes --- .../lib/editor/core/createEditorCore.ts | 8 +++++--- .../test/editor/core/createEditorCoreTest.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 73e33595118..1a6351a4513 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -1,6 +1,6 @@ import { coreApiMap } from '../../coreApi/coreApiMap'; import { createDarkColorHandler } from './DarkColorHandlerImpl'; -import { createDOMCreator, createTrustedHTMLHandler } from '../../utils/domCreator'; +import { createDOMCreator, createTrustedHTMLHandler, isDOMCreator } from '../../utils/domCreator'; import { createDOMHelper } from './DOMHelperImpl'; import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; import { createEditorCorePlugins } from '../../corePlugin/createEditorCorePlugins'; @@ -20,7 +20,6 @@ import type { export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); const domCreator = createDOMCreator(options.trustedHTMLHandler); - const trustedHTMLHandler = createTrustedHTMLHandler(domCreator); return { physicalRoot: contentDiv, @@ -46,7 +45,10 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti options.knownColors, options.generateColorKey ), - trustedHTMLHandler: trustedHTMLHandler, + trustedHTMLHandler: + options.trustedHTMLHandler && !isDOMCreator(options.trustedHTMLHandler) + ? options.trustedHTMLHandler + : createTrustedHTMLHandler(domCreator), domCreator: domCreator, domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index 4bc20b76df3..810cfc4ba1b 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -150,7 +150,7 @@ describe('createEditorCore', () => { const mockedPlugin1 = 'P1' as any; const mockedPlugin2 = 'P2' as any; const mockedGetDarkColor = 'DARK' as any; - const mockedTrustHtmlHandler = 'TRUSTED' as any; + const mockedTrustHtmlHandler = 'OPTIONS TRUSTED' as any; const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; From 334e038952eb191053948d407223c9ab45cfc5e2 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 9 Dec 2024 11:01:18 -0800 Subject: [PATCH 7/8] #2859 Support changing format inside entity (#2896) * #2859 Support changing format inside entity * fix build --- .../controlsV2/plugins/SampleEntityPlugin.ts | 10 ++ .../utils/formatSegmentWithContentModel.ts | 123 ++++++++++++++- .../formatSegmentWithContentModelTest.ts | 141 +++++++++++++++++- .../modelApi/selection/collectSelections.ts | 3 + .../selection/collectSelectionsTest.ts | 18 ++- .../selection/iterateSelectionsTest.ts | 16 +- .../lib/enum/EntityOperation.ts | 16 +- .../lib/event/EntityOperationEvent.ts | 34 ++++- .../lib/index.ts | 3 +- .../lib/editor/utils/eventConverter.ts | 1 + 10 files changed, 352 insertions(+), 13 deletions(-) diff --git a/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts b/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts index dc0fe02b5c4..84a559e0ba4 100644 --- a/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts +++ b/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts @@ -63,6 +63,16 @@ export default class SampleEntityPlugin implements EditorPlugin { } break; + + case 'beforeFormat': + const span = entity.wrapper.querySelector('span'); + + if (span && event.formattableRoots) { + event.formattableRoots.push({ + element: span, + }); + } + break; } } } diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 1966f591fcf..8b0d5d403a8 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,8 +1,20 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { getSelectedSegmentsAndParagraphs, mergeTextSegments } from 'roosterjs-content-model-dom'; +import { + contentModelToDom, + createDomToModelContext, + createModelToDomContext, + domToContentModel, + getSelectedSegmentsAndParagraphs, + mergeTextSegments, +} from 'roosterjs-content-model-dom'; import type { + ContentModelDocument, + ContentModelEntity, ContentModelSegmentFormat, + EditorContext, + FormattableRoot, IEditor, + PluginEventData, ReadonlyContentModelDocument, ShallowMutableContentModelParagraph, ShallowMutableContentModelSegment, @@ -39,13 +51,14 @@ export function formatSegmentWithContentModel( let segmentAndParagraphs = getSelectedSegmentsAndParagraphs( model, !!includingFormatHolder, - false /*includingEntity*/, + true /*includingEntity*/, true /*mutate*/ ); let isCollapsedSelection = segmentAndParagraphs.length >= 1 && segmentAndParagraphs.every(x => x[0].segmentType == 'SelectionMarker'); + // 1. adjust selection to a word if selection is collapsed if (isCollapsedSelection) { const para = segmentAndParagraphs[0][1]; const path = segmentAndParagraphs[0][2]; @@ -60,30 +73,54 @@ export function formatSegmentWithContentModel( } } + // 2. expand selection for entities if any const formatsAndSegments: [ ContentModelSegmentFormat, ShallowMutableContentModelSegment | null, ShallowMutableContentModelParagraph | null - ][] = segmentAndParagraphs.map(item => [item[0].format, item[0], item[1]]); + ][] = []; + const modelsFromEntities: [ + ContentModelEntity, + FormattableRoot, + ContentModelDocument + ][] = []; + segmentAndParagraphs.forEach(item => { + if (item[0].segmentType == 'Entity') { + expandEntitySelections(editor, item[0], formatsAndSegments, modelsFromEntities); + } else { + formatsAndSegments.push([item[0].format, item[0], item[1]]); + } + }); + + // 3. check if we should turn format on (when not all selection has the required format already) + // or off (all selections already have the required format) const isTurningOff = segmentHasStyleCallback ? formatsAndSegments.every(([format, segment, paragraph]) => segmentHasStyleCallback(format, segment, paragraph) ) : false; + // 4. invoke the callback function to apply the format formatsAndSegments.forEach(([format, segment, paragraph]) => { toggleStyleCallback(format, !isTurningOff, segment, paragraph); }); + // 5. after format is applied to all selections, invoke another callback to do some clean up before write the change back afterFormatCallback?.(model); + // 6. finally merge segments if possible, to avoid fragmentation formatsAndSegments.forEach(([_, __, paragraph]) => { if (paragraph) { mergeTextSegments(paragraph); } }); + // 7. Write back models that we got from entities (if any) + writeBackEntities(editor, modelsFromEntities); + + // 8. if the selection is still collapsed, it means we didn't actually applied format, set a pending format so it can be applied when user type + // otherwise, write back to editor if (isCollapsedSelection) { context.newPendingFormat = segmentAndParagraphs[0][0].format; editor.focus(); @@ -97,3 +134,83 @@ export function formatSegmentWithContentModel( } ); } + +function createEditorContextForEntity(editor: IEditor, entity: ContentModelEntity): EditorContext { + const domHelper = editor.getDOMHelper(); + const context: EditorContext = { + isDarkMode: editor.isDarkMode(), + defaultFormat: { ...entity.format }, + darkColorHandler: editor.getColorManager(), + addDelimiterForEntity: false, + allowCacheElement: false, + domIndexer: undefined, + zoomScale: domHelper.calculateZoomScale(), + experimentalFeatures: [], + }; + + if (editor.getDocument().defaultView?.getComputedStyle(entity.wrapper).direction == 'rtl') { + context.isRootRtl = true; + } + + return context; +} + +function expandEntitySelections( + editor: IEditor, + entity: ContentModelEntity, + formatsAndSegments: [ + ContentModelSegmentFormat, + ShallowMutableContentModelSegment | null, + ShallowMutableContentModelParagraph | null + ][], + modelsFromEntities: [ContentModelEntity, FormattableRoot, ContentModelDocument][] +) { + const { id, entityType: type, isReadonly } = entity.entityFormat; + + if (id && type) { + const formattableRoots: FormattableRoot[] = []; + const entityOperationEventData: PluginEventData<'entityOperation'> = { + entity: { id, type, isReadonly: !!isReadonly, wrapper: entity.wrapper }, + operation: 'beforeFormat', + formattableRoots, + }; + + editor.triggerEvent('entityOperation', entityOperationEventData); + + formattableRoots.forEach(root => { + if (entity.wrapper.contains(root.element)) { + const editorContext = createEditorContextForEntity(editor, entity); + const context = createDomToModelContext(editorContext, root.domToModelOptions); + + // Treat everything as selected since the parent entity is selected + context.isInSelection = true; + + const model = domToContentModel(root.element, context); + const selections = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + false /*includingEntity*/, + true /*mutate*/ + ); + + selections.forEach(item => { + formatsAndSegments.push([item[0].format, item[0], item[1]]); + }); + + modelsFromEntities.push([entity, root, model]); + } + }); + } +} + +function writeBackEntities( + editor: IEditor, + modelsFromEntities: [ContentModelEntity, FormattableRoot, ContentModelDocument][] +) { + modelsFromEntities.forEach(([entity, root, model]) => { + const editorContext = createEditorContextForEntity(editor, entity); + const modelToDomContext = createModelToDomContext(editorContext, root.modelToDomOptions); + + contentModelToDom(editor.getDocument(), root.element, model, modelToDomContext); + }); +} diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 31b52ffc0ad..65654c23712 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -1,3 +1,5 @@ +import { EntityOperationEvent, FormattableRoot } from 'roosterjs-content-model-types'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; import { ContentModelBlockFormat, @@ -16,16 +18,22 @@ import { createParagraph as originalCreateParagraph, createSelectionMarker, createText, + createEntity, } from 'roosterjs-content-model-dom'; -describe('formatSegment', () => { +describe('formatSegmentWithContentModel', () => { let editor: IEditor; let focus: jasmine.Spy; let model: ContentModelDocument; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; let context: FormatContentModelContext | undefined; + let triggerEvent: jasmine.Spy; + const mockedCachedElement = 'CACHE' as any; + const mockedDOMHelper = { + calculateZoomScale: () => {}, + } as any; function createParagraph( isImplicit?: boolean, @@ -56,10 +64,17 @@ describe('formatSegment', () => { formatResult = callback(model, context); }); - editor = ({ + triggerEvent = jasmine.createSpy('triggerEvent'); + + editor = { focus, formatContentModel, - } as any) as IEditor; + triggerEvent, + getDOMHelper: () => mockedDOMHelper, + isDarkMode: () => false, + getDocument: () => document, + getColorManager: () => {}, + } as any; }); it('empty doc', () => { @@ -326,4 +341,124 @@ describe('formatSegment', () => { }, }); }); + + it('doc with entity selection, no plugin handle it', () => { + model = createContentModelDocument(); + + const div = document.createElement('div'); + const span = document.createElement('span'); + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test2'); + const text3 = document.createTextNode('test3'); + + span.appendChild(text2); + div.appendChild(text1); + div.appendChild(span); + div.appendChild(text3); + + const entity = createEntity(div, true, {}, 'TestEntity', 'TestEntity1'); + + model.blocks.push(entity); + entity.isSelected = true; + + const callback = jasmine + .createSpy('callback') + .and.callFake((format: ContentModelSegmentFormat) => { + format.fontFamily = 'test'; + }); + + formatSegmentWithContentModel(editor, apiName, callback); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { id: 'TestEntity1', entityType: 'TestEntity', isReadonly: true }, + wrapper: div, + isSelected: true, + }, + ], + }); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeFalse(); + expect(callback).toHaveBeenCalledTimes(0); + expectHtml(div.innerHTML, 'test1test2test3'); + }); + + it('doc with entity selection, plugin returns formattable root', () => { + model = createContentModelDocument(); + + const div = document.createElement('div'); + const span = document.createElement('span'); + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test2'); + const text3 = document.createTextNode('test3'); + + span.appendChild(text2); + div.appendChild(text1); + div.appendChild(span); + div.appendChild(text3); + + const entity = createEntity(div, true, {}, 'TestEntity', 'TestEntity1'); + + model.blocks.push(entity); + entity.isSelected = true; + + let formattableRoots: FormattableRoot[] | undefined; + + const callback = jasmine + .createSpy('callback') + .and.callFake((format: ContentModelSegmentFormat) => { + format.fontFamily = 'test'; + }); + + triggerEvent.and.callFake((eventType: string, event: EntityOperationEvent) => { + expect(eventType).toBe('entityOperation'); + expect(event.operation).toBe('beforeFormat'); + expect(event.entity).toEqual({ + id: 'TestEntity1', + type: 'TestEntity', + isReadonly: true, + wrapper: div, + }); + expect(event.formattableRoots).toEqual([]); + + formattableRoots = event.formattableRoots; + formattableRoots?.push({ + element: span, + }); + }); + + formatSegmentWithContentModel(editor, apiName, callback); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { id: 'TestEntity1', entityType: 'TestEntity', isReadonly: true }, + wrapper: div, + isSelected: true, + }, + ], + }); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(callback).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith('entityOperation', { + entity: { id: 'TestEntity1', type: 'TestEntity', isReadonly: true, wrapper: div }, + operation: 'beforeFormat', + formattableRoots: formattableRoots, + }); + expectHtml( + div.innerHTML, + 'test1test2test3' + ); + }); }); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts index 4d6c2c8e4f4..8257dacda6b 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts @@ -125,6 +125,9 @@ export function getSelectedSegmentsAndParagraphs( } }); } + } else if (block?.blockType == 'Entity' && includingEntity) { + // Here we treat the entity as segment since they are compatible, then it has no parent paragraph + result.push([block, null /*paragraph*/, path]); } }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts index bd950e01685..768eb2ccdc4 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts @@ -220,7 +220,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { ); }); - it('Include entity', () => { + it('Include entity - entity segment', () => { const e1 = createEntity(null!); const e2 = createEntity(null!, false); const p1 = createParagraph(); @@ -243,6 +243,22 @@ describe('getSelectedSegmentsAndParagraphs', () => { ] ); }); + + it('Include entity - entity block', () => { + const e1 = createEntity(null!); + + runTest( + [ + { + path: [], + block: e1, + }, + ], + false, + true, + [[e1, null, []]] + ); + }); }); describe('getSelectedParagraphs', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts index 762ab0eb22a..afe8d2f598d 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts @@ -1191,7 +1191,7 @@ describe('iterateSelections', () => { expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text1, text2]); }); - it('With selected entity', () => { + it('With selected entity segment', () => { const doc = createContentModelDocument(); const para = createParagraph(); const entity = createEntity(null!); @@ -1206,4 +1206,18 @@ describe('iterateSelections', () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([doc], undefined, para, [entity]); }); + + it('With selected entity block', () => { + const doc = createContentModelDocument(); + const entity = createEntity(null!); + + entity.isSelected = true; + + doc.blocks.push(entity); + + iterateSelections(doc, callback, { includeListFormatHolder: 'never' }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith([doc], undefined, entity, undefined); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts b/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts index 9ec9b12dd1a..ec7df0605bd 100644 --- a/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts +++ b/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts @@ -58,7 +58,21 @@ export type EntityRemovalOperation = */ | 'overwrite'; +/** + * DEfine entity format related operations + */ +export type EntityFormatOperation = + /** + * Tell plugins we are doing format change and an entity is inside the selection. + * Plugin can handle this event and put root level node (must be under the entity wrapper) into + * event.formattableRoots so editor will create content models for each root and do format to their contents + */ + 'beforeFormat'; + /** * Define possible operations to an entity */ -export type EntityOperation = EntityLifecycleOperation | EntityRemovalOperation; +export type EntityOperation = + | EntityLifecycleOperation + | EntityRemovalOperation + | EntityFormatOperation; diff --git a/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts b/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts index fc3ba4a7062..60291716c5f 100644 --- a/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts @@ -1,5 +1,7 @@ import type { BasePluginEvent } from './BasePluginEvent'; import type { EntityOperation } from '../enum/EntityOperation'; +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; /** * Represents an entity in editor. @@ -23,9 +25,29 @@ export interface Entity { isReadonly: boolean; } +/** + * Represent a combination of a root element under an entity and options to do DOM and content model conversion + */ +export interface FormattableRoot { + /** + * The root element to apply format under an entity + */ + element: HTMLElement; + + /** + * @optional DOM to Content Model option + */ + domToModelOptions?: DomToModelOption; + + /** + * @optional Content Model to DOM option + */ + modelToDomOptions?: ModelToDomOption; +} + /** * Provide a chance for plugins to handle entity related events. - * See enum EntityOperation for more details about each operation + * See type EntityOperation for more details about each operation */ export interface EntityOperationEvent extends BasePluginEvent<'entityOperation'> { /** @@ -44,15 +66,21 @@ export interface EntityOperationEvent extends BasePluginEvent<'entityOperation'> rawEvent?: Event; /** - * For EntityOperation.UpdateEntityState, we use this object to pass the new entity state to plugin. + * For entity operation "updateEntityState", we use this object to pass the new entity state to plugin. * For other operation types, it is not used. */ state?: string; /** - * For EntityOperation.NewEntity, plugin can set this property to true then the entity will be persisted. + * For entity operation "newEntity", plugin can set this property to true then the entity will be persisted. * A persisted entity won't be touched during undo/redo, unless it does not exist after undo/redo. * For other operation types, this value will be ignored. */ shouldPersist?: boolean; + + /** + * For entity operation "beforeFormat" (happens when user wants to do format change), we will set this array + * in event and plugins can check if there is any elements inside the entity that should also apply the format + */ + formattableRoots?: FormattableRoot[]; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 241660c5d01..06720540376 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -77,6 +77,7 @@ export { EntityLifecycleOperation, EntityOperation, EntityRemovalOperation, + EntityFormatOperation, } from './enum/EntityOperation'; export { TableOperation, @@ -463,7 +464,7 @@ export { ContextMenuEvent } from './event/ContextMenuEvent'; export { RewriteFromModelEvent } from './event/RewriteFromModelEvent'; export { EditImageEvent } from './event/EditImageEvent'; export { EditorReadyEvent } from './event/EditorReadyEvent'; -export { EntityOperationEvent, Entity } from './event/EntityOperationEvent'; +export { EntityOperationEvent, FormattableRoot, Entity } from './event/EntityOperationEvent'; export { ExtractContentWithDomEvent } from './event/ExtractContentWithDomEvent'; export { EditorInputEvent } from './event/EditorInputEvent'; export { diff --git a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts index 447c1b68929..9c3908706c8 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts @@ -70,6 +70,7 @@ const EntityOperationNewToOld: Record Date: Tue, 10 Dec 2024 09:43:45 -0800 Subject: [PATCH 8/8] Fix issue that paste will remove cache (#2900) * Fix issue that paste will remove cache * improve --- .../lib/command/paste/mergePasteContent.ts | 8 +- .../lib/command/paste/paste.ts | 6 +- .../command/paste/mergePasteContentTest.ts | 296 +++++++++++++++--- .../test/command/paste/pasteTest.ts | 32 ++ 4 files changed, 289 insertions(+), 53 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 fb0e61e966a..708826b597b 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -36,7 +36,11 @@ export function cloneModelForPaste(model: ReadonlyContentModelDocument) { /** * @internal */ -export function mergePasteContent(editor: IEditor, eventResult: BeforePasteEvent) { +export function mergePasteContent( + editor: IEditor, + eventResult: BeforePasteEvent, + isFirstPaste: boolean +) { const { fragment, domToModelOption, @@ -48,7 +52,7 @@ export function mergePasteContent(editor: IEditor, eventResult: BeforePasteEvent editor.formatContentModel( (model, context) => { - if (clipboardData.modelBeforePaste) { + if (!isFirstPaste && clipboardData.modelBeforePaste) { const clonedModel = cloneModelForPaste(clipboardData.modelBeforePaste); model.blocks = clonedModel.blocks; } 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 1028bc20765..d7b2d815c2d 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/paste.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/paste.ts @@ -22,7 +22,11 @@ export function paste( pasteTypeOrGetter: PasteTypeOrGetter = 'normal' ) { editor.focus(); + let isFirstPaste = false; + if (!clipboardData.modelBeforePaste) { + isFirstPaste = true; + editor.formatContentModel(model => { clipboardData.modelBeforePaste = cloneModelForPaste(model); @@ -64,7 +68,7 @@ export function paste( convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); // 6. Merge pasted content into main Content Model - mergePasteContent(editor, eventResult); + mergePasteContent(editor, eventResult, isFirstPaste); } function createDOMFromHtml( 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 71efb5798c0..b8a4779b761 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -1,3 +1,4 @@ +import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; 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'; @@ -22,6 +23,7 @@ import { FormatContentModelOptions, InsertPoint, IEditor, + ClipboardData, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { @@ -30,11 +32,12 @@ describe('mergePasteContent', () => { let formatContentModel: jasmine.Spy; let sourceModel: ContentModelDocument; let editor: IEditor; - const mockedClipboard = 'CLIPBOARD' as any; + let mockedClipboard: ClipboardData; beforeEach(() => { formatResult = undefined; context = undefined; + mockedClipboard = 'CLIPBOARD' as any; formatContentModel = jasmine .createSpy('formatContentModel') @@ -167,7 +170,7 @@ describe('mergePasteContent', () => { clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult); + mergePasteContent(editor, eventResult, true); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -274,7 +277,7 @@ describe('mergePasteContent', () => { clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult); + mergePasteContent(editor, eventResult, true); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -296,7 +299,7 @@ describe('mergePasteContent', () => { clipboardData: mockedClipboard, } as any; - mergePasteContent(editor, eventResult); + mergePasteContent(editor, eventResult, true); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -377,11 +380,15 @@ describe('mergePasteContent', () => { }, }); - mergePasteContent(editor, { - fragment: mockedFragment, - domToModelOption: mockedDefaultDomToModelOptions, - clipboardData: mockedClipboard, - } as any); + mergePasteContent( + editor, + { + fragment: mockedFragment, + domToModelOption: mockedDefaultDomToModelOptions, + clipboardData: mockedClipboard, + } as any, + true + ); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -439,7 +446,7 @@ describe('mergePasteContent', () => { containsBlockElements: true, } as any; - mergePasteContent(editor, eventResult); + mergePasteContent(editor, eventResult, true); expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); @@ -483,13 +490,17 @@ describe('mergePasteContent', () => { para.segments.push(marker); addBlock(sourceModel, para); - mergePasteContent(editor, { - fragment, - containsBlockElements: true, - domToModelOption: {}, - pasteType: 'normal', - clipboardData: mockedClipboard, - }); + mergePasteContent( + editor, + { + fragment, + containsBlockElements: true, + domToModelOption: {}, + pasteType: 'normal', + clipboardData: mockedClipboard, + }, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -876,12 +887,16 @@ describe('mergePasteContent', () => { para.segments.push(marker); sourceModel.blocks.push(para); - mergePasteContent(editor, { - fragment, - domToModelOption: {}, - pasteType: 'mergeFormat', - clipboardData: mockedClipboard, - } as any); + mergePasteContent( + editor, + { + fragment, + domToModelOption: {}, + pasteType: 'mergeFormat', + clipboardData: mockedClipboard, + } as any, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1245,12 +1260,16 @@ describe('mergePasteContent', () => { para.segments.push(marker); sourceModel.blocks.push(para); - mergePasteContent(editor, { - fragment, - domToModelOption: {}, - pasteType: 'asPlainText', - clipboardData: mockedClipboard, - } as any); + mergePasteContent( + editor, + { + fragment, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + } as any, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1472,13 +1491,17 @@ describe('mergePasteContent', () => { para.segments.push(createText('Text in source'), marker); addBlock(sourceModel, para); - mergePasteContent(editor, { - fragment, - containsBlockElements: false, - domToModelOption: {}, - pasteType: 'normal', - clipboardData: mockedClipboard, - }); + mergePasteContent( + editor, + { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'normal', + clipboardData: mockedClipboard, + }, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1611,13 +1634,17 @@ describe('mergePasteContent', () => { para.segments.push(createText('Text in source'), marker); addBlock(sourceModel, para); - mergePasteContent(editor, { - fragment, - containsBlockElements: false, - domToModelOption: {}, - pasteType: 'mergeFormat', - clipboardData: mockedClipboard, - }); + mergePasteContent( + editor, + { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'mergeFormat', + clipboardData: mockedClipboard, + }, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1747,13 +1774,17 @@ describe('mergePasteContent', () => { para.segments.push(createText('Text in source'), marker); addBlock(sourceModel, para); - mergePasteContent(editor, { - fragment, - containsBlockElements: false, - domToModelOption: {}, - pasteType: 'asPlainText', - clipboardData: mockedClipboard, - }); + mergePasteContent( + editor, + { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + }, + true + ); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, @@ -1842,4 +1873,169 @@ describe('mergePasteContent', () => { format: { fontFamily: 'Aptos', fontSize: '10pt', textColor: 'blue' }, }); }); + + it('do not clone model for first paste, and keep cache', () => { + const fragment = createPasteFragment( + document, + { text: 'text' } as any, + 'asPlainText', + document.body + ); + const div = document.createElement('div'); + const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callThrough(); + + sourceModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + cachedElement: div, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + format: {}, + }; + + const modelBeforePaste: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }; + mockedClipboard = { + modelBeforePaste, + } as any; + + mergePasteContent( + editor, + { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + }, + true + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + cachedElement: div, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'text', format: {} }, + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + ], + format: {}, + }, + ], + format: {}, + }); + expect(cloneModelSpy).not.toHaveBeenCalled(); + }); + + it('clone model for second paste, and clear cache', () => { + const fragment = createPasteFragment( + document, + { text: 'text' } as any, + 'asPlainText', + document.body + ); + const div = document.createElement('div'); + const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callThrough(); + + sourceModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + cachedElement: div, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + format: {}, + }; + + const modelBeforePaste: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }; + mockedClipboard = { + modelBeforePaste, + } as any; + + mergePasteContent( + editor, + { + fragment, + containsBlockElements: false, + domToModelOption: {}, + pasteType: 'asPlainText', + clipboardData: mockedClipboard, + }, + false + ); + + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'text', format: {} }, + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + ], + format: {}, + cachedElement: undefined, + isImplicit: undefined, + }, + ], + format: {}, + }); + expect(cloneModelSpy).toHaveBeenCalledTimes(1); + expect(cloneModelSpy).toHaveBeenCalledWith(modelBeforePaste, { + includeCachedElement: jasmine.anything(), + } as any); + }); }); 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 a6aab524bb7..2230d485c73 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts @@ -6,6 +6,7 @@ import * as generatePasteOptionFromPluginsFile from '../../../lib/command/paste/ import * as getPasteSourceF from 'roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource'; import * as getSelectedSegmentsF from 'roosterjs-content-model-dom/lib/modelApi/selection/collectSelections'; import * as mergeModelFile from 'roosterjs-content-model-dom/lib/modelApi/editing/mergeModel'; +import * as mergePasteContentFile from '../../../lib/command/paste/mergePasteContent'; import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from 'roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; import * as WacComponents from 'roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; @@ -312,6 +313,7 @@ describe('paste with content model & paste plugin', () => { describe('Paste with clipboardData', () => { let editor: IEditor = undefined!; const ID = 'EDITOR_ID'; + let mergePasteContentSpy: jasmine.Spy; beforeEach(() => { editor = initEditor(ID); @@ -325,6 +327,7 @@ describe('Paste with clipboardData', () => { htmlFirstLevelChildTags: ['P', 'P'], html: '', }); + mergePasteContentSpy = spyOn(mergePasteContentFile, 'mergePasteContent').and.callThrough(); }); afterEach(() => { @@ -390,6 +393,33 @@ describe('Paste with clipboardData', () => { ], format: {}, }); + expect(mergePasteContentSpy.calls.argsFor(0)[2]).toBeTrue(); + }); + + it('Second paste', () => { + clipboardData.rawHtml = ''; + clipboardData.modelBeforePaste = { + blockGroupType: 'Document', + blocks: [], + }; + + paste(editor, clipboardData); + + const model = editor.getContentModelCopy('connected'); + + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [{ isSelected: true, segmentType: 'SelectionMarker', format: {} }], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }); + expect(mergePasteContentSpy.calls.argsFor(0)[2]).toBeFalse(); }); it('Remove unsupported url of link from clipboardContent', () => { @@ -437,6 +467,7 @@ describe('Paste with clipboardData', () => { ], format: {}, }); + expect(mergePasteContentSpy.calls.argsFor(0)[2]).toBeTrue(); }); it('Keep supported url of link from clipboardContent', () => { @@ -496,5 +527,6 @@ describe('Paste with clipboardData', () => { ], format: {}, }); + expect(mergePasteContentSpy.calls.argsFor(0)[2]).toBeTrue(); }); });