Skip to content

Commit

Permalink
Standalone editor: decouple entity (#2107)
Browse files Browse the repository at this point in the history
* Standalone editor: decouple entity

* fix build

* fix build

* improve

* fix build

* add test
  • Loading branch information
JiuqingSong authored Sep 30, 2023
1 parent ac0a912 commit 43a947b
Show file tree
Hide file tree
Showing 44 changed files with 765 additions and 271 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,27 @@ const styles = require('./ContentModelEntityView.scss');
export function ContentModelEntityView(props: { entity: ContentModelEntity }) {
const { entity } = props;

const [id, setId] = useProperty(entity.id);
const [isReadonly, setIsReadonly] = useProperty(entity.isReadonly);
const [type, setType] = useProperty(entity.type);
const [id, setId] = useProperty(entity.entityFormat.id);
const [isReadonly, setIsReadonly] = useProperty(entity.entityFormat.isReadonly);
const [type, setType] = useProperty(entity.entityFormat.entityType);

const idTextBox = React.useRef<HTMLInputElement>(null);
const isReadonlyCheckBox = React.useRef<HTMLInputElement>(null);
const typeTextBox = React.useRef<HTMLInputElement>(null);

const onIdChange = React.useCallback(() => {
const newValue = idTextBox.current.value;
entity.id = newValue;
entity.entityFormat.id = newValue;
setId(newValue);
}, [id, setId]);
const onTypeChange = React.useCallback(() => {
const newValue = typeTextBox.current.value;
entity.type = newValue;
entity.entityFormat.entityType = newValue;
setType(newValue);
}, [type, setType]);
const onReadonlyChange = React.useCallback(() => {
const newValue = isReadonlyCheckBox.current.checked;
entity.isReadonly = newValue;
entity.entityFormat.isReadonly = newValue;
setIsReadonly(newValue);
}, [id, setId]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getDelimiterFromElement, getEntityFromElement } from 'roosterjs-editor-dom';
import { getDelimiterFromElement } from 'roosterjs-editor-dom';
import { isEntityElement } from '../../domUtils/entityUtils';
import type {
DomToModelContext,
ElementProcessor,
Expand All @@ -22,8 +23,7 @@ export const elementProcessor: ElementProcessor<HTMLElement> = (group, element,
};

function tryGetProcessorForEntity(element: HTMLElement, context: DomToModelContext) {
return (element.className && getEntityFromElement(element)) ||
element.contentEditable == 'false' // For readonly element, treat as an entity
return isEntityElement(element) || element.contentEditable == 'false' // For readonly element, treat as an entity
? context.elementProcessors.entity
: null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { addBlock } from '../../modelApi/common/addBlock';
import { addSegment } from '../../modelApi/common/addSegment';
import { createEntity } from '../../modelApi/creators/createEntity';
import { getEntityFromElement } from 'roosterjs-editor-dom';
import { isBlockElement } from '../utils/isBlockElement';
import { parseFormat } from '../utils/parseFormat';
import { stackFormat } from '../utils/stackFormat';
import type { ElementProcessor } from 'roosterjs-content-model-types';

Expand All @@ -13,17 +13,15 @@ import type { ElementProcessor } from 'roosterjs-content-model-types';
* @param context DOM to Content Model context
*/
export const entityProcessor: ElementProcessor<HTMLElement> = (group, element, context) => {
const entity = getEntityFromElement(element);

// In Content Model we also treat read only element as an entity since we cannot edit it
const { id, type, isReadonly } = entity || { isReadonly: true };
const isBlockEntity = isBlockElement(element, context);

stackFormat(
context,
{ segment: isBlockEntity ? 'empty' : undefined, paragraph: 'empty' },
() => {
const entityModel = createEntity(element, isReadonly, type, context.segmentFormat, id);
const entityModel = createEntity(element, true /*isReadonly*/, context.segmentFormat);

parseFormat(element, context.formatParsers.entity, entityModel.entityFormat, context);

// TODO: Need to handle selection for editable entity
if (context.isInSelection) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { isNodeOfType } from './isNodeOfType';
import type { ContentModelEntityFormat } from 'roosterjs-content-model-types';

const ENTITY_INFO_NAME = '_Entity';
const ENTITY_TYPE_PREFIX = '_EType_';
const ENTITY_ID_PREFIX = '_EId_';
const ENTITY_READONLY_PREFIX = '_EReadonly_';

/**
* @internal
*/
export function isEntityElement(node: Node): boolean {
return isNodeOfType(node, 'ELEMENT_NODE') && node.classList.contains(ENTITY_INFO_NAME);
}

/**
* @internal
*/
export function parseEntityClassName(
className: string,
format: ContentModelEntityFormat
): boolean | undefined {
if (className == ENTITY_INFO_NAME) {
return true;
} else if (className.indexOf(ENTITY_TYPE_PREFIX) == 0) {
format.entityType = className.substring(ENTITY_TYPE_PREFIX.length);
} else if (className.indexOf(ENTITY_ID_PREFIX) == 0) {
format.id = className.substring(ENTITY_ID_PREFIX.length);
} else if (className.indexOf(ENTITY_READONLY_PREFIX) == 0) {
format.isReadonly = className.substring(ENTITY_READONLY_PREFIX.length) == '1';
}
}

/**
* @internal
*/
export function generateEntityClassNames(format: ContentModelEntityFormat): string {
return format.isFakeEntity
? ''
: `${ENTITY_INFO_NAME} ${ENTITY_TYPE_PREFIX}${format.entityType ?? ''} ${
format.id ? `${ENTITY_ID_PREFIX}${format.id} ` : ''
}${ENTITY_READONLY_PREFIX}${format.isReadonly ? '1' : '0'}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { boxShadowFormatHandler } from './common/boxShadowFormatHandler';
import { datasetFormatHandler } from './common/datasetFormatHandler';
import { directionFormatHandler } from './block/directionFormatHandler';
import { displayFormatHandler } from './block/displayFormatHandler';
import { entityFormatHandler } from './entity/entityFormatHandler';
import { floatFormatHandler } from './common/floatFormatHandler';
import { fontFamilyFormatHandler } from './segment/fontFamilyFormatHandler';
import { fontSizeFormatHandler } from './segment/fontSizeFormatHandler';
Expand Down Expand Up @@ -60,6 +61,7 @@ const defaultFormatHandlerMap: FormatHandlers = {
float: floatFormatHandler,
fontFamily: fontFamilyFormatHandler,
fontSize: fontSizeFormatHandler,
entity: entityFormatHandler,
htmlAlign: htmlAlignFormatHandler,
id: idFormatHandler,
italic: italicFormatHandler,
Expand Down Expand Up @@ -196,6 +198,7 @@ export const defaultFormatKeysPerCategory: {
dataset: ['dataset'],
divider: [...sharedBlockFormats, ...sharedContainerFormats, 'display', 'size', 'htmlAlign'],
container: [...sharedContainerFormats, 'htmlAlign', 'size', 'display'],
entity: ['entity'],
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { generateEntityClassNames, parseEntityClassName } from '../../domUtils/entityUtils';
import type { EntityInfoFormat, IdFormat } from 'roosterjs-content-model-types';
import type { FormatHandler } from '../FormatHandler';

/**
* @internal
*/
export const entityFormatHandler: FormatHandler<EntityInfoFormat & IdFormat> = {
parse: (format, element) => {
let isEntity = false;

element.classList.forEach(name => {
isEntity = parseEntityClassName(name, format) || isEntity;
});

if (!isEntity) {
format.isFakeEntity = true;
format.isReadonly = !element.isContentEditable;
}
},

apply: (format, element) => {
if (!format.isFakeEntity) {
element.className = generateEntityClassNames(format);
}

if (format.isReadonly) {
element.contentEditable = 'false';
} else {
element.removeAttribute('contenteditable');
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,27 @@ import type { ContentModelEntity, ContentModelSegmentFormat } from 'roosterjs-co
/**
* Create a ContentModelEntity model
* @param wrapper Wrapper element of this entity
* @param isReadonly Whether this is a readonly entity
* @param type @optional Type of this entity
* @param isReadonly Whether this is a readonly entity @default true
* @param segmentFormat @optional Segment format of this entity
* @param type @optional Type of this entity
* @param id @optional Id of this entity
*/
export function createEntity(
wrapper: HTMLElement,
isReadonly: boolean,
type?: string,
isReadonly: boolean = true,
segmentFormat?: ContentModelSegmentFormat,
type?: string,
id?: string
): ContentModelEntity {
return {
segmentType: 'Entity',
blockType: 'Entity',
format: { ...segmentFormat },
id,
type,
isReadonly,
entityFormat: {
id,
entityType: type,
isReadonly,
},
wrapper,
};
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { addDelimiters, commitEntity, getObjectKeys, wrap } from 'roosterjs-editor-dom';
import { addDelimiters, getObjectKeys, wrap } from 'roosterjs-editor-dom';
import { applyFormat } from '../utils/applyFormat';
import { reuseCachedElement } from '../utils/reuseCachedElement';
import type { Entity } from 'roosterjs-editor-types';
import type {
ContentModelBlockHandler,
ContentModelEntity,
ContentModelSegmentHandler,
ModelToDomContext,
} from 'roosterjs-content-model-types';

/**
Expand All @@ -19,7 +17,9 @@ export const handleEntityBlock: ContentModelBlockHandler<ContentModelEntity> = (
context,
refNode
) => {
const wrapper = preprocessEntity(entityModel, context);
let { entityFormat, wrapper } = entityModel;

applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context);

refNode = reuseCachedElement(parent, wrapper, refNode);
context.onNodeCreated?.(entityModel, wrapper);
Expand All @@ -37,8 +37,7 @@ export const handleEntitySegment: ContentModelSegmentHandler<ContentModelEntity>
context,
newSegments
) => {
const wrapper = preprocessEntity(entityModel, context);
const { format, isReadonly } = entityModel;
let { entityFormat, wrapper, format } = entityModel;

parent.appendChild(wrapper);
newSegments?.push(wrapper);
Expand All @@ -49,7 +48,9 @@ export const handleEntitySegment: ContentModelSegmentHandler<ContentModelEntity>
applyFormat(span, context.formatAppliers.segment, format, context);
}

if (context.addDelimiterForEntity && isReadonly) {
applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context);

if (context.addDelimiterForEntity && entityFormat.isReadonly) {
const [after, before] = addDelimiters(wrapper);

newSegments?.push(after, before);
Expand All @@ -60,23 +61,3 @@ export const handleEntitySegment: ContentModelSegmentHandler<ContentModelEntity>

context.onNodeCreated?.(entityModel, wrapper);
};

function preprocessEntity(entityModel: ContentModelEntity, context: ModelToDomContext) {
let { id, type, isReadonly, wrapper } = entityModel;

const entity: Entity | null =
id && type
? {
wrapper,
id,
type,
isReadonly: !!isReadonly,
}
: null;

if (entity) {
// Commit the entity attributes in case there is any change
commitEntity(wrapper, entity.type, entity.isReadonly, entity.id);
}
return wrapper;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { EntityClasses } from 'roosterjs-editor-types';
import { isNodeOfType } from '../../domUtils/isNodeOfType';
import { isEntityElement } from '../../domUtils/entityUtils';
import { mergeNode } from './mergeNode';
import { removeUnnecessarySpan } from './removeUnnecessarySpan';

Expand All @@ -10,10 +9,7 @@ export function optimize(root: Node) {
/**
* Do no do any optimization to entity
*/
if (
isNodeOfType(root, 'ELEMENT_NODE') &&
root.classList.contains(EntityClasses.ENTITY_INFO_NAME)
) {
if (isEntityElement(root)) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getEntityFromElement } from 'roosterjs-editor-dom';
import { isNodeOfType } from '../../domUtils/isNodeOfType';
import { isEntityElement } from '../../domUtils/entityUtils';

/**
* @internal
Expand All @@ -9,7 +8,7 @@ export function reuseCachedElement(parent: Node, element: Node, refNode: Node |
// Remove nodes before the one we are hitting since they don't appear in Content Model at this position.
// But we don't want to touch entity since it would better to keep entity at its place unless it is removed
// In that case we will remove it after we have handled all other nodes
while (refNode && refNode != element && !isEntity(refNode)) {
while (refNode && refNode != element && !isEntityElement(refNode)) {
const next = refNode.nextSibling;

refNode.parentNode?.removeChild(refNode);
Expand Down Expand Up @@ -37,7 +36,3 @@ export function removeNode(node: Node): Node | null {

return next;
}

function isEntity(node: Node) {
return isNodeOfType(node, 'ELEMENT_NODE') && !!getEntityFromElement(node);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as getDelimiterFromElement from 'roosterjs-editor-dom/lib/delimiter/getDelimiterFromElement';
import { commitEntity } from 'roosterjs-editor-dom';
import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument';
import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext';
import { elementProcessor } from '../../../lib/domToModel/processors/elementProcessor';
import { setEntityElementClasses } from '../../domUtils/entityUtilTest';
import {
ContentModelDocument,
DomToModelContext,
Expand Down Expand Up @@ -59,7 +59,7 @@ describe('elementProcessor', () => {
it('Entity', () => {
const div = document.createElement('div');

commitEntity(div, 'entity', true, 'entity_1');
setEntityElementClasses(div, 'entity', true, 'entity_1');

elementProcessor(group, div, context);

Expand Down
Loading

0 comments on commit 43a947b

Please sign in to comment.