Skip to content

Commit

Permalink
Content Model Cache improvement 1: Create ContentModelCachePlugin (#2066
Browse files Browse the repository at this point in the history
)

* Content Model Customization refactor

* fix build

* improve

* Content Model Customization refactor 2: Add default config

* fix build

* Content Model: Persist cache 1

* fix build

* improve

* Content Model: Cache 2

* Fix test

* Fix build

* improve

* Improve

* improve

* Improve

* fix test

* Improve
  • Loading branch information
JiuqingSong authored Sep 19, 2023
1 parent 7bf0557 commit 51eb85c
Show file tree
Hide file tree
Showing 33 changed files with 1,023 additions and 801 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,14 @@ export default class ContentModelEditor
}

/**
* Cache a content model object. Next time when format with content model, we can reuse it.
* @param model
* Notify editor the current cache may be invalid
*/
cacheContentModel(model: ContentModelDocument | null) {
invalidateCache() {
const core = this.getCore();

if (!core.lifecycle.shadowEditFragment) {
core.cachedModel = model || undefined;
core.cachedRangeEx = undefined;
core.cache.cachedModel = undefined;
core.cache.cachedRangeEx = undefined;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ import {
* @param selectionOverride When passed, use this selection range instead of current selection in editor
*/
export const createContentModel: CreateContentModel = (core, option, selectionOverride) => {
let cachedModel = selectionOverride ? null : core.cachedModel;
let cachedModel = selectionOverride ? 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
cachedModel = cloneModel(cachedModel, { includeCachedElement: true });
}

return cachedModel || internalCreateContentModel(core, option, selectionOverride);
if (cachedModel) {
return cachedModel;
} else {
const model = internalCreateContentModel(core, option, selectionOverride);

if (!option && !selectionOverride) {
core.cache.cachedModel = model;
}

return model;
}
};

function internalCreateContentModel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import { GetSelectionRangeEx } from 'roosterjs-editor-types';
export const getSelectionRangeEx: GetSelectionRangeEx = core => {
const contentModelCore = core as ContentModelEditorCore;

return contentModelCore.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core);
return contentModelCore.cache.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core);
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea

if (!core.lifecycle.shadowEditFragment) {
core.api.select(core, range);
core.cachedRangeEx = range || undefined;

if (range) {
core.cache.cachedRangeEx = range;
}
}

// TODO: Reconcile selection text node cache

return range;
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => {

if (isOn != !!core.lifecycle.shadowEditFragment) {
if (isOn) {
const model = !core.cachedModel ? core.api.createContentModel(core) : null;
const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null;
const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/);

// Fake object, not used in Content Model Editor, just to satisfy original editor code
Expand All @@ -34,8 +34,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => {

// This need to be done after EnteredShadowEdit event is triggered since EnteredShadowEdit event will cause a SelectionChanged event
// if current selection is table selection or image selection
if (!core.cachedModel && model) {
core.cachedModel = model;
if (!core.cache.cachedModel && model) {
core.cache.cachedModel = model;
}

core.lifecycle.shadowEditSelectionPath = selectionPath;
Expand All @@ -52,8 +52,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => {
false /*broadcast*/
);

if (core.cachedModel) {
core.api.setContentModel(core, core.cachedModel);
if (core.cache.cachedModel) {
core.api.setContentModel(core, core.cache.cachedModel);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { ContentModelCachePluginState } from '../../publicTypes/pluginState/ContentModelCachePluginState';
import { IContentModelEditor } from '../../publicTypes/IContentModelEditor';
import {
IEditor,
Keys,
PluginEvent,
PluginEventType,
PluginWithState,
SelectionRangeEx,
} from 'roosterjs-editor-types';

/**
* ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary
*/
export default class ContentModelCachePlugin
implements PluginWithState<ContentModelCachePluginState> {
private editor: IContentModelEditor | null = null;

/**
* Construct a new instance of ContentModelEditPlugin class
* @param state State of this plugin
*/
constructor(private state: ContentModelCachePluginState) {
// TODO: Remove tempState parameter once we have standalone Content Model editor
}

/**
* Get name of this plugin
*/
getName() {
return 'ContentModelCache';
}

/**
* The first method that editor will call to a plugin when editor is initializing.
* It will pass in the editor instance, plugin should take this chance to save the
* editor reference so that it can call to any editor method or format API later.
* @param editor The editor object
*/
initialize(editor: IEditor) {
// TODO: Later we may need a different interface for Content Model editor plugin
this.editor = editor as IContentModelEditor;
this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange);
}

/**
* The last method that editor will call to a plugin before it is disposed.
* Plugin can take this chance to clear the reference to editor. After this method is
* called, plugin should not call to any editor method since it will result in error.
*/
dispose() {
if (this.editor) {
this.editor
.getDocument()
.removeEventListener('selectionchange', this.onNativeSelectionChange);
this.editor = null;
}
}

/**
* Get plugin state object
*/
getState(): ContentModelCachePluginState {
return this.state;
}

/**
* Core method for a plugin. Once an event happens in editor, editor will call this
* method of each plugin to handle the event as long as the event is not handled
* exclusively by another plugin.
* @param event The event to handle:
*/
onPluginEvent(event: PluginEvent) {
if (!this.editor) {
return;
}

switch (event.eventType) {
case PluginEventType.KeyDown:
switch (event.rawEvent.which) {
case Keys.ENTER:
// ENTER key will create new paragraph, so need to update cache to reflect this change
// TODO: Handle ENTER key to better reuse content model
this.editor.invalidateCache();

break;
}
break;

case PluginEventType.Input:
case PluginEventType.SelectionChanged:
this.reconcileSelection(this.editor);
break;

case PluginEventType.ContentChanged:
this.editor.invalidateCache();
break;
}
}

private onNativeSelectionChange = () => {
if (this.editor?.hasFocus()) {
this.reconcileSelection(this.editor);
}
};

private reconcileSelection(editor: IContentModelEditor, newRangeEx?: SelectionRangeEx) {
// TODO: Really do reconcile selection
editor.invalidateCache();
}
}

/**
* @internal
* Create a new instance of ContentModelCachePlugin class.
* This is mostly for unit test
* @param state State of this plugin
*/
export function createContentModelCachePlugin(state: ContentModelCachePluginState) {
return new ContentModelCachePlugin(state);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import ContentModelCopyPastePlugin from './corePlugins/ContentModelCopyPastePlugin';
import ContentModelEditPlugin from './plugins/ContentModelEditPlugin';
import ContentModelFormatPlugin from './plugins/ContentModelFormatPlugin';
import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInContainerPlugin';
import { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore';
import { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor';
import { ContentModelPluginState } from '../publicTypes/pluginState/ContentModelPluginState';
import { ContentModelSegmentFormat } from 'roosterjs-content-model-types';
import { CoreCreator, EditorCore, ExperimentalFeatures } from 'roosterjs-editor-types';
import { createContentModel } from './coreApi/createContentModel';
import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin';
import { createContentModelEditPlugin } from './plugins/ContentModelEditPlugin';
import { createContentModelFormatPlugin } from './plugins/ContentModelFormatPlugin';
import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom';
import { createEditorContext } from './coreApi/createEditorContext';
import { createEditorCore, isFeatureEnabled } from 'roosterjs-editor-core';
Expand All @@ -22,30 +24,35 @@ export const createContentModelEditorCore: CoreCreator<
ContentModelEditorCore,
ContentModelEditorOptions
> = (contentDiv, options) => {
const pluginState: ContentModelPluginState = {
cache: {},
copyPaste: {
allowedCustomPasteType: options.allowedCustomPasteType || [],
},
};
const modifiedOptions: ContentModelEditorOptions = {
...options,
plugins: [
createContentModelCachePlugin(pluginState.cache),
...(options.plugins || []),
new ContentModelFormatPlugin(),
new ContentModelEditPlugin(),
createContentModelFormatPlugin(),
createContentModelEditPlugin(),
],
corePluginOverride: {
typeInContainer: new ContentModelTypeInContainerPlugin(),
copyPaste: isFeatureEnabled(
options.experimentalFeatures,
ExperimentalFeatures.ContentModelPaste
)
? new ContentModelCopyPastePlugin({
allowedCustomPasteType: options.allowedCustomPasteType || [],
})
? new ContentModelCopyPastePlugin(pluginState.copyPaste)
: undefined,
...(options.corePluginOverride || {}),
},
};

const core = createEditorCore(contentDiv, modifiedOptions) as ContentModelEditorCore;

promoteToContentModelEditorCore(core, modifiedOptions);
promoteToContentModelEditorCore(core, modifiedOptions, pluginState);

return core;
};
Expand All @@ -57,15 +64,24 @@ export const createContentModelEditorCore: CoreCreator<
*/
export function promoteToContentModelEditorCore(
core: EditorCore,
options: ContentModelEditorOptions
options: ContentModelEditorOptions,
pluginState: ContentModelPluginState
) {
const cmCore = core as ContentModelEditorCore;

promoteCorePluginState(cmCore, pluginState);
promoteDefaultFormat(cmCore);
promoteContentModelInfo(cmCore, options);
promoteCoreApi(cmCore);
}

function promoteCorePluginState(
cmCore: ContentModelEditorCore,
pluginState: ContentModelPluginState
) {
Object.assign(cmCore, pluginState);
}

function promoteDefaultFormat(cmCore: ContentModelEditorCore) {
cmCore.lifecycle.defaultFormat = cmCore.lifecycle.defaultFormat || {};
cmCore.defaultFormat = getDefaultSegmentFormat(cmCore);
Expand Down
Loading

0 comments on commit 51eb85c

Please sign in to comment.