]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalReconciler.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / core / LexicalReconciler.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {
10   EditorConfig,
11   LexicalEditor,
12   MutatedNodes,
13   MutationListeners,
14   RegisteredNodes,
15 } from './LexicalEditor';
16 import type {NodeKey, NodeMap} from './LexicalNode';
17 import type {ElementNode} from './nodes/LexicalElementNode';
18
19 import invariant from 'lexical/shared/invariant';
20 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
21
22 import {
23   $isDecoratorNode,
24   $isElementNode,
25   $isLineBreakNode,
26   $isParagraphNode,
27   $isRootNode,
28   $isTextNode,
29 } from '.';
30 import {
31   DOUBLE_LINE_BREAK,
32   FULL_RECONCILE,
33   IS_ALIGN_CENTER,
34   IS_ALIGN_END,
35   IS_ALIGN_JUSTIFY,
36   IS_ALIGN_LEFT,
37   IS_ALIGN_RIGHT,
38   IS_ALIGN_START,
39 } from './LexicalConstants';
40 import {EditorState} from './LexicalEditorState';
41 import {
42   $textContentRequiresDoubleLinebreakAtEnd,
43   cloneDecorators,
44   getElementByKeyOrThrow,
45   getTextDirection,
46   setMutatedNode,
47 } from './LexicalUtils';
48
49 type IntentionallyMarkedAsDirtyElement = boolean;
50
51 let subTreeTextContent = '';
52 let subTreeDirectionedTextContent = '';
53 let subTreeTextFormat: number | null = null;
54 let subTreeTextStyle: string = '';
55 let editorTextContent = '';
56 let activeEditorConfig: EditorConfig;
57 let activeEditor: LexicalEditor;
58 let activeEditorNodes: RegisteredNodes;
59 let treatAllNodesAsDirty = false;
60 let activeEditorStateReadOnly = false;
61 let activeMutationListeners: MutationListeners;
62 let activeTextDirection: 'ltr' | 'rtl' | null = null;
63 let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
64 let activeDirtyLeaves: Set<NodeKey>;
65 let activePrevNodeMap: NodeMap;
66 let activeNextNodeMap: NodeMap;
67 let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
68 let mutatedNodes: MutatedNodes;
69
70 function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
71   const node = activePrevNodeMap.get(key);
72
73   if (parentDOM !== null) {
74     const dom = getPrevElementByKeyOrThrow(key);
75     if (dom.parentNode === parentDOM) {
76       parentDOM.removeChild(dom);
77     }
78   }
79
80   // This logic is really important, otherwise we will leak DOM nodes
81   // when their corresponding LexicalNodes are removed from the editor state.
82   if (!activeNextNodeMap.has(key)) {
83     activeEditor._keyToDOMMap.delete(key);
84   }
85
86   if ($isElementNode(node)) {
87     const children = createChildrenArray(node, activePrevNodeMap);
88     destroyChildren(children, 0, children.length - 1, null);
89   }
90
91   if (node !== undefined) {
92     setMutatedNode(
93       mutatedNodes,
94       activeEditorNodes,
95       activeMutationListeners,
96       node,
97       'destroyed',
98     );
99   }
100 }
101
102 function destroyChildren(
103   children: Array<NodeKey>,
104   _startIndex: number,
105   endIndex: number,
106   dom: null | HTMLElement,
107 ): void {
108   let startIndex = _startIndex;
109
110   for (; startIndex <= endIndex; ++startIndex) {
111     const child = children[startIndex];
112
113     if (child !== undefined) {
114       destroyNode(child, dom);
115     }
116   }
117 }
118
119 function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
120   domStyle.setProperty('text-align', value);
121 }
122
123 const DEFAULT_INDENT_VALUE = '40px';
124
125 function setElementIndent(dom: HTMLElement, indent: number): void {
126   const indentClassName = activeEditorConfig.theme.indent;
127
128   if (typeof indentClassName === 'string') {
129     const elementHasClassName = dom.classList.contains(indentClassName);
130
131     if (indent > 0 && !elementHasClassName) {
132       dom.classList.add(indentClassName);
133     } else if (indent < 1 && elementHasClassName) {
134       dom.classList.remove(indentClassName);
135     }
136   }
137
138   const indentationBaseValue =
139     getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') ||
140     DEFAULT_INDENT_VALUE;
141
142   dom.style.setProperty(
143     'padding-inline-start',
144     indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`,
145   );
146 }
147
148 function setElementFormat(dom: HTMLElement, format: number): void {
149   const domStyle = dom.style;
150
151   if (format === 0) {
152     setTextAlign(domStyle, '');
153   } else if (format === IS_ALIGN_LEFT) {
154     setTextAlign(domStyle, 'left');
155   } else if (format === IS_ALIGN_CENTER) {
156     setTextAlign(domStyle, 'center');
157   } else if (format === IS_ALIGN_RIGHT) {
158     setTextAlign(domStyle, 'right');
159   } else if (format === IS_ALIGN_JUSTIFY) {
160     setTextAlign(domStyle, 'justify');
161   } else if (format === IS_ALIGN_START) {
162     setTextAlign(domStyle, 'start');
163   } else if (format === IS_ALIGN_END) {
164     setTextAlign(domStyle, 'end');
165   }
166 }
167
168 function $createNode(
169   key: NodeKey,
170   parentDOM: null | HTMLElement,
171   insertDOM: null | Node,
172 ): HTMLElement {
173   const node = activeNextNodeMap.get(key);
174
175   if (node === undefined) {
176     invariant(false, 'createNode: node does not exist in nodeMap');
177   }
178   const dom = node.createDOM(activeEditorConfig, activeEditor);
179   storeDOMWithKey(key, dom, activeEditor);
180
181   // This helps preserve the text, and stops spell check tools from
182   // merging or break the spans (which happens if they are missing
183   // this attribute).
184   if ($isTextNode(node)) {
185     dom.setAttribute('data-lexical-text', 'true');
186   } else if ($isDecoratorNode(node)) {
187     dom.setAttribute('data-lexical-decorator', 'true');
188   }
189
190   if ($isElementNode(node)) {
191     const indent = node.__indent;
192     const childrenSize = node.__size;
193
194     if (indent !== 0) {
195       setElementIndent(dom, indent);
196     }
197     if (childrenSize !== 0) {
198       const endIndex = childrenSize - 1;
199       const children = createChildrenArray(node, activeNextNodeMap);
200       $createChildrenWithDirection(children, endIndex, node, dom);
201     }
202     const format = node.__format;
203
204     if (format !== 0) {
205       setElementFormat(dom, format);
206     }
207     if (!node.isInline()) {
208       reconcileElementTerminatingLineBreak(null, node, dom);
209     }
210     if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
211       subTreeTextContent += DOUBLE_LINE_BREAK;
212       editorTextContent += DOUBLE_LINE_BREAK;
213     }
214   } else {
215     const text = node.getTextContent();
216
217     if ($isDecoratorNode(node)) {
218       const decorator = node.decorate(activeEditor, activeEditorConfig);
219
220       if (decorator !== null) {
221         reconcileDecorator(key, decorator);
222       }
223       // Decorators are always non editable
224       dom.contentEditable = 'false';
225     } else if ($isTextNode(node)) {
226       if (!node.isDirectionless()) {
227         subTreeDirectionedTextContent += text;
228       }
229     }
230     subTreeTextContent += text;
231     editorTextContent += text;
232   }
233
234   if (parentDOM !== null) {
235     if (insertDOM != null) {
236       parentDOM.insertBefore(dom, insertDOM);
237     } else {
238       // @ts-expect-error: internal field
239       const possibleLineBreak = parentDOM.__lexicalLineBreak;
240
241       if (possibleLineBreak != null) {
242         parentDOM.insertBefore(dom, possibleLineBreak);
243       } else {
244         parentDOM.appendChild(dom);
245       }
246     }
247   }
248
249   if (__DEV__) {
250     // Freeze the node in DEV to prevent accidental mutations
251     Object.freeze(node);
252   }
253
254   setMutatedNode(
255     mutatedNodes,
256     activeEditorNodes,
257     activeMutationListeners,
258     node,
259     'created',
260   );
261   return dom;
262 }
263
264 function $createChildrenWithDirection(
265   children: Array<NodeKey>,
266   endIndex: number,
267   element: ElementNode,
268   dom: HTMLElement,
269 ): void {
270   const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent;
271   subTreeDirectionedTextContent = '';
272   $createChildren(children, element, 0, endIndex, dom, null);
273   reconcileBlockDirection(element, dom);
274   subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent;
275 }
276
277 function $createChildren(
278   children: Array<NodeKey>,
279   element: ElementNode,
280   _startIndex: number,
281   endIndex: number,
282   dom: null | HTMLElement,
283   insertDOM: null | HTMLElement,
284 ): void {
285   const previousSubTreeTextContent = subTreeTextContent;
286   subTreeTextContent = '';
287   let startIndex = _startIndex;
288
289   for (; startIndex <= endIndex; ++startIndex) {
290     $createNode(children[startIndex], dom, insertDOM);
291     const node = activeNextNodeMap.get(children[startIndex]);
292     if (node !== null && $isTextNode(node)) {
293       if (subTreeTextFormat === null) {
294         subTreeTextFormat = node.getFormat();
295       }
296       if (subTreeTextStyle === '') {
297         subTreeTextStyle = node.getStyle();
298       }
299     }
300   }
301   if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
302     subTreeTextContent += DOUBLE_LINE_BREAK;
303   }
304   // @ts-expect-error: internal field
305   dom.__lexicalTextContent = subTreeTextContent;
306   subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
307 }
308
309 function isLastChildLineBreakOrDecorator(
310   childKey: NodeKey,
311   nodeMap: NodeMap,
312 ): boolean {
313   const node = nodeMap.get(childKey);
314   return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
315 }
316
317 // If we end an element with a LineBreakNode, then we need to add an additional <br>
318 function reconcileElementTerminatingLineBreak(
319   prevElement: null | ElementNode,
320   nextElement: ElementNode,
321   dom: HTMLElement,
322 ): void {
323   const prevLineBreak =
324     prevElement !== null &&
325     (prevElement.__size === 0 ||
326       isLastChildLineBreakOrDecorator(
327         prevElement.__last as NodeKey,
328         activePrevNodeMap,
329       ));
330   const nextLineBreak =
331     nextElement.__size === 0 ||
332     isLastChildLineBreakOrDecorator(
333       nextElement.__last as NodeKey,
334       activeNextNodeMap,
335     );
336
337   if (prevLineBreak) {
338     if (!nextLineBreak) {
339       // @ts-expect-error: internal field
340       const element = dom.__lexicalLineBreak;
341
342       if (element != null) {
343         try {
344           dom.removeChild(element);
345         } catch (error) {
346           if (typeof error === 'object' && error != null) {
347             const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
348               element.tagName
349             }.`;
350             throw new Error(msg);
351           } else {
352             throw error;
353           }
354         }
355       }
356
357       // @ts-expect-error: internal field
358       dom.__lexicalLineBreak = null;
359     }
360   } else if (nextLineBreak) {
361     const element = document.createElement('br');
362     // @ts-expect-error: internal field
363     dom.__lexicalLineBreak = element;
364     dom.appendChild(element);
365   }
366 }
367
368 function reconcileParagraphFormat(element: ElementNode): void {
369   if (
370     $isParagraphNode(element) &&
371     subTreeTextFormat != null &&
372     subTreeTextFormat !== element.__textFormat &&
373     !activeEditorStateReadOnly
374   ) {
375     element.setTextFormat(subTreeTextFormat);
376     element.setTextStyle(subTreeTextStyle);
377   }
378 }
379
380 function reconcileParagraphStyle(element: ElementNode): void {
381   if (
382     $isParagraphNode(element) &&
383     subTreeTextStyle !== '' &&
384     subTreeTextStyle !== element.__textStyle &&
385     !activeEditorStateReadOnly
386   ) {
387     element.setTextStyle(subTreeTextStyle);
388   }
389 }
390
391 function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void {
392   const previousSubTreeDirectionTextContent: string =
393     // @ts-expect-error: internal field
394     dom.__lexicalDirTextContent;
395   // @ts-expect-error: internal field
396   const previousDirection: string = dom.__lexicalDir;
397
398   if (
399     previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent ||
400     previousDirection !== activeTextDirection
401   ) {
402     const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === '';
403     const direction = hasEmptyDirectionedTextContent
404       ? activeTextDirection
405       : getTextDirection(subTreeDirectionedTextContent);
406
407     if (direction !== previousDirection) {
408       const classList = dom.classList;
409       const theme = activeEditorConfig.theme;
410       let previousDirectionTheme =
411         previousDirection !== null ? theme[previousDirection] : undefined;
412       let nextDirectionTheme =
413         direction !== null ? theme[direction] : undefined;
414
415       // Remove the old theme classes if they exist
416       if (previousDirectionTheme !== undefined) {
417         if (typeof previousDirectionTheme === 'string') {
418           const classNamesArr = normalizeClassNames(previousDirectionTheme);
419           previousDirectionTheme = theme[previousDirection] = classNamesArr;
420         }
421
422         // @ts-ignore: intentional
423         classList.remove(...previousDirectionTheme);
424       }
425
426       if (
427         direction === null ||
428         (hasEmptyDirectionedTextContent && direction === 'ltr')
429       ) {
430         // Remove direction
431         dom.removeAttribute('dir');
432       } else {
433         // Apply the new theme classes if they exist
434         if (nextDirectionTheme !== undefined) {
435           if (typeof nextDirectionTheme === 'string') {
436             const classNamesArr = normalizeClassNames(nextDirectionTheme);
437             // @ts-expect-error: intentional
438             nextDirectionTheme = theme[direction] = classNamesArr;
439           }
440
441           if (nextDirectionTheme !== undefined) {
442             classList.add(...nextDirectionTheme);
443           }
444         }
445
446         // Update direction
447         dom.dir = direction;
448       }
449
450       if (!activeEditorStateReadOnly) {
451         const writableNode = element.getWritable();
452         writableNode.__dir = direction;
453       }
454     }
455
456     activeTextDirection = direction;
457     // @ts-expect-error: internal field
458     dom.__lexicalDirTextContent = subTreeDirectionedTextContent;
459     // @ts-expect-error: internal field
460     dom.__lexicalDir = direction;
461   }
462 }
463
464 function $reconcileChildrenWithDirection(
465   prevElement: ElementNode,
466   nextElement: ElementNode,
467   dom: HTMLElement,
468 ): void {
469   const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent;
470   subTreeDirectionedTextContent = '';
471   subTreeTextFormat = null;
472   subTreeTextStyle = '';
473   $reconcileChildren(prevElement, nextElement, dom);
474   reconcileBlockDirection(nextElement, dom);
475   reconcileParagraphFormat(nextElement);
476   reconcileParagraphStyle(nextElement);
477   subTreeDirectionedTextContent = previousSubTreeDirectionTextContent;
478 }
479
480 function createChildrenArray(
481   element: ElementNode,
482   nodeMap: NodeMap,
483 ): Array<NodeKey> {
484   const children = [];
485   let nodeKey = element.__first;
486   while (nodeKey !== null) {
487     const node = nodeMap.get(nodeKey);
488     if (node === undefined) {
489       invariant(false, 'createChildrenArray: node does not exist in nodeMap');
490     }
491     children.push(nodeKey);
492     nodeKey = node.__next;
493   }
494   return children;
495 }
496
497 function $reconcileChildren(
498   prevElement: ElementNode,
499   nextElement: ElementNode,
500   dom: HTMLElement,
501 ): void {
502   const previousSubTreeTextContent = subTreeTextContent;
503   const prevChildrenSize = prevElement.__size;
504   const nextChildrenSize = nextElement.__size;
505   subTreeTextContent = '';
506
507   if (prevChildrenSize === 1 && nextChildrenSize === 1) {
508     const prevFirstChildKey = prevElement.__first as NodeKey;
509     const nextFrstChildKey = nextElement.__first as NodeKey;
510     if (prevFirstChildKey === nextFrstChildKey) {
511       $reconcileNode(prevFirstChildKey, dom);
512     } else {
513       const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
514       const replacementDOM = $createNode(nextFrstChildKey, null, null);
515       try {
516         dom.replaceChild(replacementDOM, lastDOM);
517       } catch (error) {
518         if (typeof error === 'object' && error != null) {
519           const msg = `${error.toString()} Parent: ${
520             dom.tagName
521           }, new child: {tag: ${
522             replacementDOM.tagName
523           } key: ${nextFrstChildKey}}, old child: {tag: ${
524             lastDOM.tagName
525           }, key: ${prevFirstChildKey}}.`;
526           throw new Error(msg);
527         } else {
528           throw error;
529         }
530       }
531       destroyNode(prevFirstChildKey, null);
532     }
533     const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
534     if ($isTextNode(nextChildNode)) {
535       if (subTreeTextFormat === null) {
536         subTreeTextFormat = nextChildNode.getFormat();
537       }
538       if (subTreeTextStyle === '') {
539         subTreeTextStyle = nextChildNode.getStyle();
540       }
541     }
542   } else {
543     const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
544     const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
545
546     if (prevChildrenSize === 0) {
547       if (nextChildrenSize !== 0) {
548         $createChildren(
549           nextChildren,
550           nextElement,
551           0,
552           nextChildrenSize - 1,
553           dom,
554           null,
555         );
556       }
557     } else if (nextChildrenSize === 0) {
558       if (prevChildrenSize !== 0) {
559         // @ts-expect-error: internal field
560         const lexicalLineBreak = dom.__lexicalLineBreak;
561         const canUseFastPath = lexicalLineBreak == null;
562         destroyChildren(
563           prevChildren,
564           0,
565           prevChildrenSize - 1,
566           canUseFastPath ? null : dom,
567         );
568
569         if (canUseFastPath) {
570           // Fast path for removing DOM nodes
571           dom.textContent = '';
572         }
573       }
574     } else {
575       $reconcileNodeChildren(
576         nextElement,
577         prevChildren,
578         nextChildren,
579         prevChildrenSize,
580         nextChildrenSize,
581         dom,
582       );
583     }
584   }
585
586   if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
587     subTreeTextContent += DOUBLE_LINE_BREAK;
588   }
589
590   // @ts-expect-error: internal field
591   dom.__lexicalTextContent = subTreeTextContent;
592   subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
593 }
594
595 function $reconcileNode(
596   key: NodeKey,
597   parentDOM: HTMLElement | null,
598 ): HTMLElement {
599   const prevNode = activePrevNodeMap.get(key);
600   let nextNode = activeNextNodeMap.get(key);
601
602   if (prevNode === undefined || nextNode === undefined) {
603     invariant(
604       false,
605       'reconcileNode: prevNode or nextNode does not exist in nodeMap',
606     );
607   }
608
609   const isDirty =
610     treatAllNodesAsDirty ||
611     activeDirtyLeaves.has(key) ||
612     activeDirtyElements.has(key);
613   const dom = getElementByKeyOrThrow(activeEditor, key);
614
615   // If the node key points to the same instance in both states
616   // and isn't dirty, we just update the text content cache
617   // and return the existing DOM Node.
618   if (prevNode === nextNode && !isDirty) {
619     if ($isElementNode(prevNode)) {
620       // @ts-expect-error: internal field
621       const previousSubTreeTextContent = dom.__lexicalTextContent;
622
623       if (previousSubTreeTextContent !== undefined) {
624         subTreeTextContent += previousSubTreeTextContent;
625         editorTextContent += previousSubTreeTextContent;
626       }
627
628       // @ts-expect-error: internal field
629       const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent;
630
631       if (previousSubTreeDirectionTextContent !== undefined) {
632         subTreeDirectionedTextContent += previousSubTreeDirectionTextContent;
633       }
634     } else {
635       const text = prevNode.getTextContent();
636
637       if ($isTextNode(prevNode) && !prevNode.isDirectionless()) {
638         subTreeDirectionedTextContent += text;
639       }
640
641       editorTextContent += text;
642       subTreeTextContent += text;
643     }
644
645     return dom;
646   }
647   // If the node key doesn't point to the same instance in both maps,
648   // it means it were cloned. If they're also dirty, we mark them as mutated.
649   if (prevNode !== nextNode && isDirty) {
650     setMutatedNode(
651       mutatedNodes,
652       activeEditorNodes,
653       activeMutationListeners,
654       nextNode,
655       'updated',
656     );
657   }
658
659   // Update node. If it returns true, we need to unmount and re-create the node
660   if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
661     const replacementDOM = $createNode(key, null, null);
662
663     if (parentDOM === null) {
664       invariant(false, 'reconcileNode: parentDOM is null');
665     }
666
667     parentDOM.replaceChild(replacementDOM, dom);
668     destroyNode(key, null);
669     return replacementDOM;
670   }
671
672   if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
673     // Reconcile element children
674     const nextIndent = nextNode.__indent;
675
676     if (nextIndent !== prevNode.__indent) {
677       setElementIndent(dom, nextIndent);
678     }
679
680     const nextFormat = nextNode.__format;
681
682     if (nextFormat !== prevNode.__format) {
683       setElementFormat(dom, nextFormat);
684     }
685     if (isDirty) {
686       $reconcileChildrenWithDirection(prevNode, nextNode, dom);
687       if (!$isRootNode(nextNode) && !nextNode.isInline()) {
688         reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
689       }
690     }
691
692     if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
693       subTreeTextContent += DOUBLE_LINE_BREAK;
694       editorTextContent += DOUBLE_LINE_BREAK;
695     }
696   } else {
697     const text = nextNode.getTextContent();
698
699     if ($isDecoratorNode(nextNode)) {
700       const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
701
702       if (decorator !== null) {
703         reconcileDecorator(key, decorator);
704       }
705     } else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) {
706       // Handle text content, for LTR, LTR cases.
707       subTreeDirectionedTextContent += text;
708     }
709
710     subTreeTextContent += text;
711     editorTextContent += text;
712   }
713
714   if (
715     !activeEditorStateReadOnly &&
716     $isRootNode(nextNode) &&
717     nextNode.__cachedText !== editorTextContent
718   ) {
719     // Cache the latest text content.
720     const nextRootNode = nextNode.getWritable();
721     nextRootNode.__cachedText = editorTextContent;
722     nextNode = nextRootNode;
723   }
724
725   if (__DEV__) {
726     // Freeze the node in DEV to prevent accidental mutations
727     Object.freeze(nextNode);
728   }
729
730   return dom;
731 }
732
733 function reconcileDecorator(key: NodeKey, decorator: unknown): void {
734   let pendingDecorators = activeEditor._pendingDecorators;
735   const currentDecorators = activeEditor._decorators;
736
737   if (pendingDecorators === null) {
738     if (currentDecorators[key] === decorator) {
739       return;
740     }
741
742     pendingDecorators = cloneDecorators(activeEditor);
743   }
744
745   pendingDecorators[key] = decorator;
746 }
747
748 function getFirstChild(element: HTMLElement): Node | null {
749   return element.firstChild;
750 }
751
752 function getNextSibling(element: HTMLElement): Node | null {
753   let nextSibling = element.nextSibling;
754   if (
755     nextSibling !== null &&
756     nextSibling === activeEditor._blockCursorElement
757   ) {
758     nextSibling = nextSibling.nextSibling;
759   }
760   return nextSibling;
761 }
762
763 function $reconcileNodeChildren(
764   nextElement: ElementNode,
765   prevChildren: Array<NodeKey>,
766   nextChildren: Array<NodeKey>,
767   prevChildrenLength: number,
768   nextChildrenLength: number,
769   dom: HTMLElement,
770 ): void {
771   const prevEndIndex = prevChildrenLength - 1;
772   const nextEndIndex = nextChildrenLength - 1;
773   let prevChildrenSet: Set<NodeKey> | undefined;
774   let nextChildrenSet: Set<NodeKey> | undefined;
775   let siblingDOM: null | Node = getFirstChild(dom);
776   let prevIndex = 0;
777   let nextIndex = 0;
778
779   while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
780     const prevKey = prevChildren[prevIndex];
781     const nextKey = nextChildren[nextIndex];
782
783     if (prevKey === nextKey) {
784       siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
785       prevIndex++;
786       nextIndex++;
787     } else {
788       if (prevChildrenSet === undefined) {
789         prevChildrenSet = new Set(prevChildren);
790       }
791
792       if (nextChildrenSet === undefined) {
793         nextChildrenSet = new Set(nextChildren);
794       }
795
796       const nextHasPrevKey = nextChildrenSet.has(prevKey);
797       const prevHasNextKey = prevChildrenSet.has(nextKey);
798
799       if (!nextHasPrevKey) {
800         // Remove prev
801         siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
802         destroyNode(prevKey, dom);
803         prevIndex++;
804       } else if (!prevHasNextKey) {
805         // Create next
806         $createNode(nextKey, dom, siblingDOM);
807         nextIndex++;
808       } else {
809         // Move next
810         const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
811
812         if (childDOM === siblingDOM) {
813           siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
814         } else {
815           if (siblingDOM != null) {
816             dom.insertBefore(childDOM, siblingDOM);
817           } else {
818             dom.appendChild(childDOM);
819           }
820
821           $reconcileNode(nextKey, dom);
822         }
823
824         prevIndex++;
825         nextIndex++;
826       }
827     }
828
829     const node = activeNextNodeMap.get(nextKey);
830     if (node !== null && $isTextNode(node)) {
831       if (subTreeTextFormat === null) {
832         subTreeTextFormat = node.getFormat();
833       }
834       if (subTreeTextStyle === '') {
835         subTreeTextStyle = node.getStyle();
836       }
837     }
838   }
839
840   const appendNewChildren = prevIndex > prevEndIndex;
841   const removeOldChildren = nextIndex > nextEndIndex;
842
843   if (appendNewChildren && !removeOldChildren) {
844     const previousNode = nextChildren[nextEndIndex + 1];
845     const insertDOM =
846       previousNode === undefined
847         ? null
848         : activeEditor.getElementByKey(previousNode);
849     $createChildren(
850       nextChildren,
851       nextElement,
852       nextIndex,
853       nextEndIndex,
854       dom,
855       insertDOM,
856     );
857   } else if (removeOldChildren && !appendNewChildren) {
858     destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
859   }
860 }
861
862 export function $reconcileRoot(
863   prevEditorState: EditorState,
864   nextEditorState: EditorState,
865   editor: LexicalEditor,
866   dirtyType: 0 | 1 | 2,
867   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
868   dirtyLeaves: Set<NodeKey>,
869 ): MutatedNodes {
870   // We cache text content to make retrieval more efficient.
871   // The cache must be rebuilt during reconciliation to account for any changes.
872   subTreeTextContent = '';
873   editorTextContent = '';
874   subTreeDirectionedTextContent = '';
875   // Rather than pass around a load of arguments through the stack recursively
876   // we instead set them as bindings within the scope of the module.
877   treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
878   activeTextDirection = null;
879   activeEditor = editor;
880   activeEditorConfig = editor._config;
881   activeEditorNodes = editor._nodes;
882   activeMutationListeners = activeEditor._listeners.mutation;
883   activeDirtyElements = dirtyElements;
884   activeDirtyLeaves = dirtyLeaves;
885   activePrevNodeMap = prevEditorState._nodeMap;
886   activeNextNodeMap = nextEditorState._nodeMap;
887   activeEditorStateReadOnly = nextEditorState._readOnly;
888   activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
889   // We keep track of mutated nodes so we can trigger mutation
890   // listeners later in the update cycle.
891   const currentMutatedNodes = new Map();
892   mutatedNodes = currentMutatedNodes;
893   $reconcileNode('root', null);
894   // We don't want a bunch of void checks throughout the scope
895   // so instead we make it seem that these values are always set.
896   // We also want to make sure we clear them down, otherwise we
897   // can leak memory.
898   // @ts-ignore
899   activeEditor = undefined;
900   // @ts-ignore
901   activeEditorNodes = undefined;
902   // @ts-ignore
903   activeDirtyElements = undefined;
904   // @ts-ignore
905   activeDirtyLeaves = undefined;
906   // @ts-ignore
907   activePrevNodeMap = undefined;
908   // @ts-ignore
909   activeNextNodeMap = undefined;
910   // @ts-ignore
911   activeEditorConfig = undefined;
912   // @ts-ignore
913   activePrevKeyToDOMMap = undefined;
914   // @ts-ignore
915   mutatedNodes = undefined;
916
917   return currentMutatedNodes;
918 }
919
920 export function storeDOMWithKey(
921   key: NodeKey,
922   dom: HTMLElement,
923   editor: LexicalEditor,
924 ): void {
925   const keyToDOMMap = editor._keyToDOMMap;
926   // @ts-ignore We intentionally add this to the Node.
927   dom['__lexicalKey_' + editor._key] = key;
928   keyToDOMMap.set(key, dom);
929 }
930
931 function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
932   const element = activePrevKeyToDOMMap.get(key);
933
934   if (element === undefined) {
935     invariant(
936       false,
937       'Reconciliation: could not find DOM element for node key %s',
938       key,
939     );
940   }
941
942   return element;
943 }