diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 4fa758d60ee..c6615a0089c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -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'; @@ -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'); @@ -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( @@ -88,7 +98,7 @@ export default function insertEntity( normalizeContentModel(model); - context.skipUndoSnapshot = skipUndoSnapshot; + context.skipUndoSnapshot = true; context.newEntities.push(entityModel); return true; @@ -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; } diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index f8a8f3619e2..475163b52e5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -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'; @@ -39,6 +40,7 @@ describe('insertEntity', () => { appendChildSpy = jasmine.createSpy('appendChildSpy'); insertEntityModelSpy = spyOn(insertEntityModel, 'insertEntityModel'); isDarkModeSpy = jasmine.createSpy('isDarkMode'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); wrapper = { style: { @@ -68,6 +70,7 @@ describe('insertEntity', () => { getDocument: getDocumentSpy, isDarkMode: isDarkModeSpy, formatContentModel: formatWithContentModelSpy, + takeSnapshot: takeSnapshotSpy, } as any; spyOn(entityUtils, 'addDelimiters').and.returnValue([]); @@ -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(); @@ -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%'); @@ -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'); @@ -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(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index d7d1d2c5f6e..3e01ee49079 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -42,9 +42,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) if (shouldAddSnapshot) { core.undo.isNested = true; - if (core.undo.snapshotsManager.hasNewContent || entityStates) { - core.api.addUndoSnapshot(core, !!canUndoByBackspace); - } + core.api.addUndoSnapshot(core, !!canUndoByBackspace, entityStates); } try { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index ec0cf75cb08..460ef490da6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -11,12 +11,11 @@ import { getAllEntityWrappers, getObjectKeys, isEntityElement, - parseEntityClassName, + parseEntityFormat, } from 'roosterjs-content-model-dom'; import type { ChangedEntity, ContentChangedEvent, - ContentModelEntityFormat, EntityOperation, EntityPluginState, IEditor, @@ -209,16 +208,19 @@ class EntityPlugin implements PluginWithState { 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', }); } @@ -244,10 +246,7 @@ class EntityPlugin implements PluginWithState { 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', { diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts index 03485506e05..5477ab1dcdf 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts @@ -27,6 +27,7 @@ import type { EditorOptions, TrustedHTMLHandler, Rect, + EntityState, } from 'roosterjs-content-model-types'; /** @@ -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 + ); } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts index cfe4d25f59e..338d076295a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts @@ -52,8 +52,9 @@ class SnapshotsManagerImpl implements SnapshotsManager { currentSnapshot.html == snapshot.html && !currentSnapshot.entityStates && !snapshot.entityStates; + const addSnapshot = !currentSnapshot || shouldAddSnapshot(currentSnapshot, snapshot); - if (this.snapshots.currentIndex < 0 || !currentSnapshot || !isSameSnapshot) { + if (this.snapshots.currentIndex < 0 || addSnapshot) { this.clearRedo(); this.snapshots.snapshots.push(snapshot); this.snapshots.currentIndex++; @@ -129,3 +130,13 @@ class SnapshotsManagerImpl implements SnapshotsManager { export function createSnapshotsManager(snapshots?: Snapshots): SnapshotsManager { return new SnapshotsManagerImpl(snapshots); } + +function shouldAddSnapshot(currentSnapshot: Snapshot, snapshot: Snapshot) { + return ( + currentSnapshot.html !== snapshot.html || + (currentSnapshot.entityStates && + snapshot.entityStates && + currentSnapshot.entityStates !== snapshot.entityStates) || + (!currentSnapshot.entityStates && snapshot.entityStates) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts index 0b48a50f51e..6b58dc828e9 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts @@ -38,9 +38,6 @@ export function deleteBlock( : undefined; if (operation !== undefined) { - const wrapper = blockToDelete.wrapper; - - wrapper.parentNode?.removeChild(wrapper); replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); context?.deletedEntities.push({ entity: blockToDelete, diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts index 64bccd128ff..1f35bb9bdcd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts @@ -48,9 +48,6 @@ export function deleteSegment( ? 'removeFromEnd' : undefined; if (operation !== undefined) { - const wrapper = segmentToDelete.wrapper; - - wrapper.parentNode?.removeChild(wrapper); segments.splice(index, 1); context?.deletedEntities.push({ entity: segmentToDelete, diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts index 1e8a67da11c..ad4a2e1a5f7 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts @@ -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'; @@ -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)) { @@ -93,6 +84,7 @@ function tryGetEntityElement( return result; } + function isBlockEntityContainer(node: HTMLElement) { return node.classList.contains(BlockEntityContainer); } @@ -101,14 +93,16 @@ function tryGetEntityFromContainer( element: HTMLElement, entityMap: Record ): 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; } diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index 40a382043e4..884709a8055 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -93,7 +93,7 @@ describe('formatContentModel', () => { newImages: [], }); expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -725,7 +725,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -750,7 +750,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -771,7 +771,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -800,7 +800,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 010938c9869..0f9bf77b654 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -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, @@ -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 = { @@ -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, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts index 0effbb59304..591c3df519b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -431,7 +431,7 @@ describe('Editor', () => { const snapshot = editor.takeSnapshot(); - expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, undefined); expect(snapshot).toBe(mockedSnapshot); editor.dispose(); @@ -465,6 +465,38 @@ describe('Editor', () => { expect(() => editor.takeSnapshot()).toThrow(); }); + it('takeSnapshot', () => { + const div = document.createElement('div'); + const mockedSnapshot = 'SNAPSHOT' as any; + const resetSpy = jasmine.createSpy('reset'); + const addUndoSnapshotSpy = jasmine + .createSpy('addUndoSnapshot') + .and.returnValue(mockedSnapshot); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { addUndoSnapshot: addUndoSnapshotSpy, setContentModel: setContentModelSpy }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + const snapshot = editor.takeSnapshot(); + + expect(snapshot).toEqual(mockedSnapshot); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, undefined); + + const mockedState = 'STATE' as any; + + editor.takeSnapshot(mockedState); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(2); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, [mockedState]); + }); + it('restoreSnapshot', () => { const div = document.createElement('div'); const mockedSnapshot = 'SNAPSHOT' as any; diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts index 70d0d6ec426..636f850d0b4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts @@ -300,6 +300,171 @@ describe('SnapshotsManagerImpl.addSnapshot', () => { ]); }); + it('Add snapshot with entity state with equal entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + + it('Add snapshot with entity state with different entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + const mockedEntityStates2 = 'ENTITYSTATES2' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + ]); + }); + + it('Add snapshot without entity state after a snapshot with empty state', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + it('Has onChanged', () => { const onChanged = jasmine.createSpy('onChanged'); snapshots.onChanged = onChanged; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index a95979139dd..2579f769c37 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -32,12 +32,33 @@ export function getAllEntityWrappers(root: HTMLElement): HTMLElement[] { return toArray(root.querySelectorAll('.' + ENTITY_INFO_NAME)) as HTMLElement[]; } +/** + * Parse entity format from entity wrapper element + * @param wrapper The wrapper element to parse entity format from + * @returns Entity format + */ +export function parseEntityFormat(wrapper: HTMLElement): ContentModelEntityFormat { + let isEntity = false; + const format: ContentModelEntityFormat = {}; + + wrapper.classList.forEach(name => { + isEntity = parseEntityClassName(name, format) || isEntity; + }); + + if (!isEntity) { + format.isFakeEntity = true; + format.isReadonly = !wrapper.isContentEditable; + } + + return format; +} + /** * Parse entity class names from entity wrapper element * @param className Class names of entity * @param format The output entity format object */ -export function parseEntityClassName( +function parseEntityClassName( className: string, format: ContentModelEntityFormat ): boolean | undefined { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts index 282bad51333..471c94ece4a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts @@ -11,10 +11,12 @@ import { isEntityElement } from './entityUtils'; */ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | null): Node | null { if (element.parentNode == parent) { + const isEntity = isEntityElement(element); + // 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 && !isEntityElement(refNode)) { + while (refNode && refNode != element && (isEntity || !isEntityElement(refNode))) { const next = refNode.nextSibling; refNode.parentNode?.removeChild(refNode); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts index 2c360c3f243..d1192fda95b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts @@ -1,4 +1,4 @@ -import { generateEntityClassNames, parseEntityClassName } from '../../domUtils/entityUtils'; +import { generateEntityClassNames, parseEntityFormat } from '../../domUtils/entityUtils'; import type { EntityInfoFormat, IdFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; @@ -7,16 +7,7 @@ import type { FormatHandler } from '../FormatHandler'; */ export const entityFormatHandler: FormatHandler = { parse: (format, element) => { - let isEntity = false; - - element.classList.forEach(name => { - isEntity = parseEntityClassName(name, format) || isEntity; - }); - - if (!isEntity) { - format.isFakeEntity = true; - format.isReadonly = !element.isContentEditable; - } + Object.assign(format, parseEntityFormat(element)); }, apply: (format, element) => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 0ffe238f6e4..b8f909736da 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -24,7 +24,7 @@ export { wrap } from './domUtils/wrap'; export { isEntityElement, getAllEntityWrappers, - parseEntityClassName, + parseEntityFormat, generateEntityClassNames, addDelimiters, isEntityDelimiter, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts index 74d896e4da0..686a73ed507 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts @@ -5,7 +5,7 @@ import { getAllEntityWrappers, isEntityDelimiter, isEntityElement, - parseEntityClassName, + parseEntityFormat, } from '../../lib/domUtils/entityUtils'; export function setEntityElementClasses( @@ -43,69 +43,61 @@ describe('isEntityElement', () => { }); }); -describe('parseEntityClassName', () => { +describe('parseEntityFormat', () => { it('No entity class', () => { - const format: ContentModelEntityFormat = {}; + const div = document.createElement('div'); + + div.className = 'test'; - const result = parseEntityClassName('test', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); - expect(format).toEqual({}); + expect(format).toEqual({ + isFakeEntity: true, + isReadonly: true, + }); }); it('Entity class', () => { - const format: ContentModelEntityFormat = {}; - - const result = parseEntityClassName('_Entity', format); - - expect(result).toBeTrue(); - expect(format).toEqual({}); - }); + const div = document.createElement('div'); - it('EntityId class', () => { - const format: ContentModelEntityFormat = {}; + div.className = '_Entity _EId_A _EType_B _EReadonly_1'; - const result = parseEntityClassName('_EId_A', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ id: 'A', + entityType: 'B', + isReadonly: true, }); }); - it('EntityType class', () => { - const format: ContentModelEntityFormat = {}; + it('Fake entity', () => { + const div = document.createElement('div'); - const result = parseEntityClassName('_EType_B', format); + div.contentEditable = 'true'; - expect(result).toBeFalsy(); - expect(format).toEqual({ - entityType: 'B', - }); - }); - - it('Entity readonly class', () => { - const format: ContentModelEntityFormat = {}; + div.className = '_EId_A _EType_B _EReadonly_1'; - const result = parseEntityClassName('_EReadonly_1', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ - isReadonly: true, + isFakeEntity: true, + isReadonly: false, + id: 'A', + entityType: 'B', }); }); - it('Parse class on existing format', () => { - const format: ContentModelEntityFormat = { - id: 'A', - }; + it('Fake entity, readonly', () => { + const div = document.createElement('div'); + + div.contentEditable = 'false'; - const result = parseEntityClassName('_EType_B', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ - id: 'A', - entityType: 'B', + isFakeEntity: true, + isReadonly: true, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts index 7a834034f9f..1f612a8c6cb 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts @@ -66,6 +66,7 @@ describe('reuseCachedElement', () => { const refNode = document.createElement('div'); const element = document.createElement('span'); const nextNode = document.createElement('br'); + const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); parent.appendChild(refNode); parent.appendChild(element); @@ -75,6 +76,7 @@ describe('reuseCachedElement', () => { const result = reuseCachedElement(parent, element, refNode); + expect(removeChildSpy).not.toHaveBeenCalled(); expect(parent.outerHTML).toBe( '

' ); @@ -82,4 +84,31 @@ describe('reuseCachedElement', () => { expect(parent.firstChild?.nextSibling).toBe(refNode); expect(result).toBe(refNode); }); + + it('RefNode is entity, current element is entity', () => { + const parent = document.createElement('div'); + const refNode = document.createElement('div'); + const element = document.createElement('span'); + const nextNode = document.createElement('br'); + const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); + + parent.appendChild(refNode); + parent.appendChild(element); + parent.appendChild(nextNode); + + setEntityElementClasses(refNode, 'TestEntity', true); + setEntityElementClasses(element, 'TestEntity2', true); + + const result = reuseCachedElement(parent, element, refNode); + + expect(removeChildSpy).toHaveBeenCalledTimes(1); + expect(removeChildSpy).toHaveBeenCalledWith(refNode); + + expect(parent.outerHTML).toBe( + '

' + ); + expect(parent.firstChild).toBe(element); + expect(parent.firstChild?.nextSibling).toBe(nextNode); + expect(result).toBe(nextNode); + }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts index 88b194a9a35..301a2367ace 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -17,6 +17,7 @@ import type { import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; +import type { EntityState } from '../parameter/FormatContentModelContext'; /** * An interface of Editor, built on top of Content Model @@ -125,8 +126,9 @@ export interface 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; /** * Restore an undo snapshot into editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts index e936fd88255..8844a0c1e9c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts @@ -21,4 +21,9 @@ export interface InsertEntityOptions { * Whether skip adding an undo snapshot around */ skipUndoSnapshot?: boolean; + + /** + * Initial entity state, this is used when restore an undo snapshot to right after entity is inserted, this state will be used for set initial state of entity + */ + initialEntityState?: string; }