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) {
175 const inserted = node?.insertDOMIntoParent(dom, parentDOM);
178 if (insertDOM != null) {
179 parentDOM.insertBefore(dom, insertDOM);
181 // @ts-expect-error: internal field
182 const possibleLineBreak = parentDOM.__lexicalLineBreak;
184 if (possibleLineBreak != null) {
185 parentDOM.insertBefore(dom, possibleLineBreak);
187 parentDOM.appendChild(dom);
194 // Freeze the node in DEV to prevent accidental mutations
201 activeMutationListeners,
208 function $createChildren(
209 children: Array<NodeKey>,
210 element: ElementNode,
213 dom: null | HTMLElement,
214 insertDOM: null | HTMLElement,
216 const previousSubTreeTextContent = subTreeTextContent;
217 subTreeTextContent = '';
218 let startIndex = _startIndex;
220 for (; startIndex <= endIndex; ++startIndex) {
221 $createNode(children[startIndex], dom, insertDOM);
222 const node = activeNextNodeMap.get(children[startIndex]);
223 if (node !== null && $isTextNode(node)) {
224 if (subTreeTextFormat === null) {
225 subTreeTextFormat = node.getFormat();
227 if (subTreeTextStyle === '') {
228 subTreeTextStyle = node.getStyle();
232 if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
233 subTreeTextContent += DOUBLE_LINE_BREAK;
235 // @ts-expect-error: internal field
236 dom.__lexicalTextContent = subTreeTextContent;
237 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
240 function isLastChildLineBreakOrDecorator(
244 const node = nodeMap.get(childKey);
245 return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
248 // If we end an element with a LineBreakNode, then we need to add an additional <br>
249 function reconcileElementTerminatingLineBreak(
250 prevElement: null | ElementNode,
251 nextElement: ElementNode,
254 const prevLineBreak =
255 prevElement !== null &&
256 (prevElement.__size === 0 ||
257 isLastChildLineBreakOrDecorator(
258 prevElement.__last as NodeKey,
261 const nextLineBreak =
262 nextElement.__size === 0 ||
263 isLastChildLineBreakOrDecorator(
264 nextElement.__last as NodeKey,
269 if (!nextLineBreak) {
270 // @ts-expect-error: internal field
271 const element = dom.__lexicalLineBreak;
273 if (element != null) {
275 dom.removeChild(element);
277 if (typeof error === 'object' && error != null) {
278 const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
281 throw new Error(msg);
288 // @ts-expect-error: internal field
289 dom.__lexicalLineBreak = null;
291 } else if (nextLineBreak) {
292 const element = document.createElement('br');
293 // @ts-expect-error: internal field
294 dom.__lexicalLineBreak = element;
295 dom.appendChild(element);
299 function reconcileParagraphFormat(element: ElementNode): void {
301 $isParagraphNode(element) &&
302 subTreeTextFormat != null &&
303 !activeEditorStateReadOnly
305 element.setTextStyle(subTreeTextStyle);
309 function reconcileParagraphStyle(element: ElementNode): void {
311 $isParagraphNode(element) &&
312 subTreeTextStyle !== '' &&
313 subTreeTextStyle !== element.__textStyle &&
314 !activeEditorStateReadOnly
316 element.setTextStyle(subTreeTextStyle);
320 function $reconcileChildrenWithDirection(
321 prevElement: ElementNode,
322 nextElement: ElementNode,
325 subTreeTextFormat = null;
326 subTreeTextStyle = '';
327 $reconcileChildren(prevElement, nextElement, dom);
328 reconcileParagraphFormat(nextElement);
329 reconcileParagraphStyle(nextElement);
332 function createChildrenArray(
333 element: ElementNode,
337 let nodeKey = element.__first;
338 while (nodeKey !== null) {
339 const node = nodeMap.get(nodeKey);
340 if (node === undefined) {
341 invariant(false, 'createChildrenArray: node does not exist in nodeMap');
343 children.push(nodeKey);
344 nodeKey = node.__next;
349 function $reconcileChildren(
350 prevElement: ElementNode,
351 nextElement: ElementNode,
354 const previousSubTreeTextContent = subTreeTextContent;
355 const prevChildrenSize = prevElement.__size;
356 const nextChildrenSize = nextElement.__size;
357 subTreeTextContent = '';
359 if (prevChildrenSize === 1 && nextChildrenSize === 1) {
360 const prevFirstChildKey = prevElement.__first as NodeKey;
361 const nextFrstChildKey = nextElement.__first as NodeKey;
362 if (prevFirstChildKey === nextFrstChildKey) {
363 $reconcileNode(prevFirstChildKey, dom);
365 const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
366 const replacementDOM = $createNode(nextFrstChildKey, null, null);
368 dom.replaceChild(replacementDOM, lastDOM);
370 if (typeof error === 'object' && error != null) {
371 const msg = `${error.toString()} Parent: ${
373 }, new child: {tag: ${
374 replacementDOM.tagName
375 } key: ${nextFrstChildKey}}, old child: {tag: ${
377 }, key: ${prevFirstChildKey}}.`;
378 throw new Error(msg);
383 destroyNode(prevFirstChildKey, null);
385 const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
386 if ($isTextNode(nextChildNode)) {
387 if (subTreeTextFormat === null) {
388 subTreeTextFormat = nextChildNode.getFormat();
390 if (subTreeTextStyle === '') {
391 subTreeTextStyle = nextChildNode.getStyle();
395 const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
396 const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
398 if (prevChildrenSize === 0) {
399 if (nextChildrenSize !== 0) {
404 nextChildrenSize - 1,
409 } else if (nextChildrenSize === 0) {
410 if (prevChildrenSize !== 0) {
411 // @ts-expect-error: internal field
412 const lexicalLineBreak = dom.__lexicalLineBreak;
413 const canUseFastPath = lexicalLineBreak == null;
417 prevChildrenSize - 1,
418 canUseFastPath ? null : dom,
421 if (canUseFastPath) {
422 // Fast path for removing DOM nodes
423 dom.textContent = '';
427 $reconcileNodeChildren(
438 if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
439 subTreeTextContent += DOUBLE_LINE_BREAK;
442 // @ts-expect-error: internal field
443 dom.__lexicalTextContent = subTreeTextContent;
444 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
447 function $reconcileNode(
449 parentDOM: HTMLElement | null,
451 const prevNode = activePrevNodeMap.get(key);
452 let nextNode = activeNextNodeMap.get(key);
454 if (prevNode === undefined || nextNode === undefined) {
457 'reconcileNode: prevNode or nextNode does not exist in nodeMap',
462 treatAllNodesAsDirty ||
463 activeDirtyLeaves.has(key) ||
464 activeDirtyElements.has(key);
465 const dom = getElementByKeyOrThrow(activeEditor, key);
467 // If the node key points to the same instance in both states
468 // and isn't dirty, we just update the text content cache
469 // and return the existing DOM Node.
470 if (prevNode === nextNode && !isDirty) {
471 if ($isElementNode(prevNode)) {
472 // @ts-expect-error: internal field
473 const previousSubTreeTextContent = dom.__lexicalTextContent;
475 if (previousSubTreeTextContent !== undefined) {
476 subTreeTextContent += previousSubTreeTextContent;
477 editorTextContent += previousSubTreeTextContent;
480 const text = prevNode.getTextContent();
482 editorTextContent += text;
483 subTreeTextContent += text;
488 // If the node key doesn't point to the same instance in both maps,
489 // it means it were cloned. If they're also dirty, we mark them as mutated.
490 if (prevNode !== nextNode && isDirty) {
494 activeMutationListeners,
500 // Update node. If it returns true, we need to unmount and re-create the node
501 if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
502 const replacementDOM = $createNode(key, null, null);
504 if (parentDOM === null) {
505 invariant(false, 'reconcileNode: parentDOM is null');
508 parentDOM.replaceChild(replacementDOM, dom);
509 destroyNode(key, null);
510 return replacementDOM;
513 if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
514 // Reconcile element children
516 $reconcileChildrenWithDirection(prevNode, nextNode, dom);
517 if (!$isRootNode(nextNode) && !nextNode.isInline()) {
518 reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
522 if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
523 subTreeTextContent += DOUBLE_LINE_BREAK;
524 editorTextContent += DOUBLE_LINE_BREAK;
527 const text = nextNode.getTextContent();
529 if ($isDecoratorNode(nextNode)) {
530 const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
532 if (decorator !== null) {
533 reconcileDecorator(key, decorator);
537 subTreeTextContent += text;
538 editorTextContent += text;
542 !activeEditorStateReadOnly &&
543 $isRootNode(nextNode) &&
544 nextNode.__cachedText !== editorTextContent
546 // Cache the latest text content.
547 const nextRootNode = nextNode.getWritable();
548 nextRootNode.__cachedText = editorTextContent;
549 nextNode = nextRootNode;
553 // Freeze the node in DEV to prevent accidental mutations
554 Object.freeze(nextNode);
560 function reconcileDecorator(key: NodeKey, decorator: unknown): void {
561 let pendingDecorators = activeEditor._pendingDecorators;
562 const currentDecorators = activeEditor._decorators;
564 if (pendingDecorators === null) {
565 if (currentDecorators[key] === decorator) {
569 pendingDecorators = cloneDecorators(activeEditor);
572 pendingDecorators[key] = decorator;
575 function getFirstChild(element: HTMLElement): Node | null {
576 return element.firstChild;
579 function getNextSibling(element: HTMLElement): Node | null {
580 let nextSibling = element.nextSibling;
582 nextSibling !== null &&
583 nextSibling === activeEditor._blockCursorElement
585 nextSibling = nextSibling.nextSibling;
590 function $reconcileNodeChildren(
591 nextElement: ElementNode,
592 prevChildren: Array<NodeKey>,
593 nextChildren: Array<NodeKey>,
594 prevChildrenLength: number,
595 nextChildrenLength: number,
598 const prevEndIndex = prevChildrenLength - 1;
599 const nextEndIndex = nextChildrenLength - 1;
600 let prevChildrenSet: Set<NodeKey> | undefined;
601 let nextChildrenSet: Set<NodeKey> | undefined;
602 let siblingDOM: null | Node = getFirstChild(dom);
606 while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
607 const prevKey = prevChildren[prevIndex];
608 const nextKey = nextChildren[nextIndex];
610 if (prevKey === nextKey) {
611 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
615 if (prevChildrenSet === undefined) {
616 prevChildrenSet = new Set(prevChildren);
619 if (nextChildrenSet === undefined) {
620 nextChildrenSet = new Set(nextChildren);
623 const nextHasPrevKey = nextChildrenSet.has(prevKey);
624 const prevHasNextKey = prevChildrenSet.has(nextKey);
626 if (!nextHasPrevKey) {
628 siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
629 destroyNode(prevKey, dom);
631 } else if (!prevHasNextKey) {
633 $createNode(nextKey, dom, siblingDOM);
637 const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
639 if (childDOM === siblingDOM) {
640 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
642 if (siblingDOM != null) {
643 dom.insertBefore(childDOM, siblingDOM);
645 dom.appendChild(childDOM);
648 $reconcileNode(nextKey, dom);
656 const node = activeNextNodeMap.get(nextKey);
657 if (node !== null && $isTextNode(node)) {
658 if (subTreeTextFormat === null) {
659 subTreeTextFormat = node.getFormat();
661 if (subTreeTextStyle === '') {
662 subTreeTextStyle = node.getStyle();
667 const appendNewChildren = prevIndex > prevEndIndex;
668 const removeOldChildren = nextIndex > nextEndIndex;
670 if (appendNewChildren && !removeOldChildren) {
671 const previousNode = nextChildren[nextEndIndex + 1];
673 previousNode === undefined
675 : activeEditor.getElementByKey(previousNode);
684 } else if (removeOldChildren && !appendNewChildren) {
685 destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
689 export function $reconcileRoot(
690 prevEditorState: EditorState,
691 nextEditorState: EditorState,
692 editor: LexicalEditor,
693 dirtyType: 0 | 1 | 2,
694 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
695 dirtyLeaves: Set<NodeKey>,
697 // We cache text content to make retrieval more efficient.
698 // The cache must be rebuilt during reconciliation to account for any changes.
699 subTreeTextContent = '';
700 editorTextContent = '';
701 // Rather than pass around a load of arguments through the stack recursively
702 // we instead set them as bindings within the scope of the module.
703 treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
704 activeEditor = editor;
705 activeEditorConfig = editor._config;
706 activeEditorNodes = editor._nodes;
707 activeMutationListeners = activeEditor._listeners.mutation;
708 activeDirtyElements = dirtyElements;
709 activeDirtyLeaves = dirtyLeaves;
710 activePrevNodeMap = prevEditorState._nodeMap;
711 activeNextNodeMap = nextEditorState._nodeMap;
712 activeEditorStateReadOnly = nextEditorState._readOnly;
713 activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
714 // We keep track of mutated nodes so we can trigger mutation
715 // listeners later in the update cycle.
716 const currentMutatedNodes = new Map();
717 mutatedNodes = currentMutatedNodes;
718 $reconcileNode('root', null);
719 // We don't want a bunch of void checks throughout the scope
720 // so instead we make it seem that these values are always set.
721 // We also want to make sure we clear them down, otherwise we
724 activeEditor = undefined;
726 activeEditorNodes = undefined;
728 activeDirtyElements = undefined;
730 activeDirtyLeaves = undefined;
732 activePrevNodeMap = undefined;
734 activeNextNodeMap = undefined;
736 activeEditorConfig = undefined;
738 activePrevKeyToDOMMap = undefined;
740 mutatedNodes = undefined;
742 return currentMutatedNodes;
745 export function storeDOMWithKey(
748 editor: LexicalEditor,
750 const keyToDOMMap = editor._keyToDOMMap;
751 // @ts-ignore We intentionally add this to the Node.
752 dom['__lexicalKey_' + editor._key] = key;
753 keyToDOMMap.set(key, dom);
756 function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
757 const element = activePrevKeyToDOMMap.get(key);
759 if (element === undefined) {
762 'Reconciliation: could not find DOM element for node key %s',