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-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts index b88c24ae8af..d3d62bc0d5d 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts @@ -6,6 +6,7 @@ import { getRootComputedStyleForContext } from '../../coreApi/createEditorContex import { pasteBlockEntityParser } from '../../override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../override/pasteTextProcessor'; +import { pasteWhiteSpaceFormatParser } from '../../override/pasteWhiteSpaceFormatParser'; import type { ContentModelSegmentFormat, DomToModelContext, @@ -52,6 +53,7 @@ export function createDomToModelContextForSanitizing( }, formatParserOverride: { display: pasteDisplayFormatParser, + whiteSpace: pasteWhiteSpaceFormatParser, }, additionalFormatParsers: { container: [containerSizeFormatParser], 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..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,4 +1,5 @@ import { convertInlineCss, retrieveCssRules } from './convertInlineCss'; +import { createDOMCreator } from '../../utils/domCreator'; import { createDomToModelContextForSanitizing } from './createDomToModelContextForSanitizing'; import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-content-model-dom'; import type { @@ -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 ? createDOMCreator(trustedHTMLHandler).htmlToDOM(html) : null; if (doc?.body) { const context = createDomToModelContextForSanitizing( 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 69cd08340d5..d7b2d815c2d 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,10 +22,11 @@ export function paste( pasteTypeOrGetter: PasteTypeOrGetter = 'normal' ) { editor.focus(); - - const trustedHTMLHandler = editor.getTrustedHTMLHandler(); + let isFirstPaste = false; if (!clipboardData.modelBeforePaste) { + isFirstPaste = true; + editor.formatContentModel(model => { clipboardData.modelBeforePaste = cloneModelForPaste(model); @@ -34,7 +35,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 +51,7 @@ export function paste( pasteType, (clipboardData.rawHtml == clipboardData.html ? doc - : createDOMFromHtml(clipboardData.html, trustedHTMLHandler) + : createDOMFromHtml(clipboardData.html, editor.getDOMCreator()) )?.body ); @@ -67,12 +68,12 @@ 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( 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/corePlugin/format/applyDefaultFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts index 300a3d50f8c..863a5203f24 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts @@ -1,4 +1,4 @@ -import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { iterateSelections } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; /** @@ -8,55 +8,59 @@ import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model * @param defaultFormat The default segment format to apply */ export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelSegmentFormat) { - editor.formatContentModel((model, context) => { - const result = deleteSelection(model, [], context); + const selection = editor.getDOMSelection(); - if (result.deleteResult == 'range') { - normalizeContentModel(model); + if (selection?.type == 'range' && selection.range.collapsed) { + editor.formatContentModel((model, context) => { + iterateSelections(model, (path, _, paragraph, segments) => { + const marker = segments?.[0]; + if ( + paragraph?.blockType == 'Paragraph' && + marker?.segmentType == 'SelectionMarker' + ) { + const blocks = path[0].blocks; + const blockCount = blocks.length; + const blockIndex = blocks.indexOf(paragraph); - editor.takeSnapshot(); + if ( + paragraph.isImplicit && + paragraph.segments.length == 1 && + paragraph.segments[0] == marker && + blockCount > 0 && + blockIndex == blockCount - 1 + ) { + // Focus is in the last paragraph which is implicit and there is not other segments. + // This can happen when focus is moved after all other content under current block group. + // We need to check if browser will merge focus into previous paragraph by checking if + // previous block is block. If previous block is paragraph, browser will most likely merge + // the input into previous paragraph, then nothing need to do here. Otherwise we need to + // apply pending format since this input event will start a new real paragraph. + const previousBlock = blocks[blockIndex - 1]; - return true; - } else if (result.deleteResult == 'notDeleted' && result.insertPoint) { - const { paragraph, path, marker } = result.insertPoint; - const blocks = path[0].blocks; - const blockCount = blocks.length; - const blockIndex = blocks.indexOf(paragraph); - - if ( - paragraph.isImplicit && - paragraph.segments.length == 1 && - paragraph.segments[0] == marker && - blockCount > 0 && - blockIndex == blockCount - 1 - ) { - // Focus is in the last paragraph which is implicit and there is not other segments. - // This can happen when focus is moved after all other content under current block group. - // We need to check if browser will merge focus into previous paragraph by checking if - // previous block is block. If previous block is paragraph, browser will most likely merge - // the input into previous paragraph, then nothing need to do here. Otherwise we need to - // apply pending format since this input event will start a new real paragraph. - const previousBlock = blocks[blockIndex - 1]; - - if (previousBlock?.blockType != 'Paragraph') { - context.newPendingFormat = getNewPendingFormat( - editor, - defaultFormat, - marker.format - ); + if (previousBlock?.blockType != 'Paragraph') { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - context.newPendingFormat = getNewPendingFormat( - editor, - defaultFormat, - marker.format - ); - } - } - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - }); + // Stop searching more selection + return true; + }); + + // We didn't do any change but just apply default format to pending format, so no need to write back + return false; + }); + } } function getNewPendingFormat( 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..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,5 +1,6 @@ import { coreApiMap } from '../../coreApi/coreApiMap'; import { createDarkColorHandler } from './DarkColorHandlerImpl'; +import { createDOMCreator, createTrustedHTMLHandler, isDOMCreator } from '../../utils/domCreator'; import { createDOMHelper } from './DOMHelperImpl'; import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; import { createEditorCorePlugins } from '../../corePlugin/createEditorCorePlugins'; @@ -18,6 +19,7 @@ import type { */ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { const corePlugins = createEditorCorePlugins(options, contentDiv); + const domCreator = createDOMCreator(options.trustedHTMLHandler); return { physicalRoot: contentDiv, @@ -43,7 +45,11 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti options.knownColors, options.generateColorKey ), - trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, + trustedHTMLHandler: + options.trustedHTMLHandler && !isDOMCreator(options.trustedHTMLHandler) + ? options.trustedHTMLHandler + : createTrustedHTMLHandler(domCreator), + domCreator: domCreator, domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, @@ -90,13 +96,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/override/pasteWhiteSpaceFormatParser.ts b/packages/roosterjs-content-model-core/lib/override/pasteWhiteSpaceFormatParser.ts new file mode 100644 index 00000000000..893c1fcef12 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/override/pasteWhiteSpaceFormatParser.ts @@ -0,0 +1,17 @@ +import type { FormatParser, WhiteSpaceFormat } from 'roosterjs-content-model-types'; + +const WhiteSpacePre = 'pre'; + +/** + * @internal + */ +export const pasteWhiteSpaceFormatParser: FormatParser = ( + format, + element, + context, + defaultStyle +) => { + if (element.style.whiteSpace != WhiteSpacePre) { + context.defaultFormatParsers.whiteSpace?.(format, element, context, defaultStyle); + } +}; 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..d9432630d52 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/utils/domCreator.ts @@ -0,0 +1,44 @@ +import type { + DOMCreator, + LegacyTrustedHTMLHandler, + TrustedHTMLHandler, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +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); +} + +/** + * @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/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts index c98a5d0422e..9c406bf9c49 100644 --- a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts +++ b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts @@ -7,6 +7,7 @@ import { DomToModelOptionForSanitizing } from 'roosterjs-content-model-types'; import { pasteBlockEntityParser } from '../../../lib/override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; +import { pasteWhiteSpaceFormatParser } from '../../../lib/override/pasteWhiteSpaceFormatParser'; describe('createDomToModelContextForSanitizing', () => { const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; @@ -61,6 +62,7 @@ describe('createDomToModelContextForSanitizing', () => { }, formatParserOverride: { display: pasteDisplayFormatParser, + whiteSpace: pasteWhiteSpaceFormatParser, }, additionalFormatParsers: { container: [containerSizeFormatParser], @@ -106,6 +108,7 @@ describe('createDomToModelContextForSanitizing', () => { }, formatParserOverride: { display: pasteDisplayFormatParser, + whiteSpace: pasteWhiteSpaceFormatParser, }, additionalFormatParsers: { container: [containerSizeFormatParser], 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(); }); }); 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/corePlugin/format/FormatPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts index e278325c4cf..79c3379878e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -234,14 +234,12 @@ describe('FormatPlugin for default format', () => { let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; let cacheContentModelSpy: jasmine.Spy; - let takeSnapshotSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; beforeEach(() => { getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); getDOMSelection = jasmine.createSpy('getDOMSelection'); cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); - takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); formatContentModelSpy = jasmine.createSpy('formatContentModelSpy'); contentDiv = document.createElement('div'); @@ -252,7 +250,6 @@ describe('FormatPlugin for default format', () => { getDOMSelection, getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, - takeSnapshot: takeSnapshotSpy, formatContentModel: formatContentModelSpy, getEnvironment: () => ({}), } as any) as IEditor; @@ -364,7 +361,6 @@ describe('FormatPlugin for default format', () => { }); expect(context).toEqual({}); - expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Collapsed range, IME input, under editor directly', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts index da1eb166360..3b5b4748f51 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts @@ -1,5 +1,4 @@ import * as deleteSelection from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection'; -import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/corePlugin/format/applyDefaultFormat'; import { ContentModelDocument, @@ -24,8 +23,6 @@ describe('applyDefaultFormat', () => { let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; - let normalizeContentModelSpy: jasmine.Spy; - let takeSnapshotSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; let isNodeInEditorSpy: jasmine.Spy; @@ -46,8 +43,6 @@ describe('applyDefaultFormat', () => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy'); deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); - normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); - takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); @@ -71,7 +66,6 @@ describe('applyDefaultFormat', () => { }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, - takeSnapshot: takeSnapshotSpy, getPendingFormat: getPendingFormatSpy, } as any; }); @@ -82,7 +76,7 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); }); it('Selection already has style', () => { @@ -99,6 +93,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); deleteSelectionSpy.and.returnValue({ @@ -124,6 +119,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: text, startOffset: 0, + collapsed: true, }, }); deleteSelectionSpy.and.returnValue({ @@ -143,6 +139,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -154,9 +151,7 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).toHaveBeenCalledWith(model); - expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); - expect(formatResult).toBeTrue(); + expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], newEntities: [], @@ -174,6 +169,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -185,8 +181,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -204,6 +198,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -215,8 +210,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -246,6 +239,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -257,8 +251,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -288,6 +280,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -299,8 +292,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -331,6 +322,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -342,8 +334,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -373,6 +363,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -384,8 +375,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -419,6 +408,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -435,8 +425,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], 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..810cfc4ba1b 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'; +import { DOMCreator, EditorCore, EditorOptions } from 'roosterjs-content-model-types'; describe('createEditorCore', () => { function createMockedPlugin(stateName: string): any { @@ -41,6 +38,10 @@ describe('createEditorCore', () => { const mockedDomToModelSettings = 'DOMTOMODEL' as any; const mockedModelToDomSettings = 'MODELTODOM' as any; const mockedDOMHelper = 'DOMHELPER' as any; + const mockedDOMCreator: DOMCreator = { + htmlToDOM: mockedDOMHelper, + }; + const mockedTrustHtmlHandler = 'TRUSTED' as any; beforeEach(() => { spyOn(createEditorCorePlugins, 'createEditorCorePlugins').and.returnValue(mockedPlugins); @@ -54,6 +55,8 @@ describe('createEditorCore', () => { mockedModelToDomSettings ); spyOn(DOMHelperImpl, 'createDOMHelper').and.returnValue(mockedDOMHelper); + spyOn(domCreator, 'createDOMCreator').and.returnValue(mockedDOMCreator); + spyOn(domCreator, 'createTrustedHTMLHandler').and.returnValue(mockedTrustHtmlHandler); }); function runTest( @@ -88,7 +91,8 @@ describe('createEditorCore', () => { modelToDomSettings: mockedModelToDomSettings, }, darkColorHandler: mockedDarkColorHandler, - trustedHTMLHandler: defaultTrustHtmlHandler, + 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 = 'OPTIONS TRUSTED' as any; const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; diff --git a/packages/roosterjs-content-model-core/test/overrides/pasteWhiteSpaceFormatParserTest.ts b/packages/roosterjs-content-model-core/test/overrides/pasteWhiteSpaceFormatParserTest.ts new file mode 100644 index 00000000000..d153176d9ab --- /dev/null +++ b/packages/roosterjs-content-model-core/test/overrides/pasteWhiteSpaceFormatParserTest.ts @@ -0,0 +1,39 @@ +import { pasteWhiteSpaceFormatParser } from '../../lib/override/pasteWhiteSpaceFormatParser'; +import { WhiteSpaceFormat } from 'roosterjs-content-model-types/lib'; + +describe('pasteWhiteSpaceFormatParser', () => { + let format: WhiteSpaceFormat; + let element: HTMLElement; + let context: any; + let defaultStyle: any; + let defaultParserSpy: jasmine.Spy; + + beforeEach(() => { + format = {}; + element = document.createElement('div'); + defaultParserSpy = jasmine.createSpy(); + context = { + defaultFormatParsers: { + whiteSpace: defaultParserSpy, + }, + }; + defaultStyle = {}; + }); + + it('should call default whiteSpace parser when element.style.whiteSpace is not "pre"', () => { + element.style.whiteSpace = 'normal'; + pasteWhiteSpaceFormatParser(format, element, context, defaultStyle); + expect(context.defaultFormatParsers.whiteSpace).toHaveBeenCalledWith( + format, + element, + context, + defaultStyle + ); + }); + + it('should not call default whiteSpace parser when element.style.whiteSpace is "pre"', () => { + element.style.whiteSpace = 'pre'; + pasteWhiteSpaceFormatParser(format, element, context, defaultStyle); + expect(context.defaultFormatParsers.whiteSpace).not.toHaveBeenCalled(); + }); +}); 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..48d35e60e84 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/utils/domCreatorTest.ts @@ -0,0 +1,38 @@ +import { createDOMCreator, 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('createDOMCreator - isDOMCreator', () => { + const trustedHTMLHandler = { + htmlToDOM: (html: string) => new DOMParser().parseFromString(html, 'text/html'), + }; + const result = createDOMCreator(trustedHTMLHandler); + expect(result).toEqual(trustedHTMLHandler); + }); + + it('createDOMCreator - undefined', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.appendChild(document.createTextNode('test')); + const result = createDOMCreator(undefined).htmlToDOM('test'); + expect(result.lastChild).toEqual(doc.lastChild); + }); + + it('createDOMCreator - trustedHTML', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.appendChild(document.createTextNode('test trusted')); + const trustedHTMLHandler = (html: string) => html + ' trusted'; + const result = createDOMCreator(trustedHTMLHandler).htmlToDOM('test'); + expect(result.lastChild).toEqual(doc.lastChild); + }); +}); 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-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 40305ffcb1d..13c35dedff2 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -207,30 +207,33 @@ export class AutoFormatPlugin implements EditorPlugin { } break; case 'Tab': - formatTextSegmentBeforeSelectionMarker( - editor, - (model, _previousSegment, paragraph, _markerFormat, context) => { - const { autoBullet, autoNumbering } = this.options; - let shouldList = false; - if (autoBullet || autoNumbering) { - shouldList = keyboardListTrigger( - model, - paragraph, - context, - autoBullet, - autoNumbering - ); - context.canUndoByBackspace = shouldList; - event.rawEvent.preventDefault(); + if (!rawEvent.shiftKey) { + formatTextSegmentBeforeSelectionMarker( + editor, + (model, _previousSegment, paragraph, _markerFormat, context) => { + const { autoBullet, autoNumbering } = this.options; + let shouldList = false; + if (autoBullet || autoNumbering) { + shouldList = keyboardListTrigger( + model, + paragraph, + context, + autoBullet, + autoNumbering + ); + context.canUndoByBackspace = shouldList; + } + if (shouldList) { + event.rawEvent.preventDefault(); + } + return shouldList; + }, + { + changeSource: ChangeSource.AutoFormat, + apiName: 'autoToggleList', } - - return shouldList; - }, - { - changeSource: ChangeSource.AutoFormat, - apiName: 'autoToggleList', - } - ); + ); + } } } } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 87c4779ea85..d0c1263a2c1 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -23,6 +23,13 @@ export type EditOptions = { const BACKSPACE_KEY = 8; const DELETE_KEY = 46; +/** + * According to https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html + * 229 can be sent in variants generated when Long press (iOS) or using IM. + * + * Other cases: https://stackoverflow.com/questions/25043934/is-it-ok-to-ignore-keydown-events-with-keycode-229 + */ +const DEAD_KEY = 229; const DefaultOptions: Partial = { handleTabKey: true, @@ -181,7 +188,11 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - if (!hasCtrlOrMetaKey) { + if ( + !hasCtrlOrMetaKey && + !event.rawEvent.isComposing && + event.rawEvent.keyCode !== DEAD_KEY + ) { keyboardEnter(editor, rawEvent, this.handleNormalEnter); } break; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 201f387f455..523645d2df0 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,10 +1,29 @@ import { ChangeSource, + createText, deleteSelection, isModifierKey, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; +import type { DeleteSelectionStep, DOMSelection, IEditor } from 'roosterjs-content-model-types'; + +// Insert a ZeroWidthSpace(ZWS) segment with selection before selection marker +// so that later browser will replace this selection with inputted text and keep format +const ZWS = '\u200B'; +const insertZWS: DeleteSelectionStep = context => { + if (context.deleteResult == 'range') { + const { marker, paragraph } = context.insertPoint; + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + const text = createText(ZWS, marker.format, marker.link, marker.code); + + text.isSelected = true; + + paragraph.segments.splice(index, 0, text); + } + } +}; /** * @internal @@ -17,7 +36,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context); + const result = deleteSelection(model, [insertZWS], context); // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation context.skipUndoSnapshot = true; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 554941a44f2..ec6dfe6047f 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -121,6 +121,16 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } }, }, + dragend: { + beforeDispatch: ev => { + if (this.editor) { + const target = ev.target as Node; + if (this.isImageSelection(target) && target.id.includes(DRAG_ID)) { + target.id = target.id.replace(DRAG_ID, '').trim(); + } + } + }, + }, }); } 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/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 481571448fc..05aa2ed03a9 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -413,8 +413,6 @@ describe('Content Model Auto Format Plugin Test', () => { format: {}, }; - event.rawEvent.preventDefault = jasmine.createSpy('preventDefault'); - formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { callback( inputModel, @@ -445,6 +443,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Tab', defaultPrevented: false, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -506,6 +505,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Tab', defaultPrevented: false, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -567,6 +567,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Tab', defaultPrevented: false, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -592,7 +593,7 @@ describe('Content Model Auto Format Plugin Test', () => { }, { autoBullet: true, autoNumbering: false }, true, - true + false ); }); @@ -603,6 +604,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Tab', defaultPrevented: false, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -639,6 +641,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Ctrl', defaultPrevented: false, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -675,6 +678,7 @@ describe('Content Model Auto Format Plugin Test', () => { key: 'Tab', defaultPrevented: true, handledByEditFeature: false, + preventDefault: jasmine.createSpy('preventDefault'), } as any, }; runTest( @@ -710,6 +714,45 @@ describe('Content Model Auto Format Plugin Test', () => { rawEvent: { key: 'Tab', defaultPrevented: false, + preventDefault: jasmine.createSpy('preventDefault'), + } as any, + handledByEditFeature: true, + }; + runTest( + event, + true, + { + blockGroupType: 'Document', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + marker, + ], + }, + ], + }, + { autoBullet: true, autoNumbering: true }, + false, + false + ); + }); + + it('[TAB] should not trigger keyboardListTrigger - shift key', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + defaultPrevented: false, + shiftKey: true, + preventDefault: jasmine.createSpy('preventDefault'), } as any, handledByEditFeature: true, }; diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 51e2c32bccd..7e94b272ad0 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -142,7 +142,7 @@ describe('EditPlugin', () => { it('Enter, normal enter not enabled', () => { plugin = new EditPlugin(); - const rawEvent = { which: 13, key: 'Enter' } as any; + const rawEvent = { keyCode: 13, which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); editor.takeSnapshot = addUndoSnapshotSpy; @@ -165,7 +165,7 @@ describe('EditPlugin', () => { (featureName: string) => featureName == 'HandleEnterKey' ); plugin = new EditPlugin(); - const rawEvent = { which: 13, key: 'Enter' } as any; + const rawEvent = { keyCode: 13, which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); editor.takeSnapshot = addUndoSnapshotSpy; diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index b2435499c9f..fe3e31af310 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -99,7 +99,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeFalse(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -130,7 +134,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -142,6 +150,90 @@ describe('keyboardInput', () => { expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); + it('Letter input, expanded selection, no modifier key, deleteSelection returns range, do real deleting', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.callThrough(); + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'aa', + format: {}, + }, + { + segmentType: 'Text', + text: '', + format: { fontSize: '10pt' }, + isSelected: true, + }, + ], + }, + ], + }; + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + skipUndoSnapshot: true, + newPendingFormat: { fontSize: '10pt' }, + }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'aa', + format: {}, + }, + { + segmentType: 'Text', + text: '\u200B', + format: { fontSize: '10pt' }, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10pt' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + it('Letter input, table selection, no modifier key, deleteSelection returns range', () => { getDOMSelectionSpy.and.returnValue({ type: 'table', @@ -159,7 +251,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -188,7 +284,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -273,7 +373,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -338,7 +442,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 9bcaabf2f3a..9fa5393fe0d 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -9,6 +9,7 @@ import { initEditor } from '../TestHelper'; import { ContentModelDocument, ContentModelFormatter, + DOMEventRecord, EditorEnvironment, FormatContentModelOptions, IEditor, @@ -64,7 +65,6 @@ describe('ImageEditPlugin', () => { }; let editor: IEditor; let mockedEnvironment: EditorEnvironment; - let attachDomEventSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let focusSpy: jasmine.Spy; @@ -76,8 +76,9 @@ describe('ImageEditPlugin', () => { let setEditorStyleSpy: jasmine.Spy; let triggerEventSpy: jasmine.Spy; let getAttributeSpy: jasmine.Spy; + let domEvents: Record = {}; + beforeEach(() => { - attachDomEventSpy = jasmine.createSpy('attachDomEvent'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); mockedEnvironment = { isSafari: false, @@ -124,7 +125,9 @@ describe('ImageEditPlugin', () => { }); editor = { getEnvironment: () => mockedEnvironment, - attachDomEvent: attachDomEventSpy, + attachDomEvent: (eventMap: Record) => { + domEvents = eventMap; + }, getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, focus: focusSpy, @@ -560,6 +563,35 @@ describe('ImageEditPlugin', () => { plugin.dispose(); }); + it('dragImage only', () => { + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + const draggedImage = document.createElement('img'); + draggedImage.id = 'image_0'; + triggerEventSpy.and.callThrough(); + domEvents.dragstart?.beforeDispatch?.({ + target: draggedImage, + } as any); + expect(draggedImage.id).toBe('image_0_dragging'); + plugin.dispose(); + }); + + it('dragImage at same place', () => { + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + const draggedImage = document.createElement('img'); + draggedImage.id = 'image_0'; + triggerEventSpy.and.callThrough(); + domEvents.dragstart?.beforeDispatch?.({ + target: draggedImage, + } as any); + domEvents.dragend?.beforeDispatch?.({ + target: draggedImage, + } as any); + expect(draggedImage.id).toBe('image_0'); + plugin.dispose(); + }); + it('flip setEditorStyle', () => { const model: ContentModelDocument = { blockGroupType: 'Document', 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/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 215f43d77df..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, @@ -424,7 +425,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'; @@ -459,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-content-model-types/lib/parameter/TrustedHTMLHandler.ts b/packages/roosterjs-content-model-types/lib/parameter/TrustedHTMLHandler.ts index 05785843669..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,17 @@ /** + * @deprecated Use DOMCreator instead * 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..082368f5489 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 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