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