diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index d238121721b..6c476eff4e5 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -26,6 +26,7 @@ export function getFormatState(editor: IEditor): ContentModelFormatState { processorOverride: { child: reducedModelChildProcessor, }, + tryGetFromCache: true, } ); diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts index 63018d7869a..ef3b5eb3ba8 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts @@ -52,34 +52,26 @@ export function formatInsertPointWithContentModel( { processorOverride: { child: getShadowChildProcessor(bundle), - '#text': getShadowTextProcessor(bundle), + textWithSelection: getShadowTextProcessor(bundle), }, + tryGetFromCache: false, } ); } -/** - * @internal Export for test only - */ -export interface InsertPointBundle { +interface InsertPointBundle { input: DOMInsertPoint; result?: InsertPoint; } -/** - * @internal Export for test only - */ -export interface DomToModelContextWithPath extends DomToModelContext { +interface DomToModelContextWithPath extends DomToModelContext { /** * Block group path of this insert point, from direct parent group to the root group */ path?: ContentModelBlockGroup[]; } -/** - * @internal Export for test only - */ -export function getShadowChildProcessor(bundle: InsertPointBundle): ElementProcessor { +function getShadowChildProcessor(bundle: InsertPointBundle): ElementProcessor { return (group, parent, context) => { const contextWithPath = context as DomToModelContextWithPath; @@ -147,10 +139,7 @@ function handleElementShadowSelection( } } -/** - * @internal export for test only - */ -export const getShadowTextProcessor = (bundle: InsertPointBundle): ElementProcessor => ( +const getShadowTextProcessor = (bundle: InsertPointBundle): ElementProcessor => ( group, textNode, context diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts index 7f490175440..f0857775e44 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts @@ -1,15 +1,11 @@ import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; +import { formatInsertPointWithContentModel } from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; import { ContentModelParagraph, ContentModelSegment, DomToModelOption, + ElementProcessor, } from 'roosterjs-content-model-types'; -import { - DomToModelContextWithPath, - formatInsertPointWithContentModel, - getShadowChildProcessor, - getShadowTextProcessor, -} from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; describe('formatInsertPointWithContentModel', () => { it('format with insertPoint', () => { @@ -25,7 +21,7 @@ describe('formatInsertPointWithContentModel', () => { .createSpy('formatContentModel') .and.callFake((callback: Function, options: any, override: DomToModelOption) => { expect(override.processorOverride?.child).toBeDefined(); - expect(override.processorOverride?.['#text']).toBeDefined(); + expect(override.processorOverride?.textWithSelection).toBeDefined(); override.processorOverride?.child!(mockedModel, node, mockedContext); @@ -45,7 +41,13 @@ describe('formatInsertPointWithContentModel', () => { expect(formatContentModelSpy).toHaveBeenCalledWith( jasmine.anything() as any, mockedOptions, - jasmine.anything() as any + { + processorOverride: { + child: jasmine.anything() as any, + textWithSelection: jasmine.anything() as any, + }, + tryGetFromCache: false, + } ); const marker = { @@ -84,7 +86,7 @@ describe('formatInsertPointWithContentModel', () => { .createSpy('formatContentModel') .and.callFake((callback: Function, options: any, override: DomToModelOption) => { expect(override.processorOverride?.child).toBeDefined(); - expect(override.processorOverride?.['#text']).toBeDefined(); + expect(override.processorOverride?.textWithSelection).toBeDefined(); override.processorOverride?.child!(mockedModel, node2, mockedContext); @@ -104,7 +106,13 @@ describe('formatInsertPointWithContentModel', () => { expect(formatContentModelSpy).toHaveBeenCalledWith( jasmine.anything() as any, mockedOptions, - jasmine.anything() as any + { + processorOverride: { + child: jasmine.anything() as any, + textWithSelection: jasmine.anything() as any, + }, + tryGetFromCache: false, + } ); expect(mockedCallback).toHaveBeenCalledWith(mockedModel, mockedContext, undefined); @@ -128,14 +136,23 @@ describe('getShadowChildProcessor', () => { div.appendChild(span2); const group = createContentModelDocument(); - const context: DomToModelContextWithPath = createDomToModelContext(); - const bundle = { - input: { + const context = createDomToModelContext(); + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + { node: div, offset: shadow, }, - }; - const processor = getShadowChildProcessor(bundle); + () => {} + ); + + const processor = formatContentModelSpy.calls.argsFor(0)[2].processorOverride + .child as ElementProcessor; context.selection = { type: 'range', @@ -204,7 +221,7 @@ describe('getShadowTextProcessor', () => { ) { const text = document.createTextNode(inputText); const group = createContentModelDocument(); - const context: DomToModelContextWithPath = createDomToModelContext(); + const context = createDomToModelContext(); context.selection = { type: 'range', @@ -221,13 +238,22 @@ describe('getShadowTextProcessor', () => { context.isInSelection = true; } - const bundle = { - input: { + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + { node: text, offset: shadowOffset, }, - }; - const processor = getShadowTextProcessor(bundle); + () => {} + ); + + const processor = formatContentModelSpy.calls.argsFor(0)[2].processorOverride + .textWithSelection as ElementProcessor; processor(group, text, context); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index 835d7032077..102c66e3b91 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -18,7 +18,8 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv // Flush all mutations if any, so that we can get an up-to-date Content Model core.cache.textMutationObserver?.flushMutations(); - let cachedModel = selectionOverride || option ? null : core.cache.cachedModel; + let cachedModel = + selectionOverride || (option && !option.tryGetFromCache) ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts index f80b25ec3d2..fc29cc407b4 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts @@ -8,6 +8,8 @@ import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model export const createEditorContext: CreateEditorContext = (core, saveIndex) => { const { lifecycle, format, darkColorHandler, logicalRoot, cache, domHelper } = core; + saveIndex = saveIndex && !core.lifecycle.shadowEditFragment; + const context: EditorContext = { isDarkMode: lifecycle.isDarkMode, defaultFormat: format.defaultFormat, diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 3eaa83c288e..7a74b953c1b 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -30,7 +30,7 @@ import type { Rect, EntityState, CachedElementHandler, - DomToModelOption, + DomToModelOptionForCreateModel, } from 'roosterjs-content-model-types'; /** @@ -100,7 +100,9 @@ export class Editor implements IEditor { switch (mode) { case 'connected': - return core.api.createContentModel(core); + return core.api.createContentModel(core, { + tryGetFromCache: true, // Pass an option here to force disable save index + }); case 'disconnected': return cloneModel( @@ -108,6 +110,7 @@ export class Editor implements IEditor { processorOverride: { table: tableProcessor, }, + tryGetFromCache: false, }), { includeCachedElement: this.cloneOptionCallback, @@ -170,7 +173,7 @@ export class Editor implements IEditor { formatContentModel( formatter: ContentModelFormatter, options?: FormatContentModelOptions, - domToModelOptions?: DomToModelOption + domToModelOptions?: DomToModelOptionForCreateModel ): void { const core = this.getCore(); diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index e53c54653a5..a6370a41559 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -1,8 +1,14 @@ import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as updateCachedSelection from '../../../lib/corePlugin/cache/updateCachedSelection'; import { createContentModel } from '../../../lib/coreApi/createContentModel/createContentModel'; -import { DomToModelContext, EditorCore } from 'roosterjs-content-model-types'; +import { + DomToModelContext, + DomToModelOptionForCreateModel, + EditorCore, + TextMutationObserver, +} from 'roosterjs-content-model-types'; const mockedEditorContext = 'EDITORCONTEXT' as any; const originalContext = { context: 'Context' } as any; @@ -92,7 +98,7 @@ describe('createContentModel', () => { spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(currentContext); - const model = createContentModel(core, {}); + const model = createContentModel(core, { tryGetFromCache: false }); expect(cloneModelSpy).not.toHaveBeenCalled(); expect(createEditorContext).toHaveBeenCalledWith(core, false); @@ -301,3 +307,123 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext); }); }); + +describe('createContentModel and cache management', () => { + let core: EditorCore; + let textMutationObserver: TextMutationObserver; + let flushMutationsSpy: jasmine.Spy; + let cloneModelSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let createEditorContextSpy: jasmine.Spy; + let updateCachedSelectionSpy: jasmine.Spy; + + const mockedSelection = 'SELECTION' as any; + const mockedFragment = 'FRAGMENT' as any; + const mockedModel = { name: 'MODEL' } as any; + const mockedNewModel = { name: 'NEWMODEL' } as any; + + function runTest( + option: DomToModelOptionForCreateModel | undefined, + hasSelection: boolean, + isInShadowEdit: boolean, + useCache: boolean, + allowIndex: boolean, + clone: boolean + ) { + flushMutationsSpy = jasmine.createSpy('flushMutations'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue(mockedSelection); + createEditorContextSpy = jasmine.createSpy('createEditorContext'); + updateCachedSelectionSpy = jasmine.createSpy('updateCachedSelection'); + + textMutationObserver = { flushMutations: flushMutationsSpy } as any; + + core = { + cache: { textMutationObserver, cachedModel: mockedModel }, + lifecycle: { + shadowEditFragment: isInShadowEdit ? mockedFragment : null, + }, + api: { + getDOMSelection: getDOMSelectionSpy, + createEditorContext: createEditorContextSpy, + }, + environment: { + domToModelSettings: {}, + }, + } as any; + + cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callFake(x => x); + + spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedNewModel); + + const result = createContentModel(core, option, hasSelection ? mockedSelection : undefined); + + expect(flushMutationsSpy).toHaveBeenCalled(); + expect(cloneModelSpy).toHaveBeenCalledTimes(clone ? 1 : 0); + + if (!useCache) { + expect(createEditorContextSpy).toHaveBeenCalledWith(core, allowIndex); + } + + if (useCache) { + expect(result).toBe(mockedModel); + } else { + expect(result).toBe(mockedNewModel); + } + + if (allowIndex) { + expect(core.cache.cachedModel).toBe(mockedNewModel); + expect(updateCachedSelectionSpy).toHaveBeenCalled(); + } else { + expect(core.cache.cachedModel).toBe(mockedModel); + expect(updateCachedSelectionSpy).not.toHaveBeenCalled(); + } + } + + it('no option, no selectionOverride, no shadow edit', () => { + runTest(undefined, false, false, true, true, false); + }); + + it('no option, no selectionOverride, has shadow edit', () => { + runTest(undefined, false, true, true, true, true); + }); + + it('no option, has selectionOverride, no shadow edit', () => { + runTest(undefined, true, false, false, false, false); + }); + + it('no option, has selectionOverride, has shadow edit', () => { + runTest(undefined, true, true, false, false, false); + }); + + it('option allow cache, no selectionOverride, no shadow edit', () => { + runTest({ tryGetFromCache: true }, false, false, true, false, false); + }); + + it('option allow cache, no selectionOverride, has shadow edit', () => { + runTest({ tryGetFromCache: true }, false, true, true, false, true); + }); + + it('option allow cache, has selectionOverride, no shadow edit', () => { + runTest({ tryGetFromCache: true }, true, false, false, false, false); + }); + + it('option allow cache, has selectionOverride, has shadow edit', () => { + runTest({ tryGetFromCache: true }, true, true, false, false, false); + }); + + it('option not allow cache, no selectionOverride, no shadow edit', () => { + runTest({ tryGetFromCache: false }, false, false, false, false, false); + }); + + it('option not allow cache, no selectionOverride, has shadow edit', () => { + runTest({ tryGetFromCache: false }, false, true, false, false, false); + }); + + it('option not allow cache, has selectionOverride, no shadow edit', () => { + runTest({ tryGetFromCache: false }, true, false, false, false, false); + }); + + it('option not allow cache, has selectionOverride, has shadow edit', () => { + runTest({ tryGetFromCache: false }, true, true, false, false, false); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts index 6d35f1d500a..45d97afccd8 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts @@ -147,6 +147,55 @@ describe('createEditorContext', () => { rootFontSize: 16, }); }); + + it('create with shadow edit', () => { + const isDarkMode = 'DARKMODE' as any; + const defaultFormat = 'DEFAULTFORMAT' as any; + const darkColorHandler = 'DARKHANDLER' as any; + const mockedPendingFormat = 'PENDINGFORMAT' as any; + const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + + const div = { + ownerDocument: { + defaultView: { + getComputedStyle: getComputedStyleSpy, + }, + }, + }; + + const core = ({ + physicalRoot: div, + logicalRoot: div, + lifecycle: { + isDarkMode, + shadowEditFragment: {} as any, + }, + format: { + defaultFormat, + pendingFormat: mockedPendingFormat, + }, + darkColorHandler, + cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, + } as any) as EditorCore; + + const context = createEditorContext(core, true); + + expect(context).toEqual({ + isDarkMode, + darkColorHandler, + defaultFormat, + addDelimiterForEntity: true, + allowCacheElement: true, + domIndexer: undefined, + pendingFormat: mockedPendingFormat, + zoomScale: 1, + rootFontSize: 16, + }); + }); }); describe('createEditorContext - checkZoomScale', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 8e15690595a..92c6d4f720c 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -131,7 +131,7 @@ describe('Editor', () => { const model1 = editor.getContentModelCopy('connected'); expect(model1).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { tryGetFromCache: true }); editor.dispose(); expect(() => editor.getContentModelCopy('connected')).toThrow(); @@ -199,6 +199,7 @@ describe('Editor', () => { processorOverride: { table: tableProcessor, }, + tryGetFromCache: false, }); expect(transformColorSpy).not.toHaveBeenCalled(); @@ -212,6 +213,7 @@ describe('Editor', () => { processorOverride: { table: tableProcessor, }, + tryGetFromCache: false, }); expect(transformColorSpy).toHaveBeenCalledWith( clonedNode, diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts index 48f243ed566..362ec5699e8 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -25,6 +25,16 @@ export interface DomToModelOption { additionalFormatParsers?: Partial; } +/** + * Options for creating DomToModelContext, used by formatContentModel and createContentModel API + */ +export interface DomToModelOptionForCreateModel extends DomToModelOption { + /** + * When set to true, it will try to reuse cached content model if any + */ + tryGetFromCache: boolean; +} + /** * Options for DOM to Content Model conversion for paste only */ diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 1f49594166e..0a570bb91db 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -8,7 +8,7 @@ import type { EntityState } from '../parameter/FormatContentModelContext'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; -import type { DomToModelOption } from '../context/DomToModelOption'; +import type { DomToModelOptionForCreateModel } from '../context/DomToModelOption'; import type { EditorContext } from '../context/EditorContext'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; import type { ModelToDomOption } from '../context/ModelToDomOption'; @@ -36,7 +36,7 @@ export type CreateEditorContext = (core: EditorCore, saveIndex: boolean) => Edit */ export type CreateContentModel = ( core: EditorCore, - option?: DomToModelOption, + option?: DomToModelOptionForCreateModel, selectionOverride?: DOMSelection | 'none' ) => ContentModelDocument; @@ -92,7 +92,7 @@ export type FormatContentModel = ( core: EditorCore, formatter: ContentModelFormatter, options?: FormatContentModelOptions, - domToModelOptions?: DomToModelOption + domToModelOptions?: DomToModelOptionForCreateModel ) => void; /** diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 8e2240233dc..826d2d27ab9 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -1,4 +1,4 @@ -import type { DomToModelOption } from '../context/DomToModelOption'; +import type { DomToModelOptionForCreateModel } from '../context/DomToModelOption'; import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; import type { PluginEventType } from '../event/PluginEventType'; @@ -72,7 +72,7 @@ export interface IEditor { formatContentModel( formatter: ContentModelFormatter, options?: FormatContentModelOptions, - domToModelOption?: DomToModelOption + domToModelOption?: DomToModelOptionForCreateModel ): void; /** diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 42458291cf0..c0c22b2d616 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -183,7 +183,11 @@ export { ContentModelSegmentHandler, ContentModelBlockHandler, } from './context/ContentModelHandler'; -export { DomToModelOption, DomToModelOptionForSanitizing } from './context/DomToModelOption'; +export { + DomToModelOption, + DomToModelOptionForSanitizing, + DomToModelOptionForCreateModel, +} from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; export { DomIndexer } from './context/DomIndexer'; export { TextMutationObserver } from './context/TextMutationObserver';