Skip to content

Commit

Permalink
#2859 Support changing format inside entity
Browse files Browse the repository at this point in the history
  • Loading branch information
JiuqingSong committed Nov 29, 2024
1 parent f6b5dca commit deec88a
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 13 deletions.
10 changes: 10 additions & 0 deletions demo/scripts/controlsV2/plugins/SampleEntityPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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];
Expand All @@ -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();
Expand All @@ -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);
});
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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, 'test1<span>test2</span>test3');
});

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,
'test1<span><span style="font-family: test;">test2</span></span>test3'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
});

Expand Down
Loading

0 comments on commit deec88a

Please sign in to comment.