diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 8b61dd8f20b..33ced2f5d4e 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -17,7 +17,6 @@ import {IS_FIREFOX} from 'shared/environment'; import { $getSelection, $isDecoratorNode, - $isElementNode, $isRangeSelection, $isTextNode, $setSelection, @@ -33,6 +32,7 @@ import { getParentElement, getWindow, internalGetRoot, + isDOMUnmanaged, isFirefoxClipboardEvents, } from './LexicalUtils'; // The time between a text entry event and the mutation observer firing. @@ -110,21 +110,28 @@ function shouldUpdateTextNodeFromMutation( return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); } -export function $getNearestNodePairFromDOMNode( +function $getNearestManagedNodePairFromDOMNode( startingDOM: Node, editor: LexicalEditor, editorState: EditorState, -): [Node, LexicalNode] | [null, null] { - for (let dom: Node | null = startingDOM; dom; dom = getParentElement(dom)) { + rootElement: HTMLElement | null, +): [HTMLElement, LexicalNode] | undefined { + for ( + let dom: Node | null = startingDOM; + dom && !isDOMUnmanaged(dom); + dom = getParentElement(dom) + ) { const key = getNodeKeyFromDOMNode(dom, editor); if (key !== undefined) { const node = $getNodeByKey(key, editorState); if (node) { - return [dom, node]; + // All decorator nodes are unmanaged + return $isDecoratorNode(node) ? undefined : [dom as HTMLElement, node]; } + } else if (dom === rootElement) { + return [rootElement, internalGetRoot(editorState)]; } } - return [null, null]; } export function $flushMutations( @@ -139,7 +146,7 @@ export function $flushMutations( try { updateEditor(editor, () => { const selection = $getSelection() || getLastSelection(editor); - const badDOMTargets = new Map(); + const badDOMTargets = new Map(); const rootElement = editor.getRootElement(); // We use the current editor state, as that reflects what is // actually "on screen". @@ -152,19 +159,16 @@ export function $flushMutations( const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; - const pair = $getNearestNodePairFromDOMNode( + const pair = $getNearestManagedNodePairFromDOMNode( targetDOM, editor, currentEditorState, + rootElement, ); - let targetNode = pair[1]; - - if ( - (targetNode === null && targetDOM !== rootElement) || - $isDecoratorNode(targetNode) - ) { + if (!pair) { continue; } + const [nodeDOM, targetNode] = pair; if (type === 'characterData') { // Text mutations are deferred and passed to mutation listeners to be @@ -233,11 +237,7 @@ export function $flushMutations( } if (removedDOMsLength !== unremovedBRs) { - if (targetDOM === rootElement) { - targetNode = internalGetRoot(currentEditorState); - } - - badDOMTargets.set(pair[0] || targetDOM, targetNode); + badDOMTargets.set(nodeDOM, targetNode); } } } @@ -248,32 +248,8 @@ export function $flushMutations( // is Lexical's "current" editor state. This is basically like // an internal revert on the DOM. if (badDOMTargets.size > 0) { - for (const [targetDOM, targetNode] of badDOMTargets) { - if ($isElementNode(targetNode)) { - const childKeys = targetNode.getChildrenKeys(); - const slot = targetNode.getDOMSlot(targetDOM as HTMLElement); - let currentDOM = slot.getFirstChild(); - - for (let s = 0; s < childKeys.length; s++) { - const key = childKeys[s]; - const correctDOM = editor.getElementByKey(key); - - if (correctDOM === null) { - continue; - } - - if (currentDOM == null) { - slot.insertChild(correctDOM); - currentDOM = correctDOM; - } else if (currentDOM !== correctDOM) { - slot.replaceChild(correctDOM, currentDOM); - } - - currentDOM = currentDOM.nextSibling; - } - } else if ($isTextNode(targetNode)) { - targetNode.markDirty(); - } + for (const [nodeDOM, targetNode] of badDOMTargets) { + targetNode.reconcileObservedMutation(nodeDOM, editor); } } diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 65eb19068b9..7717627438a 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -62,6 +62,7 @@ export interface LexicalPrivateDOM { __lexicalLineBreak?: HTMLBRElement | undefined | null; __lexicalDirTextContent?: string | undefined | null; __lexicalDir?: 'ltr' | 'rtl' | null | undefined; + __lexicalUnmanaged?: boolean | undefined; } export function $removeNode( @@ -1168,6 +1169,11 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** @internal */ + reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { + this.markDirty(); + } } function errorOnTypeKlassMismatch( diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 678d82ba0b3..02ecd7d991f 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -20,7 +20,12 @@ import type { Spread, } from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; -import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode'; +import type { + LexicalNode, + LexicalPrivateDOM, + NodeKey, + NodeMap, +} from './LexicalNode'; import type { BaseSelection, PointType, @@ -1857,3 +1862,14 @@ export function setNodeIndentFromDOM( const indent = indentSize / 40; elementNode.setIndent(indent); } + +/** @internal */ +export function setDOMUnmanaged(elementDom: HTMLElement): void { + const el: HTMLElement & LexicalPrivateDOM = elementDom; + el.__lexicalUnmanaged = true; +} + +export function isDOMUnmanaged(elementDom: Node): boolean { + const el: Node & LexicalPrivateDOM = elementDom; + return el.__lexicalUnmanaged === true; +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 28f04f6040f..070907e5f05 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -182,6 +182,7 @@ export { getNearestEditorFromDOMNode, isBlockDomNode, isDocumentFragment, + isDOMUnmanaged, isHTMLAnchorElement, isHTMLElement, isInlineDomNode, @@ -189,6 +190,7 @@ export { isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, resetRandomKey, + setDOMUnmanaged, setNodeIndentFromDOM, } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 5df8a8b46c8..cd55c542a4b 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -702,6 +702,32 @@ export class ElementNode extends LexicalNode { canMergeWhenEmpty(): boolean { return false; } + + /** @internal */ + reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { + const slot = this.getDOMSlot(dom); + let currentDOM = slot.getFirstChild(); + for ( + let currentNode = this.getFirstChild(); + currentNode; + currentNode = currentNode.getNextSibling() + ) { + const correctDOM = editor.getElementByKey(currentNode.getKey()); + + if (correctDOM === null) { + continue; + } + + if (currentDOM == null) { + slot.insertChild(correctDOM); + currentDOM = correctDOM; + } else if (currentDOM !== correctDOM) { + slot.replaceChild(correctDOM, currentDOM); + } + + currentDOM = currentDOM.nextSibling; + } + } } export function $isElementNode(