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