Skip to content

Commit

Permalink
Content Model cache improvement: Let Content model update cache for c…
Browse files Browse the repository at this point in the history
…hild list change (#2613)

* KeyboardEnter

* fix comment

* fix test

* improve

* Let Content Model cache handle child list change

* Scroll caret into view when call formatContentModel

* scroll caret into view

* Readonly types (3rd try

* Improve

* fix build

* Improve

* improve

* Improve

* Add shallow mutable type

* improve

* Improve

* improve

* improve

* add test

* Readonly types step 2

* Readonly types step 3

* Readonly type step 4

* add test

* Improve

* improve

* improve

* Readonly types step 5: dom package

* add change

* improve

* Readonly types step 6

* fix build

* improve

* Improve

* improve

* fix test

* Improve

* Improve

* fix build

* improve

* improve

* Readonly types step 7: Port all other files

* improve

* Readonly type steps 8: Finally enable readonly type

* fix test

* improve

* improve

* fix build

* fix build

* fix build

* Fix build

* fix build

* improve

* fix build

* Add experimental features

* Improve

* fix test

* add test

* add test

* improve

* do not scroll caret into view for now

* improve

* fix test

* Improve

* fix test

* add test cases
  • Loading branch information
JiuqingSong committed Jun 12, 2024
1 parent 3b900a8 commit 00bb508
Show file tree
Hide file tree
Showing 22 changed files with 797 additions and 79 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function formatSegmentWithContentModel(
true /*mutate*/
);
let isCollapsedSelection =
segmentAndParagraphs.length == 1 &&
segmentAndParagraphs[0][0].segmentType == 'SelectionMarker';
segmentAndParagraphs.length >= 1 &&
segmentAndParagraphs.every(x => x[0].segmentType == 'SelectionMarker');

if (isCollapsedSelection) {
const para = segmentAndParagraphs[0][1];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createDomToModelContextForSanitizing } from '../createModelFromHtml/createDomToModelContextForSanitizing';
import {
ChangeSource,
EmptySegmentFormat,
cloneModel,
domToContentModel,
getSegmentTextFormat,
Expand All @@ -12,26 +13,11 @@ import type {
ClipboardData,
CloneModelOptions,
ContentModelDocument,
ContentModelSegmentFormat,
IEditor,
MergeModelOption,
ReadonlyContentModelDocument,
} from 'roosterjs-content-model-types';

const EmptySegmentFormat: Required<ContentModelSegmentFormat> = {
backgroundColor: '',
fontFamily: '',
fontSize: '',
fontWeight: '',
italic: false,
letterSpacing: '',
lineHeight: '',
strikethrough: false,
superOrSubScriptSequence: '',
textColor: '',
underline: false,
};

const CloneOption: CloneModelOptions = {
includeCachedElement: (node, type) => (type == 'cache' ? undefined : node),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea
}

// Clear pending mutations since we will use our latest model object to replace existing cache
core.cache.textMutationObserver?.flushMutations();
core.cache.cachedModel = model;
core.cache.textMutationObserver?.flushMutations(model);
}

return selection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PluginEvent,
PluginWithState,
EditorOptions,
ContentModelDocument,
} from 'roosterjs-content-model-types';

/**
Expand All @@ -23,15 +24,24 @@ class CachePlugin implements PluginWithState<CachePluginState> {
* @param contentDiv The editor content DIV
*/
constructor(option: EditorOptions, contentDiv: HTMLDivElement) {
this.state = option.disableCache
? {}
: {
domIndexer: new DomIndexerImpl(
option.experimentalFeatures &&
option.experimentalFeatures.indexOf('PersistCache') >= 0
),
textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation),
};
if (option.disableCache) {
this.state = {};
} else {
const domIndexer = new DomIndexerImpl(
option.experimentalFeatures &&
option.experimentalFeatures.indexOf('PersistCache') >= 0
);

this.state = {
domIndexer: domIndexer,
textMutationObserver: createTextMutationObserver(
contentDiv,
domIndexer,
this.onMutation,
this.onSkipMutation
),
};
}
}

