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';
20 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
39 } from './LexicalConstants';
40 import {EditorState} from './LexicalEditorState';
42 $textContentRequiresDoubleLinebreakAtEnd,
44 getElementByKeyOrThrow,
46 } from './LexicalUtils';
48 type IntentionallyMarkedAsDirtyElement = boolean;
50 let subTreeTextContent = '';
51 let subTreeTextFormat: number | null = null;
52 let subTreeTextStyle: string = '';
53 let editorTextContent = '';
54 let activeEditorConfig: EditorConfig;
55 let activeEditor: LexicalEditor;
56 let activeEditorNodes: RegisteredNodes;
57 let treatAllNodesAsDirty = false;
58 let activeEditorStateReadOnly = false;
59 let activeMutationListeners: MutationListeners;
60 let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
61 let activeDirtyLeaves: Set<NodeKey>;
62 let activePrevNodeMap: NodeMap;
63 let activeNextNodeMap: NodeMap;
64 let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
65 let mutatedNodes: MutatedNodes;
67 function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
68 const node = activePrevNodeMap.get(key);
70 if (parentDOM !== null) {
71 const dom = getPrevElementByKeyOrThrow(key);
72 if (dom.parentNode === parentDOM) {
73 parentDOM.removeChild(dom);
77 // This logic is really important, otherwise we will leak DOM nodes
78 // when their corresponding LexicalNodes are removed from the editor state.
79 if (!activeNextNodeMap.has(key)) {
80 activeEditor._keyToDOMMap.delete(key);
83 if ($isElementNode(node)) {
84 const children = createChildrenArray(node, activePrevNodeMap);
85 destroyChildren(children, 0, children.length - 1, null);
88 if (node !== undefined) {
92 activeMutationListeners,
99 function destroyChildren(
100 children: Array<NodeKey>,
103 dom: null | HTMLElement,
105 let startIndex = _startIndex;
107 for (; startIndex <= endIndex; ++startIndex) {
108 const child = children[startIndex];
110 if (child !== undefined) {
111 destroyNode(child, dom);
116 function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
117 domStyle.setProperty('text-align', value);
120 const DEFAULT_INDENT_VALUE = '40px';
122 function setElementIndent(dom: HTMLElement, indent: number): void {
123 const indentClassName = activeEditorConfig.theme.indent;
125 if (typeof indentClassName === 'string') {
126 const elementHasClassName = dom.classList.contains(indentClassName);
128 if (indent > 0 && !elementHasClassName) {
129 dom.classList.add(indentClassName);
130 } else if (indent < 1 && elementHasClassName) {
131 dom.classList.remove(indentClassName);
135 const indentationBaseValue =
136 getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') ||
137 DEFAULT_INDENT_VALUE;
139 dom.style.setProperty(
140 'padding-inline-start',
141 indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`,
145 function setElementFormat(dom: HTMLElement, format: number): void {
146 const domStyle = dom.style;
149 setTextAlign(domStyle, '');
150 } else if (format === IS_ALIGN_LEFT) {
151 setTextAlign(domStyle, 'left');
152 } else if (format === IS_ALIGN_CENTER) {
153 setTextAlign(domStyle, 'center');
154 } else if (format === IS_ALIGN_RIGHT) {
155 setTextAlign(domStyle, 'right');
156 } else if (format === IS_ALIGN_JUSTIFY) {
157 setTextAlign(domStyle, 'justify');
158 } else if (format === IS_ALIGN_START) {
159 setTextAlign(domStyle, 'start');
160 } else if (format === IS_ALIGN_END) {
161 setTextAlign(domStyle, 'end');
165 function $createNode(
167 parentDOM: null | HTMLElement,
168 insertDOM: null | Node,
170 const node = activeNextNodeMap.get(key);
172 if (node === undefined) {
173 invariant(false, 'createNode: node does not exist in nodeMap');
175 const dom = node.createDOM(activeEditorConfig, activeEditor);
176 storeDOMWithKey(key, dom, activeEditor);
178 // This helps preserve the text, and stops spell check tools from
179 // merging or break the spans (which happens if they are missing
181 if ($isTextNode(node)) {
182 dom.setAttribute('data-lexical-text', 'true');
183 } else if ($isDecoratorNode(node)) {
184 dom.setAttribute('data-lexical-decorator', 'true');
187 if ($isElementNode(node)) {
188 const indent = node.__indent;
189 const childrenSize = node.__size;
192 setElementIndent(dom, indent);
194 if (childrenSize !== 0) {
195 const endIndex = childrenSize - 1;
196 const children = createChildrenArray(node, activeNextNodeMap);
197 $createChildren(children, node, 0, endIndex, dom, null);
199 const format = node.__format;
202 setElementFormat(dom, format);
204 if (!node.isInline()) {
205 reconcileElementTerminatingLineBreak(null, node, dom);
207 if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
208 subTreeTextContent += DOUBLE_LINE_BREAK;
209 editorTextContent += DOUBLE_LINE_BREAK;
212 const text = node.getTextContent();
214 if ($isDecoratorNode(node)) {
215 const decorator = node.decorate(activeEditor, activeEditorConfig);
217 if (decorator !== null) {
218 reconcileDecorator(key, decorator);
220 // Decorators are always non editable
221 dom.contentEditable = 'false';
223 subTreeTextContent += text;
224 editorTextContent += text;
227 if (parentDOM !== null) {
228 if (insertDOM != null) {
229 parentDOM.insertBefore(dom, insertDOM);
231 // @ts-expect-error: internal field
232 const possibleLineBreak = parentDOM.__lexicalLineBreak;
234 if (possibleLineBreak != null) {
235 parentDOM.insertBefore(dom, possibleLineBreak);
237 parentDOM.appendChild(dom);
243 // Freeze the node in DEV to prevent accidental mutations
250 activeMutationListeners,
257 function $createChildren(
258 children: Array<NodeKey>,
259 element: ElementNode,
262 dom: null | HTMLElement,
263 insertDOM: null | HTMLElement,
265 const previousSubTreeTextContent = subTreeTextContent;
266 subTreeTextContent = '';
267 let startIndex = _startIndex;
269 for (; startIndex <= endIndex; ++startIndex) {
270 $createNode(children[startIndex], dom, insertDOM);
271 const node = activeNextNodeMap.get(children[startIndex]);
272 if (node !== null && $isTextNode(node)) {
273 if (subTreeTextFormat === null) {
274 subTreeTextFormat = node.getFormat();
276 if (subTreeTextStyle === '') {
277 subTreeTextStyle = node.getStyle();
281 if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
282 subTreeTextContent += DOUBLE_LINE_BREAK;
284 // @ts-expect-error: internal field
285 dom.__lexicalTextContent = subTreeTextContent;
286 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
289 function isLastChildLineBreakOrDecorator(
293 const node = nodeMap.get(childKey);
294 return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
297 // If we end an element with a LineBreakNode, then we need to add an additional <br>
298 function reconcileElementTerminatingLineBreak(
299 prevElement: null | ElementNode,
300 nextElement: ElementNode,
303 const prevLineBreak =
304 prevElement !== null &&
305 (prevElement.__size === 0 ||
306 isLastChildLineBreakOrDecorator(
307 prevElement.__last as NodeKey,
310 const nextLineBreak =
311 nextElement.__size === 0 ||
312 isLastChildLineBreakOrDecorator(
313 nextElement.__last as NodeKey,
318 if (!nextLineBreak) {
319 // @ts-expect-error: internal field
320 const element = dom.__lexicalLineBreak;
322 if (element != null) {
324 dom.removeChild(element);
326 if (typeof error === 'object' && error != null) {
327 const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
330 throw new Error(msg);
337 // @ts-expect-error: internal field
338 dom.__lexicalLineBreak = null;
340 } else if (nextLineBreak) {
341 const element = document.createElement('br');
342 // @ts-expect-error: internal field
343 dom.__lexicalLineBreak = element;
344 dom.appendChild(element);
348 function reconcileParagraphFormat(element: ElementNode): void {
350 $isParagraphNode(element) &&
351 subTreeTextFormat != null &&
352 subTreeTextFormat !== element.__textFormat &&
353 !activeEditorStateReadOnly
355 element.setTextFormat(subTreeTextFormat);
356 element.setTextStyle(subTreeTextStyle);
360 function reconcileParagraphStyle(element: ElementNode): void {
362 $isParagraphNode(element) &&
363 subTreeTextStyle !== '' &&
364 subTreeTextStyle !== element.__textStyle &&
365 !activeEditorStateReadOnly
367 element.setTextStyle(subTreeTextStyle);
371 function $reconcileChildrenWithDirection(
372 prevElement: ElementNode,
373 nextElement: ElementNode,
376 subTreeTextFormat = null;
377 subTreeTextStyle = '';
378 $reconcileChildren(prevElement, nextElement, dom);
379 reconcileParagraphFormat(nextElement);
380 reconcileParagraphStyle(nextElement);
383 function createChildrenArray(
384 element: ElementNode,
388 let nodeKey = element.__first;
389 while (nodeKey !== null) {
390 const node = nodeMap.get(nodeKey);
391 if (node === undefined) {
392 invariant(false, 'createChildrenArray: node does not exist in nodeMap');
394 children.push(nodeKey);
395 nodeKey = node.__next;
400 function $reconcileChildren(
401 prevElement: ElementNode,
402 nextElement: ElementNode,
405 const previousSubTreeTextContent = subTreeTextContent;
406 const prevChildrenSize = prevElement.__size;
407 const nextChildrenSize = nextElement.__size;
408 subTreeTextContent = '';
410 if (prevChildrenSize === 1 && nextChildrenSize === 1) {
411 const prevFirstChildKey = prevElement.__first as NodeKey;
412 const nextFrstChildKey = nextElement.__first as NodeKey;
413 if (prevFirstChildKey === nextFrstChildKey) {
414 $reconcileNode(prevFirstChildKey, dom);
416 const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
417 const replacementDOM = $createNode(nextFrstChildKey, null, null);
419 dom.replaceChild(replacementDOM, lastDOM);
421 if (typeof error === 'object' && error != null) {
422 const msg = `${error.toString()} Parent: ${
424 }, new child: {tag: ${
425 replacementDOM.tagName
426 } key: ${nextFrstChildKey}}, old child: {tag: ${
428 }, key: ${prevFirstChildKey}}.`;
429 throw new Error(msg);
434 destroyNode(prevFirstChildKey, null);
436 const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
437 if ($isTextNode(nextChildNode)) {
438 if (subTreeTextFormat === null) {
439 subTreeTextFormat = nextChildNode.getFormat();
441 if (subTreeTextStyle === '') {
442 subTreeTextStyle = nextChildNode.getStyle();
446 const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
447 const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
449 if (prevChildrenSize === 0) {
450 if (nextChildrenSize !== 0) {
455 nextChildrenSize - 1,
460 } else if (nextChildrenSize === 0) {
461 if (prevChildrenSize !== 0) {
462 // @ts-expect-error: internal field
463 const lexicalLineBreak = dom.__lexicalLineBreak;
464 const canUseFastPath = lexicalLineBreak == null;
468 prevChildrenSize - 1,
469 canUseFastPath ? null : dom,
472 if (canUseFastPath) {
473 // Fast path for removing DOM nodes
474 dom.textContent = '';
478 $reconcileNodeChildren(
489 if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
490 subTreeTextContent += DOUBLE_LINE_BREAK;
493 // @ts-expect-error: internal field
494 dom.__lexicalTextContent = subTreeTextContent;
495 subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
498 function $reconcileNode(
500 parentDOM: HTMLElement | null,
502 const prevNode = activePrevNodeMap.get(key);
503 let nextNode = activeNextNodeMap.get(key);
505 if (prevNode === undefined || nextNode === undefined) {
508 'reconcileNode: prevNode or nextNode does not exist in nodeMap',
513 treatAllNodesAsDirty ||
514 activeDirtyLeaves.has(key) ||
515 activeDirtyElements.has(key);
516 const dom = getElementByKeyOrThrow(activeEditor, key);
518 // If the node key points to the same instance in both states
519 // and isn't dirty, we just update the text content cache
520 // and return the existing DOM Node.
521 if (prevNode === nextNode && !isDirty) {
522 if ($isElementNode(prevNode)) {
523 // @ts-expect-error: internal field
524 const previousSubTreeTextContent = dom.__lexicalTextContent;
526 if (previousSubTreeTextContent !== undefined) {
527 subTreeTextContent += previousSubTreeTextContent;
528 editorTextContent += previousSubTreeTextContent;
531 const text = prevNode.getTextContent();
533 editorTextContent += text;
534 subTreeTextContent += text;
539 // If the node key doesn't point to the same instance in both maps,
540 // it means it were cloned. If they're also dirty, we mark them as mutated.
541 if (prevNode !== nextNode && isDirty) {
545 activeMutationListeners,
551 // Update node. If it returns true, we need to unmount and re-create the node
552 if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
553 const replacementDOM = $createNode(key, null, null);
555 if (parentDOM === null) {
556 invariant(false, 'reconcileNode: parentDOM is null');
559 parentDOM.replaceChild(replacementDOM, dom);
560 destroyNode(key, null);
561 return replacementDOM;
564 if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
565 // Reconcile element children
566 const nextIndent = nextNode.__indent;
568 if (nextIndent !== prevNode.__indent) {
569 setElementIndent(dom, nextIndent);
572 const nextFormat = nextNode.__format;
574 if (nextFormat !== prevNode.__format) {
575 setElementFormat(dom, nextFormat);
578 $reconcileChildrenWithDirection(prevNode, nextNode, dom);
579 if (!$isRootNode(nextNode) && !nextNode.isInline()) {
580 reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
584 if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
585 subTreeTextContent += DOUBLE_LINE_BREAK;
586 editorTextContent += DOUBLE_LINE_BREAK;
589 const text = nextNode.getTextContent();
591 if ($isDecoratorNode(nextNode)) {
592 const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
594 if (decorator !== null) {
595 reconcileDecorator(key, decorator);
599 subTreeTextContent += text;
600 editorTextContent += text;
604 !activeEditorStateReadOnly &&
605 $isRootNode(nextNode) &&
606 nextNode.__cachedText !== editorTextContent
608 // Cache the latest text content.
609 const nextRootNode = nextNode.getWritable();
610 nextRootNode.__cachedText = editorTextContent;
611 nextNode = nextRootNode;
615 // Freeze the node in DEV to prevent accidental mutations
616 Object.freeze(nextNode);
622 function reconcileDecorator(key: NodeKey, decorator: unknown): void {
623 let pendingDecorators = activeEditor._pendingDecorators;
624 const currentDecorators = activeEditor._decorators;
626 if (pendingDecorators === null) {
627 if (currentDecorators[key] === decorator) {
631 pendingDecorators = cloneDecorators(activeEditor);
634 pendingDecorators[key] = decorator;
637 function getFirstChild(element: HTMLElement): Node | null {
638 return element.firstChild;
641 function getNextSibling(element: HTMLElement): Node | null {
642 let nextSibling = element.nextSibling;
644 nextSibling !== null &&
645 nextSibling === activeEditor._blockCursorElement
647 nextSibling = nextSibling.nextSibling;
652 function $reconcileNodeChildren(
653 nextElement: ElementNode,
654 prevChildren: Array<NodeKey>,
655 nextChildren: Array<NodeKey>,
656 prevChildrenLength: number,
657 nextChildrenLength: number,
660 const prevEndIndex = prevChildrenLength - 1;
661 const nextEndIndex = nextChildrenLength - 1;
662 let prevChildrenSet: Set<NodeKey> | undefined;
663 let nextChildrenSet: Set<NodeKey> | undefined;
664 let siblingDOM: null | Node = getFirstChild(dom);
668 while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
669 const prevKey = prevChildren[prevIndex];
670 const nextKey = nextChildren[nextIndex];
672 if (prevKey === nextKey) {
673 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
677 if (prevChildrenSet === undefined) {
678 prevChildrenSet = new Set(prevChildren);
681 if (nextChildrenSet === undefined) {
682 nextChildrenSet = new Set(nextChildren);
685 const nextHasPrevKey = nextChildrenSet.has(prevKey);
686 const prevHasNextKey = prevChildrenSet.has(nextKey);
688 if (!nextHasPrevKey) {
690 siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
691 destroyNode(prevKey, dom);
693 } else if (!prevHasNextKey) {
695 $createNode(nextKey, dom, siblingDOM);
699 const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
701 if (childDOM === siblingDOM) {
702 siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
704 if (siblingDOM != null) {
705 dom.insertBefore(childDOM, siblingDOM);
707 dom.appendChild(childDOM);
710 $reconcileNode(nextKey, dom);
718 const node = activeNextNodeMap.get(nextKey);
719 if (node !== null && $isTextNode(node)) {
720 if (subTreeTextFormat === null) {
721 subTreeTextFormat = node.getFormat();
723 if (subTreeTextStyle === '') {
724 subTreeTextStyle = node.getStyle();
729 const appendNewChildren = prevIndex > prevEndIndex;
730 const removeOldChildren = nextIndex > nextEndIndex;
732 if (appendNewChildren && !removeOldChildren) {
733 const previousNode = nextChildren[nextEndIndex + 1];
735 previousNode === undefined
737 : activeEditor.getElementByKey(previousNode);
746 } else if (removeOldChildren && !appendNewChildren) {
747 destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
751 export function $reconcileRoot(
752 prevEditorState: EditorState,
753 nextEditorState: EditorState,
754 editor: LexicalEditor,
755 dirtyType: 0 | 1 | 2,
756 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
757 dirtyLeaves: Set<NodeKey>,
759 // We cache text content to make retrieval more efficient.
760 // The cache must be rebuilt during reconciliation to account for any changes.
761 subTreeTextContent = '';
762 editorTextContent = '';
763 // Rather than pass around a load of arguments through the stack recursively
764 // we instead set them as bindings within the scope of the module.
765 treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
766 activeEditor = editor;
767 activeEditorConfig = editor._config;
768 activeEditorNodes = editor._nodes;
769 activeMutationListeners = activeEditor._listeners.mutation;
770 activeDirtyElements = dirtyElements;
771 activeDirtyLeaves = dirtyLeaves;
772 activePrevNodeMap = prevEditorState._nodeMap;
773 activeNextNodeMap = nextEditorState._nodeMap;
774 activeEditorStateReadOnly = nextEditorState._readOnly;
775 activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
776 // We keep track of mutated nodes so we can trigger mutation
777 // listeners later in the update cycle.
778 const currentMutatedNodes = new Map();
779 mutatedNodes = currentMutatedNodes;
780 $reconcileNode('root', null);
781 // We don't want a bunch of void checks throughout the scope
782 // so instead we make it seem that these values are always set.
783 // We also want to make sure we clear them down, otherwise we
786 activeEditor = undefined;
788 activeEditorNodes = undefined;
790 activeDirtyElements = undefined;
792 activeDirtyLeaves = undefined;
794 activePrevNodeMap = undefined;
796 activeNextNodeMap = undefined;
798 activeEditorConfig = undefined;
800 activePrevKeyToDOMMap = undefined;
802 mutatedNodes = undefined;
804 return currentMutatedNodes;
807 export function storeDOMWithKey(
810 editor: LexicalEditor,
812 const keyToDOMMap = editor._keyToDOMMap;
813 // @ts-ignore We intentionally add this to the Node.
814 dom['__lexicalKey_' + editor._key] = key;
815 keyToDOMMap.set(key, dom);
818 function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
819 const element = activePrevKeyToDOMMap.get(key);
821 if (element === undefined) {
824 'Reconciliation: could not find DOM element for node key %s',