]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalReconciler.ts
Lexical: Reorganised custom node code into lexical codebase
[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
21 import {
22   $isDecoratorNode,
23   $isElementNode,
24   $isLineBreakNode,
25   $isParagraphNode,
26   $isRootNode,
27   $isTextNode,
28 } from '.';
29 import {
30   DOUBLE_LINE_BREAK,
31   FULL_RECONCILE,
32
33
34
35
36
37
38 } from './LexicalConstants';
39 import {EditorState} from './LexicalEditorState';
40 import {
41   $textContentRequiresDoubleLinebreakAtEnd,
42   cloneDecorators,
43   getElementByKeyOrThrow,
44   setMutatedNode,
45 } from './LexicalUtils';
46
47 type IntentionallyMarkedAsDirtyElement = boolean;
48
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;
65
66 function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
67   const node = activePrevNodeMap.get(key);
68
69   if (parentDOM !== null) {
70     const dom = getPrevElementByKeyOrThrow(key);
71     if (dom.parentNode === parentDOM) {
72       parentDOM.removeChild(dom);
73     }
74   }
75
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);
80   }
81
82   if ($isElementNode(node)) {
83     const children = createChildrenArray(node, activePrevNodeMap);
84     destroyChildren(children, 0, children.length - 1, null);
85   }
86
87   if (node !== undefined) {
88     setMutatedNode(
89       mutatedNodes,
90       activeEditorNodes,
91       activeMutationListeners,
92       node,
93       'destroyed',
94     );
95   }
96 }
97
98 function destroyChildren(
99   children: Array<NodeKey>,
100   _startIndex: number,
101   endIndex: number,
102   dom: null | HTMLElement,
103 ): void {
104   let startIndex = _startIndex;
105
106   for (; startIndex <= endIndex; ++startIndex) {
107     const child = children[startIndex];
108
109     if (child !== undefined) {
110       destroyNode(child, dom);
111     }
112   }
113 }
114
115 function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
116   domStyle.setProperty('text-align', value);
117 }
118
119 function $createNode(
120   key: NodeKey,
121   parentDOM: null | HTMLElement,
122   insertDOM: null | Node,
123 ): HTMLElement {
124   const node = activeNextNodeMap.get(key);
125
126   if (node === undefined) {
127     invariant(false, 'createNode: node does not exist in nodeMap');
128   }
129   const dom = node.createDOM(activeEditorConfig, activeEditor);
130   storeDOMWithKey(key, dom, activeEditor);
131
132   // This helps preserve the text, and stops spell check tools from
133   // merging or break the spans (which happens if they are missing
134   // this attribute).
135   if ($isTextNode(node)) {
136     dom.setAttribute('data-lexical-text', 'true');
137   } else if ($isDecoratorNode(node)) {
138     dom.setAttribute('data-lexical-decorator', 'true');
139   }
140
141   if ($isElementNode(node)) {
142     const childrenSize = node.__size;
143
144     if (childrenSize !== 0) {
145       const endIndex = childrenSize - 1;
146       const children = createChildrenArray(node, activeNextNodeMap);
147       $createChildren(children, node, 0, endIndex, dom, null);
148     }
149
150     if (!node.isInline()) {
151       reconcileElementTerminatingLineBreak(null, node, dom);
152     }
153     if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
154       subTreeTextContent += DOUBLE_LINE_BREAK;
155       editorTextContent += DOUBLE_LINE_BREAK;
156     }
157   } else {
158     const text = node.getTextContent();
159
160     if ($isDecoratorNode(node)) {
161       const decorator = node.decorate(activeEditor, activeEditorConfig);
162
163       if (decorator !== null) {
164         reconcileDecorator(key, decorator);
165       }
166       // Decorators are always non editable
167       dom.contentEditable = 'false';
168     }
169     subTreeTextContent += text;
170     editorTextContent += text;
171   }
172
173   if (parentDOM !== null) {
174     if (insertDOM != null) {
175       parentDOM.insertBefore(dom, insertDOM);
176     } else {
177       // @ts-expect-error: internal field
178       const possibleLineBreak = parentDOM.__lexicalLineBreak;
179
180       if (possibleLineBreak != null) {
181         parentDOM.insertBefore(dom, possibleLineBreak);
182       } else {
183         parentDOM.appendChild(dom);
184       }
185     }
186   }
187
188   if (__DEV__) {
189     // Freeze the node in DEV to prevent accidental mutations
190     Object.freeze(node);
191   }
192
193   setMutatedNode(
194     mutatedNodes,
195     activeEditorNodes,
196     activeMutationListeners,
197     node,
198     'created',
199   );
200   return dom;
201 }
202
203 function $createChildren(
204   children: Array<NodeKey>,
205   element: ElementNode,
206   _startIndex: number,
207   endIndex: number,
208   dom: null | HTMLElement,
209   insertDOM: null | HTMLElement,
210 ): void {
211   const previousSubTreeTextContent = subTreeTextContent;
212   subTreeTextContent = '';
213   let startIndex = _startIndex;
214
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();
221       }
222       if (subTreeTextStyle === '') {
223         subTreeTextStyle = node.getStyle();
224       }
225     }
226   }
227   if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
228     subTreeTextContent += DOUBLE_LINE_BREAK;
229   }
230   // @ts-expect-error: internal field
231   dom.__lexicalTextContent = subTreeTextContent;
232   subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
233 }
234
235 function isLastChildLineBreakOrDecorator(
236   childKey: NodeKey,
237   nodeMap: NodeMap,
238 ): boolean {
239   const node = nodeMap.get(childKey);
240   return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
241 }
242
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,
247   dom: HTMLElement,
248 ): void {
249   const prevLineBreak =
250     prevElement !== null &&
251     (prevElement.__size === 0 ||
252       isLastChildLineBreakOrDecorator(
253         prevElement.__last as NodeKey,
254         activePrevNodeMap,
255       ));
256   const nextLineBreak =
257     nextElement.__size === 0 ||
258     isLastChildLineBreakOrDecorator(
259       nextElement.__last as NodeKey,
260       activeNextNodeMap,
261     );
262
263   if (prevLineBreak) {
264     if (!nextLineBreak) {
265       // @ts-expect-error: internal field
266       const element = dom.__lexicalLineBreak;
267
268       if (element != null) {
269         try {
270           dom.removeChild(element);
271         } catch (error) {
272           if (typeof error === 'object' && error != null) {
273             const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
274               element.tagName
275             }.`;
276             throw new Error(msg);
277           } else {
278             throw error;
279           }
280         }
281       }
282
283       // @ts-expect-error: internal field
284       dom.__lexicalLineBreak = null;
285     }
286   } else if (nextLineBreak) {
287     const element = document.createElement('br');
288     // @ts-expect-error: internal field
289     dom.__lexicalLineBreak = element;
290     dom.appendChild(element);
291   }
292 }
293
294 function reconcileParagraphFormat(element: ElementNode): void {
295   if (
296     $isParagraphNode(element) &&
297     subTreeTextFormat != null &&
298     !activeEditorStateReadOnly
299   ) {
300     element.setTextStyle(subTreeTextStyle);
301   }
302 }
303
304 function reconcileParagraphStyle(element: ElementNode): void {
305   if (
306     $isParagraphNode(element) &&
307     subTreeTextStyle !== '' &&
308     subTreeTextStyle !== element.__textStyle &&
309     !activeEditorStateReadOnly
310   ) {
311     element.setTextStyle(subTreeTextStyle);
312   }
313 }
314
315 function $reconcileChildrenWithDirection(
316   prevElement: ElementNode,
317   nextElement: ElementNode,
318   dom: HTMLElement,
319 ): void {
320   subTreeTextFormat = null;
321   subTreeTextStyle = '';
322   $reconcileChildren(prevElement, nextElement, dom);
323   reconcileParagraphFormat(nextElement);
324   reconcileParagraphStyle(nextElement);
325 }
326
327 function createChildrenArray(
328   element: ElementNode,
329   nodeMap: NodeMap,
330 ): Array<NodeKey> {
331   const children = [];
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');
337     }
338     children.push(nodeKey);
339     nodeKey = node.__next;
340   }
341   return children;
342 }
343
344 function $reconcileChildren(
345   prevElement: ElementNode,
346   nextElement: ElementNode,
347   dom: HTMLElement,
348 ): void {
349   const previousSubTreeTextContent = subTreeTextContent;
350   const prevChildrenSize = prevElement.__size;
351   const nextChildrenSize = nextElement.__size;
352   subTreeTextContent = '';
353
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);
359     } else {
360       const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
361       const replacementDOM = $createNode(nextFrstChildKey, null, null);
362       try {
363         dom.replaceChild(replacementDOM, lastDOM);
364       } catch (error) {
365         if (typeof error === 'object' && error != null) {
366           const msg = `${error.toString()} Parent: ${
367             dom.tagName
368           }, new child: {tag: ${
369             replacementDOM.tagName
370           } key: ${nextFrstChildKey}}, old child: {tag: ${
371             lastDOM.tagName
372           }, key: ${prevFirstChildKey}}.`;
373           throw new Error(msg);
374         } else {
375           throw error;
376         }
377       }
378       destroyNode(prevFirstChildKey, null);
379     }
380     const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
381     if ($isTextNode(nextChildNode)) {
382       if (subTreeTextFormat === null) {
383         subTreeTextFormat = nextChildNode.getFormat();
384       }
385       if (subTreeTextStyle === '') {
386         subTreeTextStyle = nextChildNode.getStyle();
387       }
388     }
389   } else {
390     const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
391     const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
392
393     if (prevChildrenSize === 0) {
394       if (nextChildrenSize !== 0) {
395         $createChildren(
396           nextChildren,
397           nextElement,
398           0,
399           nextChildrenSize - 1,
400           dom,
401           null,
402         );
403       }
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;
409         destroyChildren(
410           prevChildren,
411           0,
412           prevChildrenSize - 1,
413           canUseFastPath ? null : dom,
414         );
415
416         if (canUseFastPath) {
417           // Fast path for removing DOM nodes
418           dom.textContent = '';
419         }
420       }
421     } else {
422       $reconcileNodeChildren(
423         nextElement,
424         prevChildren,
425         nextChildren,
426         prevChildrenSize,
427         nextChildrenSize,
428         dom,
429       );
430     }
431   }
432
433   if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
434     subTreeTextContent += DOUBLE_LINE_BREAK;
435   }
436
437   // @ts-expect-error: internal field
438   dom.__lexicalTextContent = subTreeTextContent;
439   subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
440 }
441
442 function $reconcileNode(
443   key: NodeKey,
444   parentDOM: HTMLElement | null,
445 ): HTMLElement {
446   const prevNode = activePrevNodeMap.get(key);
447   let nextNode = activeNextNodeMap.get(key);
448
449   if (prevNode === undefined || nextNode === undefined) {
450     invariant(
451       false,
452       'reconcileNode: prevNode or nextNode does not exist in nodeMap',
453     );
454   }
455
456   const isDirty =
457     treatAllNodesAsDirty ||
458     activeDirtyLeaves.has(key) ||
459     activeDirtyElements.has(key);
460   const dom = getElementByKeyOrThrow(activeEditor, key);
461
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;
469
470       if (previousSubTreeTextContent !== undefined) {
471         subTreeTextContent += previousSubTreeTextContent;
472         editorTextContent += previousSubTreeTextContent;
473       }
474     } else {
475       const text = prevNode.getTextContent();
476
477       editorTextContent += text;
478       subTreeTextContent += text;
479     }
480
481     return dom;
482   }
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) {
486     setMutatedNode(
487       mutatedNodes,
488       activeEditorNodes,
489       activeMutationListeners,
490       nextNode,
491       'updated',
492     );
493   }
494
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);
498
499     if (parentDOM === null) {
500       invariant(false, 'reconcileNode: parentDOM is null');
501     }
502
503     parentDOM.replaceChild(replacementDOM, dom);
504     destroyNode(key, null);
505     return replacementDOM;
506   }
507
508   if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
509     // Reconcile element children
510     if (isDirty) {
511       $reconcileChildrenWithDirection(prevNode, nextNode, dom);
512       if (!$isRootNode(nextNode) && !nextNode.isInline()) {
513         reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
514       }
515     }
516
517     if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
518       subTreeTextContent += DOUBLE_LINE_BREAK;
519       editorTextContent += DOUBLE_LINE_BREAK;
520     }
521   } else {
522     const text = nextNode.getTextContent();
523
524     if ($isDecoratorNode(nextNode)) {
525       const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
526
527       if (decorator !== null) {
528         reconcileDecorator(key, decorator);
529       }
530     }
531
532     subTreeTextContent += text;
533     editorTextContent += text;
534   }
535
536   if (
537     !activeEditorStateReadOnly &&
538     $isRootNode(nextNode) &&
539     nextNode.__cachedText !== editorTextContent
540   ) {
541     // Cache the latest text content.
542     const nextRootNode = nextNode.getWritable();
543     nextRootNode.__cachedText = editorTextContent;
544     nextNode = nextRootNode;
545   }
546
547   if (__DEV__) {
548     // Freeze the node in DEV to prevent accidental mutations
549     Object.freeze(nextNode);
550   }
551
552   return dom;
553 }
554
555 function reconcileDecorator(key: NodeKey, decorator: unknown): void {
556   let pendingDecorators = activeEditor._pendingDecorators;
557   const currentDecorators = activeEditor._decorators;
558
559   if (pendingDecorators === null) {
560     if (currentDecorators[key] === decorator) {
561       return;
562     }
563
564     pendingDecorators = cloneDecorators(activeEditor);
565   }
566
567   pendingDecorators[key] = decorator;
568 }
569
570 function getFirstChild(element: HTMLElement): Node | null {
571   return element.firstChild;
572 }
573
574 function getNextSibling(element: HTMLElement): Node | null {
575   let nextSibling = element.nextSibling;
576   if (
577     nextSibling !== null &&
578     nextSibling === activeEditor._blockCursorElement
579   ) {
580     nextSibling = nextSibling.nextSibling;
581   }
582   return nextSibling;
583 }
584
585 function $reconcileNodeChildren(
586   nextElement: ElementNode,
587   prevChildren: Array<NodeKey>,
588   nextChildren: Array<NodeKey>,
589   prevChildrenLength: number,
590   nextChildrenLength: number,
591   dom: HTMLElement,
592 ): void {
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);
598   let prevIndex = 0;
599   let nextIndex = 0;
600
601   while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
602     const prevKey = prevChildren[prevIndex];
603     const nextKey = nextChildren[nextIndex];
604
605     if (prevKey === nextKey) {
606       siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
607       prevIndex++;
608       nextIndex++;
609     } else {
610       if (prevChildrenSet === undefined) {
611         prevChildrenSet = new Set(prevChildren);
612       }
613
614       if (nextChildrenSet === undefined) {
615         nextChildrenSet = new Set(nextChildren);
616       }
617
618       const nextHasPrevKey = nextChildrenSet.has(prevKey);
619       const prevHasNextKey = prevChildrenSet.has(nextKey);
620
621       if (!nextHasPrevKey) {
622         // Remove prev
623         siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
624         destroyNode(prevKey, dom);
625         prevIndex++;
626       } else if (!prevHasNextKey) {
627         // Create next
628         $createNode(nextKey, dom, siblingDOM);
629         nextIndex++;
630       } else {
631         // Move next
632         const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
633
634         if (childDOM === siblingDOM) {
635           siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
636         } else {
637           if (siblingDOM != null) {
638             dom.insertBefore(childDOM, siblingDOM);
639           } else {
640             dom.appendChild(childDOM);
641           }
642
643           $reconcileNode(nextKey, dom);
644         }
645
646         prevIndex++;
647         nextIndex++;
648       }
649     }
650
651     const node = activeNextNodeMap.get(nextKey);
652     if (node !== null && $isTextNode(node)) {
653       if (subTreeTextFormat === null) {
654         subTreeTextFormat = node.getFormat();
655       }
656       if (subTreeTextStyle === '') {
657         subTreeTextStyle = node.getStyle();
658       }
659     }
660   }
661
662   const appendNewChildren = prevIndex > prevEndIndex;
663   const removeOldChildren = nextIndex > nextEndIndex;
664
665   if (appendNewChildren && !removeOldChildren) {
666     const previousNode = nextChildren[nextEndIndex + 1];
667     const insertDOM =
668       previousNode === undefined
669         ? null
670         : activeEditor.getElementByKey(previousNode);
671     $createChildren(
672       nextChildren,
673       nextElement,
674       nextIndex,
675       nextEndIndex,
676       dom,
677       insertDOM,
678     );
679   } else if (removeOldChildren && !appendNewChildren) {
680     destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
681   }
682 }
683
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>,
691 ): MutatedNodes {
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
717   // can leak memory.
718   // @ts-ignore
719   activeEditor = undefined;
720   // @ts-ignore
721   activeEditorNodes = undefined;
722   // @ts-ignore
723   activeDirtyElements = undefined;
724   // @ts-ignore
725   activeDirtyLeaves = undefined;
726   // @ts-ignore
727   activePrevNodeMap = undefined;
728   // @ts-ignore
729   activeNextNodeMap = undefined;
730   // @ts-ignore
731   activeEditorConfig = undefined;
732   // @ts-ignore
733   activePrevKeyToDOMMap = undefined;
734   // @ts-ignore
735   mutatedNodes = undefined;
736
737   return currentMutatedNodes;
738 }
739
740 export function storeDOMWithKey(
741   key: NodeKey,
742   dom: HTMLElement,
743   editor: LexicalEditor,
744 ): void {
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);
749 }
750
751 function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
752   const element = activePrevKeyToDOMMap.get(key);
753
754   if (element === undefined) {
755     invariant(
756       false,
757       'Reconciliation: could not find DOM element for node key %s',
758       key,
759     );
760   }
761
762   return element;
763 }