diff --git a/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts b/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts
index dc0fe02b5c4..84a559e0ba4 100644
--- a/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts
+++ b/demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts
@@ -63,6 +63,16 @@ export default class SampleEntityPlugin implements EditorPlugin {
}
break;
+
+ case 'beforeFormat':
+ const span = entity.wrapper.querySelector('span');
+
+ if (span && event.formattableRoots) {
+ event.formattableRoots.push({
+ element: span,
+ });
+ }
+ break;
}
}
}
diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts
index 1966f591fcf..8b0d5d403a8 100644
--- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts
+++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts
@@ -1,8 +1,20 @@
import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection';
-import { getSelectedSegmentsAndParagraphs, mergeTextSegments } from 'roosterjs-content-model-dom';
+import {
+ contentModelToDom,
+ createDomToModelContext,
+ createModelToDomContext,
+ domToContentModel,
+ getSelectedSegmentsAndParagraphs,
+ mergeTextSegments,
+} from 'roosterjs-content-model-dom';
import type {
+ ContentModelDocument,
+ ContentModelEntity,
ContentModelSegmentFormat,
+ EditorContext,
+ FormattableRoot,
IEditor,
+ PluginEventData,
ReadonlyContentModelDocument,
ShallowMutableContentModelParagraph,
ShallowMutableContentModelSegment,
@@ -39,13 +51,14 @@ export function formatSegmentWithContentModel(
let segmentAndParagraphs = getSelectedSegmentsAndParagraphs(
model,
!!includingFormatHolder,
- false /*includingEntity*/,
+ true /*includingEntity*/,
true /*mutate*/
);
let isCollapsedSelection =
segmentAndParagraphs.length >= 1 &&
segmentAndParagraphs.every(x => x[0].segmentType == 'SelectionMarker');
+ // 1. adjust selection to a word if selection is collapsed
if (isCollapsedSelection) {
const para = segmentAndParagraphs[0][1];
const path = segmentAndParagraphs[0][2];
@@ -60,30 +73,54 @@ export function formatSegmentWithContentModel(
}
}
+ // 2. expand selection for entities if any
const formatsAndSegments: [
ContentModelSegmentFormat,
ShallowMutableContentModelSegment | null,
ShallowMutableContentModelParagraph | null
- ][] = segmentAndParagraphs.map(item => [item[0].format, item[0], item[1]]);
+ ][] = [];
+ const modelsFromEntities: [
+ ContentModelEntity,
+ FormattableRoot,
+ ContentModelDocument
+ ][] = [];
+ segmentAndParagraphs.forEach(item => {
+ if (item[0].segmentType == 'Entity') {
+ expandEntitySelections(editor, item[0], formatsAndSegments, modelsFromEntities);
+ } else {
+ formatsAndSegments.push([item[0].format, item[0], item[1]]);
+ }
+ });
+
+ // 3. check if we should turn format on (when not all selection has the required format already)
+ // or off (all selections already have the required format)
const isTurningOff = segmentHasStyleCallback
? formatsAndSegments.every(([format, segment, paragraph]) =>
segmentHasStyleCallback(format, segment, paragraph)
)
: false;
+ // 4. invoke the callback function to apply the format
formatsAndSegments.forEach(([format, segment, paragraph]) => {
toggleStyleCallback(format, !isTurningOff, segment, paragraph);
});
+ // 5. after format is applied to all selections, invoke another callback to do some clean up before write the change back
afterFormatCallback?.(model);
+ // 6. finally merge segments if possible, to avoid fragmentation
formatsAndSegments.forEach(([_, __, paragraph]) => {
if (paragraph) {
mergeTextSegments(paragraph);
}
});
+ // 7. Write back models that we got from entities (if any)
+ writeBackEntities(editor, modelsFromEntities);
+
+ // 8. if the selection is still collapsed, it means we didn't actually applied format, set a pending format so it can be applied when user type
+ // otherwise, write back to editor
if (isCollapsedSelection) {
context.newPendingFormat = segmentAndParagraphs[0][0].format;
editor.focus();
@@ -97,3 +134,83 @@ export function formatSegmentWithContentModel(
}
);
}
+
+function createEditorContextForEntity(editor: IEditor, entity: ContentModelEntity): EditorContext {
+ const domHelper = editor.getDOMHelper();
+ const context: EditorContext = {
+ isDarkMode: editor.isDarkMode(),
+ defaultFormat: { ...entity.format },
+ darkColorHandler: editor.getColorManager(),
+ addDelimiterForEntity: false,
+ allowCacheElement: false,
+ domIndexer: undefined,
+ zoomScale: domHelper.calculateZoomScale(),
+ experimentalFeatures: [],
+ };
+
+ if (editor.getDocument().defaultView?.getComputedStyle(entity.wrapper).direction == 'rtl') {
+ context.isRootRtl = true;
+ }
+
+ return context;
+}
+
+function expandEntitySelections(
+ editor: IEditor,
+ entity: ContentModelEntity,
+ formatsAndSegments: [
+ ContentModelSegmentFormat,
+ ShallowMutableContentModelSegment | null,
+ ShallowMutableContentModelParagraph | null
+ ][],
+ modelsFromEntities: [ContentModelEntity, FormattableRoot, ContentModelDocument][]
+) {
+ const { id, entityType: type, isReadonly } = entity.entityFormat;
+
+ if (id && type) {
+ const formattableRoots: FormattableRoot[] = [];
+ const entityOperationEventData: PluginEventData<'entityOperation'> = {
+ entity: { id, type, isReadonly: !!isReadonly, wrapper: entity.wrapper },
+ operation: 'beforeFormat',
+ formattableRoots,
+ };
+
+ editor.triggerEvent('entityOperation', entityOperationEventData);
+
+ formattableRoots.forEach(root => {
+ if (entity.wrapper.contains(root.element)) {
+ const editorContext = createEditorContextForEntity(editor, entity);
+ const context = createDomToModelContext(editorContext, root.domToModelOptions);
+
+ // Treat everything as selected since the parent entity is selected
+ context.isInSelection = true;
+
+ const model = domToContentModel(root.element, context);
+ const selections = getSelectedSegmentsAndParagraphs(
+ model,
+ false /*includingFormatHolder*/,
+ false /*includingEntity*/,
+ true /*mutate*/
+ );
+
+ selections.forEach(item => {
+ formatsAndSegments.push([item[0].format, item[0], item[1]]);
+ });
+
+ modelsFromEntities.push([entity, root, model]);
+ }
+ });
+ }
+}
+
+function writeBackEntities(
+ editor: IEditor,
+ modelsFromEntities: [ContentModelEntity, FormattableRoot, ContentModelDocument][]
+) {
+ modelsFromEntities.forEach(([entity, root, model]) => {
+ const editorContext = createEditorContextForEntity(editor, entity);
+ const modelToDomContext = createModelToDomContext(editorContext, root.modelToDomOptions);
+
+ contentModelToDom(editor.getDocument(), root.element, model, modelToDomContext);
+ });
+}
diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts
index 31b52ffc0ad..65654c23712 100644
--- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts
+++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts
@@ -1,3 +1,5 @@
+import { EntityOperationEvent, FormattableRoot } from 'roosterjs-content-model-types';
+import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils';
import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel';
import {
ContentModelBlockFormat,
@@ -16,16 +18,22 @@ import {
createParagraph as originalCreateParagraph,
createSelectionMarker,
createText,
+ createEntity,
} from 'roosterjs-content-model-dom';
-describe('formatSegment', () => {
+describe('formatSegmentWithContentModel', () => {
let editor: IEditor;
let focus: jasmine.Spy;
let model: ContentModelDocument;
let formatContentModel: jasmine.Spy;
let formatResult: boolean | undefined;
let context: FormatContentModelContext | undefined;
+ let triggerEvent: jasmine.Spy;
+
const mockedCachedElement = 'CACHE' as any;
+ const mockedDOMHelper = {
+ calculateZoomScale: () => {},
+ } as any;
function createParagraph(
isImplicit?: boolean,
@@ -56,10 +64,17 @@ describe('formatSegment', () => {
formatResult = callback(model, context);
});
- editor = ({
+ triggerEvent = jasmine.createSpy('triggerEvent');
+
+ editor = {
focus,
formatContentModel,
- } as any) as IEditor;
+ triggerEvent,
+ getDOMHelper: () => mockedDOMHelper,
+ isDarkMode: () => false,
+ getDocument: () => document,
+ getColorManager: () => {},
+ } as any;
});
it('empty doc', () => {
@@ -326,4 +341,124 @@ describe('formatSegment', () => {
},
});
});
+
+ it('doc with entity selection, no plugin handle it', () => {
+ model = createContentModelDocument();
+
+ const div = document.createElement('div');
+ const span = document.createElement('span');
+ const text1 = document.createTextNode('test1');
+ const text2 = document.createTextNode('test2');
+ const text3 = document.createTextNode('test3');
+
+ span.appendChild(text2);
+ div.appendChild(text1);
+ div.appendChild(span);
+ div.appendChild(text3);
+
+ const entity = createEntity(div, true, {}, 'TestEntity', 'TestEntity1');
+
+ model.blocks.push(entity);
+ entity.isSelected = true;
+
+ const callback = jasmine
+ .createSpy('callback')
+ .and.callFake((format: ContentModelSegmentFormat) => {
+ format.fontFamily = 'test';
+ });
+
+ formatSegmentWithContentModel(editor, apiName, callback);
+
+ expect(model).toEqual({
+ blockGroupType: 'Document',
+ blocks: [
+ {
+ segmentType: 'Entity',
+ blockType: 'Entity',
+ format: {},
+ entityFormat: { id: 'TestEntity1', entityType: 'TestEntity', isReadonly: true },
+ wrapper: div,
+ isSelected: true,
+ },
+ ],
+ });
+ expect(formatContentModel).toHaveBeenCalledTimes(1);
+ expect(formatResult).toBeFalse();
+ expect(callback).toHaveBeenCalledTimes(0);
+ expectHtml(div.innerHTML, 'test1test2test3');
+ });
+
+ it('doc with entity selection, plugin returns formattable root', () => {
+ model = createContentModelDocument();
+
+ const div = document.createElement('div');
+ const span = document.createElement('span');
+ const text1 = document.createTextNode('test1');
+ const text2 = document.createTextNode('test2');
+ const text3 = document.createTextNode('test3');
+
+ span.appendChild(text2);
+ div.appendChild(text1);
+ div.appendChild(span);
+ div.appendChild(text3);
+
+ const entity = createEntity(div, true, {}, 'TestEntity', 'TestEntity1');
+
+ model.blocks.push(entity);
+ entity.isSelected = true;
+
+ let formattableRoots: FormattableRoot[] | undefined;
+
+ const callback = jasmine
+ .createSpy('callback')
+ .and.callFake((format: ContentModelSegmentFormat) => {
+ format.fontFamily = 'test';
+ });
+
+ triggerEvent.and.callFake((eventType: string, event: EntityOperationEvent) => {
+ expect(eventType).toBe('entityOperation');
+ expect(event.operation).toBe('beforeFormat');
+ expect(event.entity).toEqual({
+ id: 'TestEntity1',
+ type: 'TestEntity',
+ isReadonly: true,
+ wrapper: div,
+ });
+ expect(event.formattableRoots).toEqual([]);
+
+ formattableRoots = event.formattableRoots;
+ formattableRoots?.push({
+ element: span,
+ });
+ });
+
+ formatSegmentWithContentModel(editor, apiName, callback);
+
+ expect(model).toEqual({
+ blockGroupType: 'Document',
+ blocks: [
+ {
+ segmentType: 'Entity',
+ blockType: 'Entity',
+ format: {},
+ entityFormat: { id: 'TestEntity1', entityType: 'TestEntity', isReadonly: true },
+ wrapper: div,
+ isSelected: true,
+ },
+ ],
+ });
+ expect(formatContentModel).toHaveBeenCalledTimes(1);
+ expect(formatResult).toBeTrue();
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(triggerEvent).toHaveBeenCalledTimes(1);
+ expect(triggerEvent).toHaveBeenCalledWith('entityOperation', {
+ entity: { id: 'TestEntity1', type: 'TestEntity', isReadonly: true, wrapper: div },
+ operation: 'beforeFormat',
+ formattableRoots: formattableRoots,
+ });
+ expectHtml(
+ div.innerHTML,
+ 'test1test2test3'
+ );
+ });
});
diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts
index 4d6c2c8e4f4..8257dacda6b 100644
--- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts
+++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts
@@ -125,6 +125,9 @@ export function getSelectedSegmentsAndParagraphs(
}
});
}
+ } else if (block?.blockType == 'Entity' && includingEntity) {
+ // Here we treat the entity as segment since they are compatible, then it has no parent paragraph
+ result.push([block, null /*paragraph*/, path]);
}
});
diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts
index bd950e01685..768eb2ccdc4 100644
--- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts
+++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts
@@ -220,7 +220,7 @@ describe('getSelectedSegmentsAndParagraphs', () => {
);
});
- it('Include entity', () => {
+ it('Include entity - entity segment', () => {
const e1 = createEntity(null!);
const e2 = createEntity(null!, false);
const p1 = createParagraph();
@@ -243,6 +243,22 @@ describe('getSelectedSegmentsAndParagraphs', () => {
]
);
});
+
+ it('Include entity - entity block', () => {
+ const e1 = createEntity(null!);
+
+ runTest(
+ [
+ {
+ path: [],
+ block: e1,
+ },
+ ],
+ false,
+ true,
+ [[e1, null, []]]
+ );
+ });
});
describe('getSelectedParagraphs', () => {
diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts
index 762ab0eb22a..afe8d2f598d 100644
--- a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts
+++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts
@@ -1191,7 +1191,7 @@ describe('iterateSelections', () => {
expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text1, text2]);
});
- it('With selected entity', () => {
+ it('With selected entity segment', () => {
const doc = createContentModelDocument();
const para = createParagraph();
const entity = createEntity(null!);
@@ -1206,4 +1206,18 @@ describe('iterateSelections', () => {
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith([doc], undefined, para, [entity]);
});
+
+ it('With selected entity block', () => {
+ const doc = createContentModelDocument();
+ const entity = createEntity(null!);
+
+ entity.isSelected = true;
+
+ doc.blocks.push(entity);
+
+ iterateSelections(doc, callback, { includeListFormatHolder: 'never' });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith([doc], undefined, entity, undefined);
+ });
});
diff --git a/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts b/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts
index 9ec9b12dd1a..ec7df0605bd 100644
--- a/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts
+++ b/packages/roosterjs-content-model-types/lib/enum/EntityOperation.ts
@@ -58,7 +58,21 @@ export type EntityRemovalOperation =
*/
| 'overwrite';
+/**
+ * DEfine entity format related operations
+ */
+export type EntityFormatOperation =
+ /**
+ * Tell plugins we are doing format change and an entity is inside the selection.
+ * Plugin can handle this event and put root level node (must be under the entity wrapper) into
+ * event.formattableRoots so editor will create content models for each root and do format to their contents
+ */
+ 'beforeFormat';
+
/**
* Define possible operations to an entity
*/
-export type EntityOperation = EntityLifecycleOperation | EntityRemovalOperation;
+export type EntityOperation =
+ | EntityLifecycleOperation
+ | EntityRemovalOperation
+ | EntityFormatOperation;
diff --git a/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts b/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts
index fc3ba4a7062..60291716c5f 100644
--- a/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts
+++ b/packages/roosterjs-content-model-types/lib/event/EntityOperationEvent.ts
@@ -1,5 +1,7 @@
import type { BasePluginEvent } from './BasePluginEvent';
import type { EntityOperation } from '../enum/EntityOperation';
+import type { DomToModelOption } from '../context/DomToModelOption';
+import type { ModelToDomOption } from '../context/ModelToDomOption';
/**
* Represents an entity in editor.
@@ -23,9 +25,29 @@ export interface Entity {
isReadonly: boolean;
}
+/**
+ * Represent a combination of a root element under an entity and options to do DOM and content model conversion
+ */
+export interface FormattableRoot {
+ /**
+ * The root element to apply format under an entity
+ */
+ element: HTMLElement;
+
+ /**
+ * @optional DOM to Content Model option
+ */
+ domToModelOptions?: DomToModelOption;
+
+ /**
+ * @optional Content Model to DOM option
+ */
+ modelToDomOptions?: ModelToDomOption;
+}
+
/**
* Provide a chance for plugins to handle entity related events.
- * See enum EntityOperation for more details about each operation
+ * See type EntityOperation for more details about each operation
*/
export interface EntityOperationEvent extends BasePluginEvent<'entityOperation'> {
/**
@@ -44,15 +66,21 @@ export interface EntityOperationEvent extends BasePluginEvent<'entityOperation'>
rawEvent?: Event;
/**
- * For EntityOperation.UpdateEntityState, we use this object to pass the new entity state to plugin.
+ * For entity operation "updateEntityState", we use this object to pass the new entity state to plugin.
* For other operation types, it is not used.
*/
state?: string;
/**
- * For EntityOperation.NewEntity, plugin can set this property to true then the entity will be persisted.
+ * For entity operation "newEntity", plugin can set this property to true then the entity will be persisted.
* A persisted entity won't be touched during undo/redo, unless it does not exist after undo/redo.
* For other operation types, this value will be ignored.
*/
shouldPersist?: boolean;
+
+ /**
+ * For entity operation "beforeFormat" (happens when user wants to do format change), we will set this array
+ * in event and plugins can check if there is any elements inside the entity that should also apply the format
+ */
+ formattableRoots?: FormattableRoot[];
}
diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts
index 241660c5d01..06720540376 100644
--- a/packages/roosterjs-content-model-types/lib/index.ts
+++ b/packages/roosterjs-content-model-types/lib/index.ts
@@ -77,6 +77,7 @@ export {
EntityLifecycleOperation,
EntityOperation,
EntityRemovalOperation,
+ EntityFormatOperation,
} from './enum/EntityOperation';
export {
TableOperation,
@@ -463,7 +464,7 @@ export { ContextMenuEvent } from './event/ContextMenuEvent';
export { RewriteFromModelEvent } from './event/RewriteFromModelEvent';
export { EditImageEvent } from './event/EditImageEvent';
export { EditorReadyEvent } from './event/EditorReadyEvent';
-export { EntityOperationEvent, Entity } from './event/EntityOperationEvent';
+export { EntityOperationEvent, FormattableRoot, Entity } from './event/EntityOperationEvent';
export { ExtractContentWithDomEvent } from './event/ExtractContentWithDomEvent';
export { EditorInputEvent } from './event/EditorInputEvent';
export {
diff --git a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts
index 447c1b68929..9c3908706c8 100644
--- a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts
+++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts
@@ -70,6 +70,7 @@ const EntityOperationNewToOld: Record