diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index e7723a17d27..0d0a4ec838d 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -1,6 +1,7 @@ import { areSameSelection } from './areSameSelection'; import { createTextMutationObserver } from './textMutationObserver'; import { DomIndexerImpl } from './domIndexerImpl'; +import { findClosestEntityWrapper, getSelectionRootNode } from 'roosterjs-content-model-dom'; import { updateCachedSelection } from './updateCachedSelection'; import type { CachePluginState, @@ -9,6 +10,7 @@ import type { PluginWithState, EditorOptions, ContentModelDocument, + DOMHelper, } from 'roosterjs-content-model-types'; /** @@ -17,6 +19,7 @@ import type { class CachePlugin implements PluginWithState { private editor: IEditor | null = null; private state: CachePluginState; + private logicalRoot: HTMLElement | null = null; /** * Construct a new instance of CachePlugin class @@ -38,7 +41,8 @@ class CachePlugin implements PluginWithState { contentDiv, domIndexer, this.onMutation, - this.onSkipMutation + this.onSkipMutation, + this.areNodesUnderEntity ), }; } @@ -71,6 +75,7 @@ class CachePlugin implements PluginWithState { */ dispose() { this.state.textMutationObserver?.stopObserving(); + this.logicalRoot = null; if (this.editor) { this.editor @@ -99,6 +104,10 @@ class CachePlugin implements PluginWithState { } switch (event.eventType) { + case 'logicalRootChanged': + this.logicalRoot = event.logicalRoot; + break; + case 'keyDown': case 'input': if (!this.state.textMutationObserver) { @@ -159,28 +168,47 @@ class CachePlugin implements PluginWithState { const cachedSelection = this.state.cachedSelection; this.state.cachedSelection = undefined; // Clear it to force getDOMSelection() retrieve the latest selection range - const newRangeEx = editor.getDOMSelection() || undefined; + const selection = editor.getDOMSelection() || undefined; const model = this.state.cachedModel; const isSelectionChanged = forceUpdate || !cachedSelection || - !newRangeEx || - !areSameSelection(newRangeEx, cachedSelection); + !selection || + !areSameSelection(selection, cachedSelection); if (isSelectionChanged) { if ( !model || - !newRangeEx || - !this.state.domIndexer?.reconcileSelection(model, newRangeEx, cachedSelection) + !selection || + (!this.state.domIndexer?.reconcileSelection(model, selection, cachedSelection) && + !this.isNodeUnderEntity(editor.getDOMHelper(), getSelectionRootNode(selection))) ) { this.invalidateCache(); } else { - updateCachedSelection(this.state, newRangeEx); + updateCachedSelection(this.state, selection); } } else { this.state.cachedSelection = cachedSelection; } } + + private areNodesUnderEntity = (nodes: Node[]) => { + const domHelper = this.editor?.getDOMHelper(); + + return !!domHelper && nodes.every(node => this.isNodeUnderEntity(domHelper, node)); + }; + + private isNodeUnderEntity(domHelper: DOMHelper, node: Node | undefined) { + const entity = node && findClosestEntityWrapper(node, domHelper); + + if (!entity) { + return false; + } else if (this.logicalRoot) { + return this.logicalRoot.contains(node); + } else { + return domHelper.isNodeInEditor(node); + } + } } /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts index bcaae8cc3e5..5b5696ea777 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts @@ -11,7 +11,8 @@ class TextMutationObserverImpl implements TextMutationObserver { private contentDiv: HTMLDivElement, private domIndexer: DomIndexer, private onMutation: (isTextChangeOnly: boolean) => void, - private onSkipMutation: (newModel: ContentModelDocument) => void + private onSkipMutation: (newModel: ContentModelDocument) => void, + private areNodesUnderEntity: (nodes: Node[]) => boolean ) { this.observer = new MutationObserver(this.onMutationInternal); } @@ -47,6 +48,12 @@ class TextMutationObserverImpl implements TextMutationObserver { let removedNodes: Node[] = []; let reconcileText = false; + const nodeSet = new Set(mutations.map(x => x.target)); + + if (this.areNodesUnderEntity(Array.from(nodeSet))) { + return; + } + for (let i = 0; i < mutations.length && canHandle; i++) { const mutation = mutations[i]; @@ -103,7 +110,14 @@ export function createTextMutationObserver( contentDiv: HTMLDivElement, domIndexer: DomIndexer, onMutation: (isTextChangeOnly: boolean) => void, - onSkipMutation: (newModel: ContentModelDocument) => void + onSkipMutation: (newModel: ContentModelDocument) => void, + areNodesUnderEntity: (nodes: Node[]) => boolean ): TextMutationObserver { - return new TextMutationObserverImpl(contentDiv, domIndexer, onMutation, onSkipMutation); + return new TextMutationObserverImpl( + contentDiv, + domIndexer, + onMutation, + onSkipMutation, + areNodesUnderEntity + ); } diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts index b4fb3795f55..f7fa4427362 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts @@ -20,7 +20,9 @@ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect return rect; } - // 2) try to get rect using range.getClientRects + // 2) Normalize this selection and try again + // If selection is at beginning of a TEXT node, we will get node=text.parentNode and offset=0 + // This will move it down to the real text node while (node.lastChild) { if (offset == node.childNodes.length) { node = node.lastChild; @@ -31,24 +33,19 @@ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect } } - const rects = range.getClientRects && range.getClientRects(); - rect = rects && rects.length == 1 ? normalizeRect(rects[0]) : null; + range.setStart(node, offset); + range.setEnd(node, offset); + rect = normalizeRect(range.getBoundingClientRect()); + if (rect) { return rect; } - // 3) if node is text node, try inserting a SPAN and get the rect of SPAN for others - if (isNodeOfType(node, 'TEXT_NODE')) { - const span = node.ownerDocument.createElement('span'); - - span.textContent = '\u200b'; - range.insertNode(span); - rect = normalizeRect(span.getBoundingClientRect()); - span.parentNode?.removeChild(span); - - if (rect) { - return rect; - } + // 3) try to get rect using range.getClientRects + const rects = range.getClientRects && range.getClientRects(); + rect = rects && rects.length == 1 ? normalizeRect(rects[0]) : null; + if (rect) { + return rect; } // 4) try getBoundingClientRect on element