Skip to content

Commit

Permalink
Improve Entity State related API (#2444)
Browse files Browse the repository at this point in the history
* Improve Entity State related API

* fix build and test

* add test

---------

Co-authored-by: Bryan Valverde U <bvalverde@microsoft.com>
  • Loading branch information
JiuqingSong and BryanValverdeU authored Feb 29, 2024
1 parent 743de92 commit 78b498a
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 95 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { ChangeSource } from 'roosterjs-content-model-core';
import { createEntity, normalizeContentModel } from 'roosterjs-content-model-dom';
import { insertEntityModel } from '../../modelApi/entity/insertEntityModel';
import {
createEntity,
normalizeContentModel,
parseEntityFormat,
} from 'roosterjs-content-model-dom';
import type {
ContentModelEntity,
DOMSelection,
InsertEntityPosition,
InsertEntityOptions,
IEditor,
EntityState,
} from 'roosterjs-content-model-types';

const BlockEntityTag = 'div';
Expand Down Expand Up @@ -57,7 +62,8 @@ export default function insertEntity(
position?: InsertEntityPosition | DOMSelection,
options?: InsertEntityOptions
): ContentModelEntity | null {
const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {};
const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot, initialEntityState } =
options || {};
const document = editor.getDocument();
const wrapper = document.createElement(isBlock ? BlockEntityTag : InlineEntityTag);
const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block');
Expand All @@ -75,6 +81,10 @@ export default function insertEntity(

const entityModel = createEntity(wrapper, true /* isReadonly */, undefined /*format*/, type);

if (!skipUndoSnapshot) {
editor.takeSnapshot();
}

editor.formatContentModel(
(model, context) => {
insertEntityModel(
Expand All @@ -88,7 +98,7 @@ export default function insertEntity(

normalizeContentModel(model);

context.skipUndoSnapshot = skipUndoSnapshot;
context.skipUndoSnapshot = true;
context.newEntities.push(entityModel);

return true;
Expand All @@ -106,5 +116,25 @@ export default function insertEntity(
}
);

if (!skipUndoSnapshot) {
let entityState: EntityState | undefined;

if (initialEntityState) {
const format = parseEntityFormat(wrapper);
const { id, entityType } = format;

entityState =
id && entityType
? {
id: id,
type: entityType,
state: initialEntityState,
}
: undefined;
}

editor.takeSnapshot(entityState);
}

return entityModel;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('insertEntity', () => {
let insertEntityModelSpy: jasmine.Spy;
let isDarkModeSpy: jasmine.Spy;
let normalizeContentModelSpy: jasmine.Spy;
let takeSnapshotSpy: jasmine.Spy;

const type = 'Entity';
const apiName = 'insertEntity';
Expand All @@ -39,6 +40,7 @@ describe('insertEntity', () => {
appendChildSpy = jasmine.createSpy('appendChildSpy');
insertEntityModelSpy = spyOn(insertEntityModel, 'insertEntityModel');
isDarkModeSpy = jasmine.createSpy('isDarkMode');
takeSnapshotSpy = jasmine.createSpy('takeSnapshot');

wrapper = {
style: {
Expand Down Expand Up @@ -68,6 +70,7 @@ describe('insertEntity', () => {
getDocument: getDocumentSpy,
isDarkMode: isDarkModeSpy,
formatContentModel: formatWithContentModelSpy,
takeSnapshot: takeSnapshotSpy,
} as any;

spyOn(entityUtils, 'addDelimiters').and.returnValue([]);
Expand All @@ -76,6 +79,9 @@ describe('insertEntity', () => {
it('insert inline entity to top', () => {
const entity = insertEntity(editor, type, false, 'begin');

expect(takeSnapshotSpy).toHaveBeenCalledTimes(2);
expect(takeSnapshotSpy).toHaveBeenCalledWith();
expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined);
expect(createElementSpy).toHaveBeenCalledWith('span');
expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block');
expect(appendChildSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -120,6 +126,9 @@ describe('insertEntity', () => {
it('block inline entity to root', () => {
const entity = insertEntity(editor, type, true, 'root');

expect(takeSnapshotSpy).toHaveBeenCalledTimes(2);
expect(takeSnapshotSpy).toHaveBeenCalledWith();
expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined);
expect(createElementSpy).toHaveBeenCalledWith('div');
expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block');
expect(setPropertySpy).toHaveBeenCalledWith('width', '100%');
Expand Down Expand Up @@ -172,6 +181,7 @@ describe('insertEntity', () => {
wrapperDisplay: 'none',
});

expect(takeSnapshotSpy).toHaveBeenCalledTimes(0);
expect(createElementSpy).toHaveBeenCalledWith('div');
expect(setPropertySpy).toHaveBeenCalledWith('display', 'none');
expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block');
Expand Down Expand Up @@ -221,6 +231,81 @@ describe('insertEntity', () => {

const entity = insertEntity(editor, type, false, 'begin');

expect(takeSnapshotSpy).toHaveBeenCalledTimes(2);
expect(takeSnapshotSpy).toHaveBeenCalledWith();
expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined);
expect(createElementSpy).toHaveBeenCalledWith('span');
expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block');
expect(appendChildSpy).not.toHaveBeenCalled();
expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName);
expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toBe(
ChangeSource.InsertEntity
);
expect(insertEntityModelSpy).toHaveBeenCalledWith(
model,
{
segmentType: 'Entity',
blockType: 'Entity',
format: {},
entityFormat: {
id: undefined,
entityType: type,
isReadonly: true,
},
wrapper: wrapper,
},
'begin',
false,
undefined,
context
);
expect(triggerContentChangedEventSpy).not.toHaveBeenCalled();
expect(normalizeContentModelSpy).toHaveBeenCalled();

expect(context.newEntities).toEqual([
{
segmentType: 'Entity',
blockType: 'Entity',
format: {},
entityFormat: {
id: undefined,
entityType: 'Entity',
isReadonly: true,
},
wrapper,
},
]);

expect(entity).toEqual({
segmentType: 'Entity',
blockType: 'Entity',
format: {},
entityFormat: {
id: undefined,
entityType: type,
isReadonly: true,
},
wrapper: wrapper,
});
});

it('Insert with initial state', () => {
spyOn(entityUtils, 'parseEntityFormat').and.returnValue({
id: 'A',
entityType: 'B',
});

const entity = insertEntity(editor, type, false, 'begin', {
initialEntityState: 'test',
});

expect(takeSnapshotSpy).toHaveBeenCalledTimes(2);
expect(takeSnapshotSpy).toHaveBeenCalledWith();
expect(takeSnapshotSpy).toHaveBeenCalledWith({
id: 'A',
type: 'B',
state: 'test',
});
expect(createElementSpy).toHaveBeenCalledWith('span');
expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block');
expect(appendChildSpy).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ import {
getAllEntityWrappers,
getObjectKeys,
isEntityElement,
parseEntityClassName,
parseEntityFormat,
} from 'roosterjs-content-model-dom';
import type {
ChangedEntity,
ContentChangedEvent,
ContentModelEntityFormat,
EntityOperation,
EntityPluginState,
IEditor,
Expand Down Expand Up @@ -209,16 +208,19 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
result.splice(index, 1);
} else {
// Entity is not in editor, which means it is deleted, use a temporary entity here to represent this entity
const tempEntity = createEntity(entry.element);
let isEntity = false;

entry.element.classList.forEach(name => {
isEntity = parseEntityClassName(name, tempEntity.entityFormat) || isEntity;
});
const format = parseEntityFormat(entry.element);

if (!format.isFakeEntity) {
const entity = createEntity(
entry.element,
format.isReadonly,
{},
format.entityType,
format.id
);

if (isEntity) {
result.push({
entity: tempEntity,
entity: entity,
operation: 'overwrite',
});
}
Expand All @@ -244,10 +246,7 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
rawEvent?: Event,
state?: string
) {
const format: ContentModelEntityFormat = {};
wrapper.classList.forEach(name => {
parseEntityClassName(name, format);
});
const format = parseEntityFormat(wrapper);

return format.id && format.entityType && !format.isFakeEntity
? editor.triggerEvent('entityOperation', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
EditorOptions,
TrustedHTMLHandler,
Rect,
EntityState,
} from 'roosterjs-content-model-types';

/**
Expand Down Expand Up @@ -174,11 +175,16 @@ export class Editor implements IEditor {

/**
* Add a single undo snapshot to undo stack
* @param entityState @optional State for entity if we want to add entity state for this snapshot
*/
takeSnapshot(): Snapshot | null {
takeSnapshot(entityState?: EntityState): Snapshot | null {
const core = this.getCore();

return core.api.addUndoSnapshot(core, false /*canUndoByBackspace*/);
return core.api.addUndoSnapshot(
core,
false /*canUndoByBackspace*/,
entityState ? [entityState] : undefined
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@ import {
getAllEntityWrappers,
isEntityElement,
isNodeOfType,
parseEntityClassName,
parseEntityFormat,
reuseCachedElement,
} from 'roosterjs-content-model-dom';
import type {
Snapshot,
EditorCore,
KnownEntityItem,
ContentModelEntityFormat,
} from 'roosterjs-content-model-types';
import type { Snapshot, EditorCore, KnownEntityItem } from 'roosterjs-content-model-types';

const BlockEntityContainer = '_E_EBlockEntityContainer';

Expand Down Expand Up @@ -79,11 +74,7 @@ function tryGetEntityElement(

if (isNodeOfType(node, 'ELEMENT_NODE')) {
if (isEntityElement(node)) {
const format: ContentModelEntityFormat = {};

node.classList.forEach(name => {
parseEntityClassName(name, format);
});
const format = parseEntityFormat(node);

result = (format.id && entityMap[format.id]?.element) || null;
} else if (isBlockEntityContainer(node)) {
Expand All @@ -93,6 +84,7 @@ function tryGetEntityElement(

return result;
}

function isBlockEntityContainer(node: HTMLElement) {
return node.classList.contains(BlockEntityContainer);
}
Expand All @@ -101,14 +93,16 @@ function tryGetEntityFromContainer(
element: HTMLElement,
entityMap: Record<string, KnownEntityItem>
): HTMLElement | null {
const format: ContentModelEntityFormat = {};
element.childNodes.forEach(node => {
for (let node = element.firstChild; node; node = node.nextSibling) {
if (isEntityElement(node) && isNodeOfType(node, 'ELEMENT_NODE')) {
node.classList.forEach(name => parseEntityClassName(name, format));
}
});
const format = parseEntityFormat(node);
const parent = format.id ? entityMap[format.id]?.element.parentElement : null;

const parent = format.id ? entityMap[format.id]?.element.parentElement : null;
return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent)
? parent
: null;
}
}

return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent) ? parent : null;
return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ describe('EntityPlugin', () => {
it('Click on entity', () => {
const mockedNode = {
parentNode: null as any,
classList: ['_ENtity', '_EType_A', '_EId_A'],
classList: ['_Entity', '_EType_A', '_EId_A'],
} as any;
const mockedEvent = {
target: mockedNode,
Expand Down Expand Up @@ -631,7 +631,7 @@ describe('EntityPlugin', () => {
it('Click on child of entity', () => {
const mockedNode1 = {
parentNode: null as any,
classList: ['_ENtity', '_EType_A', '_EId_A'],
classList: ['_Entity', '_EType_A', '_EId_A'],
} as any;

const mockedNode2 = {
Expand Down Expand Up @@ -667,7 +667,7 @@ describe('EntityPlugin', () => {
it('Not clicking', () => {
const mockedNode = {
parentNode: null as any,
classList: ['_ENtity', '_EType_A', '_EId_A'],
classList: ['_Entity', '_EType_A', '_EId_A'],
} as any;
const mockedEvent = {
target: mockedNode,
Expand Down
Loading

0 comments on commit 78b498a

Please sign in to comment.