/**
Expand Down Expand Up @@ -125,6 +135,13 @@ class CachePlugin implements PluginWithState<CachePluginState> {
}
};

private onSkipMutation = (newModel: ContentModelDocument) => {
if (!this.editor?.isInShadowEdit()) {
this.state.cachedModel = newModel;
this.state.cachedSelection = undefined;
}
};

private onNativeSelectionChange = () => {
if (this.editor?.hasFocus()) {
this.updateCachedModel(this.editor);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
EmptySegmentFormat,
createSelectionMarker,
createText,
getObjectKeys,
isNodeOfType,
setSelection,
} from 'roosterjs-content-model-dom';
Expand All @@ -9,6 +11,7 @@ import type {
ContentModelDocument,
ContentModelParagraph,
ContentModelSegment,
ContentModelSegmentFormat,
ContentModelSelectionMarker,
ContentModelTable,
ContentModelTableRow,
Expand All @@ -19,23 +22,65 @@ import type {
Selectable,
} from 'roosterjs-content-model-types';

interface SegmentItem {
/**
* @internal Export for test only
*/
export interface SegmentItem {
paragraph: ContentModelParagraph;
segments: ContentModelSegment[];
}

interface TableItem {
/**
* @internal Export for test only
*/
export interface TableItem {
tableRows: ContentModelTableRow[];
}

interface IndexedSegmentNode extends Node {
/**
* @internal Export for test only
*/
export interface IndexedSegmentNode extends Node {
__roosterjsContentModel: SegmentItem;
}

interface IndexedTableElement extends HTMLTableElement {
/**
* @internal Export for test only
*/
export interface IndexedTableElement extends HTMLTableElement {
__roosterjsContentModel: TableItem;
}

/**
* Context object used by DomIndexer when reconcile mutations with child list
*/
interface ReconcileChildListContext {
/**
* Index of segment in current paragraph
*/
segIndex: number;

/**
* The current paragraph that we are handling
*/
paragraph?: ContentModelParagraph;

/**
* Text node that is added from mutation but has not been handled. This can happen when we first see an added node then later we see a removed one.
* e.g. Type text in an empty paragraph (&lt;div&gt;&lt;br&gt;&lt;/div&gt;), so a text node will be added and &lt;BR&gt; will be removed.
* Set to a valid text node means we need to handle it later. If it is finally not handled, that means we need to clear cache
* Set to undefined (initial value) means no pending text node is hit yet (valid case)
* Set to null means there was a pending text node which is already handled, so if we see another pending text node,
* we should clear cache since we don't know how to handle it
*/
pendingTextNode?: Text | null;

/**
* Format of the removed segment, this will be used as the format for newly created segment
*/
format?: ContentModelSegmentFormat;
}

function isIndexedSegment(node: Node): node is IndexedSegmentNode {
const { paragraph, segments } = (node as IndexedSegmentNode).__roosterjsContentModel ?? {};

Expand All @@ -47,12 +92,16 @@ function isIndexedSegment(node: Node): node is IndexedSegmentNode {
);
}

function getIndexedSegmentItem(node: Node | null): SegmentItem | null {
return node && isIndexedSegment(node) ? node.__roosterjsContentModel : null;
}

/**
* @internal
* Implementation of DomIndexer
*/
export class DomIndexerImpl implements DomIndexer {
constructor(public readonly persistCache?: boolean) {}
constructor(private readonly persistCache?: boolean) {}

onSegment(segmentNode: Node, paragraph: ContentModelParagraph, segment: ContentModelSegment[]) {
const indexedText = segmentNode as IndexedSegmentNode;
Expand All @@ -70,9 +119,7 @@ export class DomIndexerImpl implements DomIndexer {
if (!previousText) {
previousText = child;
} else {
const item = isIndexedSegment(previousText)
? previousText.__roosterjsContentModel
: undefined;
const item = getIndexedSegmentItem(previousText);

if (item && isIndexedSegment(child)) {
item.segments = item.segments.concat(
Expand Down Expand Up @@ -171,6 +218,37 @@ export class DomIndexerImpl implements DomIndexer {
return false;
}

reconcileChildList(addedNodes: ArrayLike<Node>, removedNodes: ArrayLike<Node>): boolean {
if (!this.persistCache) {
return false;
}

let canHandle = true;
const context: ReconcileChildListContext = {
segIndex: -1,
};

// First process added nodes
const addedNode = addedNodes[0];

if (addedNodes.length == 1 && isNodeOfType(addedNode, 'TEXT_NODE')) {
canHandle = this.reconcileAddedNode(addedNode, context);
} else if (addedNodes.length > 0) {
canHandle = false;
}

// Second, process removed nodes
const removedNode = removedNodes[0];

if (canHandle && removedNodes.length == 1) {
canHandle = this.reconcileRemovedNode(removedNode, context);
} else if (removedNodes.length > 0) {
canHandle = false;
}

return canHandle && !context.pendingTextNode;
}

private isCollapsed(selection: RangeSelectionForCache): boolean {
const { start, end } = selection;

Expand All @@ -189,9 +267,10 @@ export class DomIndexerImpl implements DomIndexer {

private insertMarker(node: Node | null, isAfter: boolean): Selectable | undefined {
let marker: ContentModelSelectionMarker | undefined;
const segmentItem = node && getIndexedSegmentItem(node);

if (node && isIndexedSegment(node)) {
const { paragraph, segments } = node.__roosterjsContentModel;
if (segmentItem) {
const { paragraph, segments } = segmentItem;
const index = paragraph.segments.indexOf(segments[0]);

if (index >= 0) {
Expand Down Expand Up @@ -294,4 +373,110 @@ export class DomIndexerImpl implements DomIndexer {

return selectable;
}

private reconcileAddedNode(node: Text, context: ReconcileChildListContext): boolean {
let segmentItem: SegmentItem | null = null;
let index = -1;
let existingSegment: ContentModelSegment;
const { previousSibling, nextSibling } = node;

if (
(segmentItem = getIndexedSegmentItem(previousSibling)) &&
(existingSegment = segmentItem.segments[segmentItem.segments.length - 1]) &&
(index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0
) {
// When we can find indexed segment before current one, use it as the insert index
this.indexNode(segmentItem.paragraph, index + 1, node, existingSegment.format);
} else if (
(segmentItem = getIndexedSegmentItem(nextSibling)) &&
(existingSegment = segmentItem.segments[0]) &&
(index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0
) {
// When we can find indexed segment after current one, use it as the insert index
this.indexNode(segmentItem.paragraph, index, node, existingSegment.format);
} else if (context.paragraph && context.segIndex >= 0) {
// When there is indexed paragraph from removed nodes, we can use it as the insert index
this.indexNode(context.paragraph, context.segIndex, node, context.format);
} else if (context.pendingTextNode === undefined) {
// When we can't find the insert index, set current node as pending node
// so later we can pick it up when we have enough info when processing removed node
// Only do this when pendingTextNode is undefined. If it is null it means there was already a pending node before
// and in that case we should return false since we can't handle two pending text node
context.pendingTextNode = node;
} else {
return false;
}

return true;
}

private reconcileRemovedNode(node: Node, context: ReconcileChildListContext): boolean {
let segmentItem: SegmentItem | null = null;
let removingSegment: ContentModelSegment;

if (
context.segIndex < 0 &&
!context.paragraph && // No previous removed segment or related paragraph found, and
(segmentItem = getIndexedSegmentItem(node)) && // The removed node is indexed, and
(removingSegment = segmentItem.segments[0]) // There is at least one related segment
) {
// Now we can remove the indexed segment from the paragraph, and remember it, later we may need to use it
context.format = removingSegment.format;
context.paragraph = segmentItem.paragraph;
context.segIndex = segmentItem.paragraph.segments.indexOf(segmentItem.segments[0]);

if (context.segIndex < 0) {
// Indexed segment is not under paragraph, something wrong happens, we cannot keep handling
return false;
}

for (let i = 0; i < segmentItem.segments.length; i++) {
const index = segmentItem.paragraph.segments.indexOf(segmentItem.segments[i]);

if (index >= 0) {
segmentItem.paragraph.segments.splice(index, 1);
}
}

if (context.pendingTextNode) {
// If we have pending text node added but not indexed, do it now
this.indexNode(
context.paragraph,
context.segIndex,
context.pendingTextNode,
segmentItem.segments[0].format
);

// Set to null since we have processed it.
// Next time we see a pending node we know we have already processed one so it is a situation we cannot handle
context.pendingTextNode = null;
}

return true;
} else {
return false;
}
}

private indexNode(
paragraph: ContentModelParagraph,
index: number,
textNode: Text,
format?: ContentModelSegmentFormat
) {
const copiedFormat = format ? { ...format } : undefined;

if (copiedFormat) {
getObjectKeys(copiedFormat).forEach(key => {
if (EmptySegmentFormat[key] === undefined) {
delete copiedFormat[key];
}
});
}

const text = createText(textNode.textContent ?? '', copiedFormat);

paragraph.segments.splice(index, 0, text);
this.onSegment(textNode, paragraph, [text]);
}
}
Loading

0 comments on commit 00bb508

Please sign in to comment.