2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
15 } from './LexicalEditor';
16 import type {NodeKey, NodeMap} from './LexicalNode';
17 import type {ElementNode} from './nodes/LexicalElementNode';
19 import invariant from 'lexical/shared/invariant';
38 } from './LexicalConstants';
39 import {EditorState} from './LexicalEditorState';
41 $textContentRequiresDoubleLinebreakAtEnd,
43 getElementByKeyOrThrow,
45 } from './LexicalUtils';
47 type IntentionallyMarkedAsDirtyElement = boolean;
49 let subTreeTextContent = '';
50 let subTreeTextFormat: number | null = null;
51 let subTreeTextStyle: string = '';
52 let editorTextContent = '';
53 let activeEditorConfig: EditorConfig;
54 let activeEditor: LexicalEditor;
55 let activeEditorNodes: RegisteredNodes;
56 let treatAllNodesAsDirty = false;
57 let activeEditorStateReadOnly = false;
58 let activeMutationListeners: MutationListeners;
59 let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
60 let activeDirtyLeaves: Set<NodeKey>;
61 let activePrevNodeMap: NodeMap;
62 let activeNextNodeMap: NodeMap;
63 let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
64 let mutatedNodes: MutatedNodes;
66 function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
67 const node = activePrevNodeMap.get(key);
69 if (parentDOM !== null) {
70 const dom = getPrevElementByKeyOrThrow(key);
71 if (dom.parentNode === parentDOM) {
72 parentDOM.removeChild(dom);
76 // This logic is really important, otherwise we will leak DOM nodes
77 // when their corresponding LexicalNodes are removed from the editor state.
78 if (!activeNextNodeMap.has(key)) {
79 activeEditor._keyToDOMMap.delete(key);
82 if ($isElementNode(node)) {
83 const children = createChildrenArray(node, activePrevNodeMap);
84 destroyChildren(children, 0, children.length - 1, null);
87 if (node !== undefined) {
91 activeMutationListeners,
98 function destroyChildren(
99 children: Array<NodeKey>,
102 dom: null | HTMLElement,
104 let startIndex = _startIndex;
106 for (; startIndex <= endIndex; ++startIndex) {
107 const child = children[startIndex];
109 if (child !== undefined) {
110 destroyNode(child, dom);
115 function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
116 domStyle.setProperty('text-align', value);
119 function $createNode(
121 parentDOM: null | HTMLElement,
122 insertDOM: null | Node,
124 const node = activeNextNodeMap.get(key);
126 if (node === undefined) {
127 invariant(false, 'createNode: node does not exist in nodeMap');
129 const dom = node.createDOM(activeEditorConfig, activeEditor);
130 storeDOMWithKey(key, dom, activeEditor);
132 // This helps preserve the text, and stops spell check tools from
133 // merging or break the spans (which happens if they are missing
135 if ($isTextNode(node)) {
136 dom.setAttribute('data-lexical-text', 'true');
137 } else if ($isDecoratorNode(node)) {
138 dom.setAttribute('data-lexical-decorator', 'true');
141 if ($isElementNode(node)) {
142 const childrenSize = node.__size;
144 if (childrenSize !== 0) {
145 const endIndex = childrenSize - 1;
146 const children = createChildrenArray(node, activeNextNodeMap);
147 $createChildren(children, node, 0, endIndex, dom, null);
150 if (!node.isInline()) {
151 reconcileElementTerminatingLineBreak(null, node, dom);
153 if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
154 subTreeTextContent += DOUBLE_LINE_BREAK;
155 editorTextContent += DOUBLE_LINE_BREAK;
158 const text = node.getTextContent();
160 if ($isDecoratorNode(node)) {
161 const decorator = node.decorate(activeEditor, activeEditorConfig);
163 if (decorator !== null) {
164 reconcileDecorator(key, decorator);
166 // Decorators are always non editable
167 dom.contentEditable = 'false';
169 subTreeTextContent += text;
170 editorTextContent += text;
173 if (parentDOM !== null) {
174 if (insertDOM != null) {
175 parentDOM.insertBefore(dom, insertDOM);
177 // @ts-expect-error: internal field
178 const possibleLineBreak = parentDOM.__lexicalLineBreak;
180 if (possibleLineBreak != null) {
181 parentDOM.insertBefore(dom, possibleLineBreak);
183 parentDOM.appendChild(dom);
189 // Freeze the node in DEV to prevent accidental mutations
196 activeMutationListeners,
203 function $createChildren(
204 children: Array<NodeKey>,
205 element: ElementNode,
208 dom: null | HTMLElement,
209 insertDOM: null | HTMLElement,
211 const previousSubTreeTextContent = subTreeTextContent;
212 subTreeTextContent = '';
213 let startIndex = _startIndex;
215 for (; startIndex <= endIndex; ++startIndex) {
216 $createNode(children[startIndex], dom, insertDOM);
217 const node = activeNextNodeMap.get(children[startIndex]);
218 if (node !== null && $isTextNode(node)) {
219 if (subTreeTextFormat === null) {
220 subTreeTextFormat = node.getFormat();
222 if (subTreeTextStyle === '') {
223 subTreeTextStyle = node.getStyle();
227 if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
228 subTreeTextContent += DOUBLE_LINE_BREAK;
230 // @ts-expect-error: internal field
231 dom.__lexicalTextContent = subTreeTextContent;
232 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
235 function isLastChildLineBreakOrDecorator(
239 const node = nodeMap.get(childKey);
240 return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
243 // If we end an element with a LineBreakNode, then we need to add an additional <br>
244 function reconcileElementTerminatingLineBreak(
245 prevElement: null | ElementNode,
246 nextElement: ElementNode,
249 const prevLineBreak =
250 prevElement !== null &&
251 (prevElement.__size === 0 ||
252 isLastChildLineBreakOrDecorator(
253 prevElement.__last as NodeKey,
256 const nextLineBreak =
257 nextElement.__size === 0 ||
258 isLastChildLineBreakOrDecorator(
259 nextElement.__last as NodeKey,
264 if (!nextLineBreak) {
265 // @ts-expect-error: internal field
266 const element = dom.__lexicalLineBreak;
268 if (element != null) {
270 dom.removeChild(element);
272 if (typeof error === 'object' && error != null) {
273 const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
276 throw new Error(msg);
283 // @ts-expect-error: internal field
284 dom.__lexicalLineBreak = null;
286 } else if (nextLineBreak) {
287 const element = document.createElement('br');
288 // @ts-expect-error: internal field
289 dom.__lexicalLineBreak = element;
290 dom.appendChild(element);
294 function reconcileParagraphFormat(element: ElementNode): void {
296 $isParagraphNode(element) &&
297 subTreeTextFormat != null &&
298 !activeEditorStateReadOnly
300 element.setTextStyle(subTreeTextStyle);
304 function reconcileParagraphStyle(element: ElementNode): void {
306 $isParagraphNode(element) &&
307 subTreeTextStyle !== '' &&
308 subTreeTextStyle !== element.__textStyle &&
309 !activeEditorStateReadOnly
311 element.setTextStyle(subTreeTextStyle);
315 function $reconcileChildrenWithDirection(
316 prevElement: ElementNode,
317 nextElement: ElementNode,
320 subTreeTextFormat = null;
321 subTreeTextStyle = '';
322 $reconcileChildren(prevElement, nextElement, dom);
323 reconcileParagraphFormat(nextElement);
324 reconcileParagraphStyle(nextElement);
327 function createChildrenArray(
328 element: ElementNode,
332 let nodeKey = element.__first;
333 while (nodeKey !== null) {
334 const node = nodeMap.get(nodeKey);
335 if (node === undefined) {
336 invariant(false, 'createChildrenArray: node does not exist in nodeMap');
338 children.push(nodeKey);
339 nodeKey = node.__next;
344 function $reconcileChildren(
345 prevElement: ElementNode,
346 nextElement: ElementNode,
349 const previousSubTreeTextContent = subTreeTextContent;
350 const prevChildrenSize = prevElement.__size;
351 const nextChildrenSize = nextElement.__size;
352 subTreeTextContent = '';
354 if (prevChildrenSize === 1 && nextChildrenSize === 1) {
355 const prevFirstChildKey = prevElement.__first as NodeKey;
356 const nextFrstChildKey = nextElement.__first as NodeKey;
357 if (prevFirstChildKey === nextFrstChildKey) {
358 $reconcileNode(prevFirstChildKey, dom);
360 const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
361 const replacementDOM = $createNode(nextFrstChildKey, null, null);
363 dom.replaceChild(replacementDOM, lastDOM);
365 if (typeof error === 'object' && error != null) {
366 const msg = `${error.toString()} Parent: ${
368 }, new child: {tag: ${
369 replacementDOM.tagName
370 } key: ${nextFrstChildKey}}, old child: {tag: ${
372 }, key: ${prevFirstChildKey}}.`;
373 throw new Error(msg);
378 destroyNode(prevFirstChildKey, null);
380 const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
381 if ($isTextNode(nextChildNode)) {
382 if (subTreeTextFormat === null) {
383 subTreeTextFormat = nextChildNode.getFormat();
385 if (subTreeTextStyle === '') {
386 subTreeTextStyle = nextChildNode.getStyle();
390 const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
391 const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
393 if (prevChildrenSize === 0) {
394 if (nextChildrenSize !== 0) {
399 nextChildrenSize - 1,
404 } else if (nextChildrenSize === 0) {
405 if (prevChildrenSize !== 0) {
406 // @ts-expect-error: internal field
407 const lexicalLineBreak = dom.__lexicalLineBreak;
408 const canUseFastPath = lexicalLineBreak == null;
412 prevChildrenSize - 1,
413 canUseFastPath ? null : dom,
416 if (canUseFastPath) {
417 // Fast path for removing DOM nodes
418 dom.textContent = '';
422 $reconcileNodeChildren(
433 if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
434 subTreeTextContent += DOUBLE_LINE_BREAK;
437 // @ts-expect-error: internal field
438 dom.__lexicalTextContent = subTreeTextContent;
439 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
442 function $reconcileNode(
444 parentDOM: HTMLElement | null,
446 const prevNode = activePrevNodeMap.get(key);
447 let nextNode = activeNextNodeMap.get(key);
449 if (prevNode === undefined || nextNode === undefined) {
452 'reconcileNode: prevNode or nextNode does not exist in nodeMap',
457 treatAllNodesAsDirty ||
458 activeDirtyLeaves.has(key) ||
459 activeDirtyElements.has(key);
460 const dom = getElementByKeyOrThrow(activeEditor, key);
462 // If the node key points to the same instance in both states
463 // and isn't dirty, we just update the text content cache
464 // and return the existing DOM Node.
465 if (prevNode === nextNode && !isDirty) {
466 if ($isElementNode(prevNode)) {
467 // @ts-expect-error: internal field
468 const previousSubTreeTextContent = dom.__lexicalTextContent;
470 if (previousSubTreeTextContent !== undefined) {
471 subTreeTextContent += previousSubTreeTextContent;
472 editorTextContent += previousSubTreeTextContent;
475 const text = prevNode.getTextContent();
477 editorTextContent += text;
478 subTreeTextContent += text;
483 // If the node key doesn't point to the same instance in both maps,
484 // it means it were cloned. If they're also dirty, we mark them as mutated.
485 if (prevNode !== nextNode && isDirty) {
489 activeMutationListeners,
495 // Update node. If it returns true, we need to unmount and re-create the node
496 if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
497 const replacementDOM = $createNode(key, null, null);
499 if (parentDOM === null) {
500 invariant(false, 'reconcileNode: parentDOM is null');
503 parentDOM.replaceChild(replacementDOM, dom);
504 destroyNode(key, null);
505 return replacementDOM;
508 if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
509 // Reconcile element children
511 $reconcileChildrenWithDirection(prevNode, nextNode, dom);
512 if (!$isRootNode(nextNode) && !nextNode.isInline()) {
513 reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
517 if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
518 subTreeTextContent += DOUBLE_LINE_BREAK;
519 editorTextContent += DOUBLE_LINE_BREAK;
522 const text = nextNode.getTextContent();
524 if ($isDecoratorNode(nextNode)) {
525 const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
527 if (decorator !== null) {
528 reconcileDecorator(key, decorator);
532 subTreeTextContent += text;
533 editorTextContent += text;
537 !activeEditorStateReadOnly &&
538 $isRootNode(nextNode) &&
539 nextNode.__cachedText !== editorTextContent
541 // Cache the latest text content.
542 const nextRootNode = nextNode.getWritable();
543 nextRootNode.__cachedText = editorTextContent;
544 nextNode = nextRootNode;
548 // Freeze the node in DEV to prevent accidental mutations
549 Object.freeze(nextNode);
555 function reconcileDecorator(key: NodeKey, decorator: unknown): void {
556 let pendingDecorators = activeEditor._pendingDecorators;
557 const currentDecorators = activeEditor._decorators;
559 if (pendingDecorators === null) {
560 if (currentDecorators[key] === decorator) {
564 pendingDecorators = cloneDecorators(activeEditor);
567 pendingDecorators[key] = decorator;
570 function getFirstChild(element: HTMLElement): Node | null {
571 return element.firstChild;
574 function getNextSibling(element: HTMLElement): Node | null {
575 let nextSibling = element.nextSibling;
577 nextSibling !== null &&
578 nextSibling === activeEditor._blockCursorElement
580 nextSibling = nextSibling.nextSibling;
585 function $reconcileNodeChildren(
586 nextElement: ElementNode,
587 prevChildren: Array<NodeKey>,
588 nextChildren: Array<NodeKey>,
589 prevChildrenLength: number,
590 nextChildrenLength: number,
593 const prevEndIndex = prevChildrenLength - 1;
594 const nextEndIndex = nextChildrenLength - 1;
595 let prevChildrenSet: Set<NodeKey> | undefined;
596 let nextChildrenSet: Set<NodeKey> | undefined;
597 let siblingDOM: null | Node = getFirstChild(dom);
601 while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
602 const prevKey = prevChildren[prevIndex];
603 const nextKey = nextChildren[nextIndex];
605 if (prevKey === nextKey) {
606 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
610 if (prevChildrenSet === undefined) {
611 prevChildrenSet = new Set(prevChildren);
614 if (nextChildrenSet === undefined) {
615 nextChildrenSet = new Set(nextChildren);
618 const nextHasPrevKey = nextChildrenSet.has(prevKey);
619 const prevHasNextKey = prevChildrenSet.has(nextKey);
621 if (!nextHasPrevKey) {
623 siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
624 destroyNode(prevKey, dom);
626 } else if (!prevHasNextKey) {
628 $createNode(nextKey, dom, siblingDOM);
632 const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
634 if (childDOM === siblingDOM) {
635 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
637 if (siblingDOM != null) {
638 dom.insertBefore(childDOM, siblingDOM);
640 dom.appendChild(childDOM);
643 $reconcileNode(nextKey, dom);
651 const node = activeNextNodeMap.get(nextKey);
652 if (node !== null && $isTextNode(node)) {
653 if (subTreeTextFormat === null) {
654 subTreeTextFormat = node.getFormat();
656 if (subTreeTextStyle === '') {
657 subTreeTextStyle = node.getStyle();
662 const appendNewChildren = prevIndex > prevEndIndex;
663 const removeOldChildren = nextIndex > nextEndIndex;
665 if (appendNewChildren && !removeOldChildren) {
666 const previousNode = nextChildren[nextEndIndex + 1];
668 previousNode === undefined
670 : activeEditor.getElementByKey(previousNode);
679 } else if (removeOldChildren && !appendNewChildren) {
680 destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
684 export function $reconcileRoot(
685 prevEditorState: EditorState,
686 nextEditorState: EditorState,
687 editor: LexicalEditor,
688 dirtyType: 0 | 1 | 2,
689 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
690 dirtyLeaves: Set<NodeKey>,
692 // We cache text content to make retrieval more efficient.
693 // The cache must be rebuilt during reconciliation to account for any changes.
694 subTreeTextContent = '';
695 editorTextContent = '';
696 // Rather than pass around a load of arguments through the stack recursively
697 // we instead set them as bindings within the scope of the module.
698 treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
699 activeEditor = editor;
700 activeEditorConfig = editor._config;
701 activeEditorNodes = editor._nodes;
702 activeMutationListeners = activeEditor._listeners.mutation;
703 activeDirtyElements = dirtyElements;
704 activeDirtyLeaves = dirtyLeaves;
705 activePrevNodeMap = prevEditorState._nodeMap;
706 activeNextNodeMap = nextEditorState._nodeMap;
707 activeEditorStateReadOnly = nextEditorState._readOnly;
708 activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
709 // We keep track of mutated nodes so we can trigger mutation
710 // listeners later in the update cycle.
711 const currentMutatedNodes = new Map();
712 mutatedNodes = currentMutatedNodes;
713 $reconcileNode('root', null);
714 // We don't want a bunch of void checks throughout the scope
715 // so instead we make it seem that these values are always set.
716 // We also want to make sure we clear them down, otherwise we
719 activeEditor = undefined;
721 activeEditorNodes = undefined;
723 activeDirtyElements = undefined;
725 activeDirtyLeaves = undefined;
727 activePrevNodeMap = undefined;
729 activeNextNodeMap = undefined;
731 activeEditorConfig = undefined;
733 activePrevKeyToDOMMap = undefined;
735 mutatedNodes = undefined;
737 return currentMutatedNodes;
740 export function storeDOMWithKey(
743 editor: LexicalEditor,
745 const keyToDOMMap = editor._keyToDOMMap;
746 // @ts-ignore We intentionally add this to the Node.
747 dom['__lexicalKey_' + editor._key] = key;
748 keyToDOMMap.set(key, dom);
751 function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
752 const element = activePrevKeyToDOMMap.get(key);
754 if (element === undefined) {
757 'Reconciliation: could not find DOM element for node key %s',