1036 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			1036 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /**
 | |
|  * Copyright (c) Meta Platforms, Inc. and affiliates.
 | |
|  *
 | |
|  * This source code is licensed under the MIT license found in the
 | |
|  * LICENSE file in the root directory of this source tree.
 | |
|  *
 | |
|  */
 | |
| 
 | |
| import type {SerializedEditorState} from './LexicalEditorState';
 | |
| import type {LexicalNode, SerializedLexicalNode} from './LexicalNode';
 | |
| 
 | |
| import invariant from 'lexical/shared/invariant';
 | |
| 
 | |
| import {$isElementNode, $isTextNode, SELECTION_CHANGE_COMMAND} from '.';
 | |
| import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants';
 | |
| import {
 | |
|   CommandPayloadType,
 | |
|   EditorUpdateOptions,
 | |
|   LexicalCommand,
 | |
|   LexicalEditor,
 | |
|   Listener,
 | |
|   MutatedNodes,
 | |
|   RegisteredNodes,
 | |
|   resetEditor,
 | |
|   Transform,
 | |
| } from './LexicalEditor';
 | |
| import {
 | |
|   cloneEditorState,
 | |
|   createEmptyEditorState,
 | |
|   EditorState,
 | |
|   editorStateHasDirtySelection,
 | |
| } from './LexicalEditorState';
 | |
| import {
 | |
|   $garbageCollectDetachedDecorators,
 | |
|   $garbageCollectDetachedNodes,
 | |
| } from './LexicalGC';
 | |
| import {initMutationObserver} from './LexicalMutations';
 | |
| import {$normalizeTextNode} from './LexicalNormalization';
 | |
| import {$reconcileRoot} from './LexicalReconciler';
 | |
| import {
 | |
|   $internalCreateSelection,
 | |
|   $isNodeSelection,
 | |
|   $isRangeSelection,
 | |
|   applySelectionTransforms,
 | |
|   updateDOMSelection,
 | |
| } from './LexicalSelection';
 | |
| import {
 | |
|   $getCompositionKey,
 | |
|   getDOMSelection,
 | |
|   getEditorPropertyFromDOMNode,
 | |
|   getEditorStateTextContent,
 | |
|   getEditorsToPropagate,
 | |
|   getRegisteredNodeOrThrow,
 | |
|   isLexicalEditor,
 | |
|   removeDOMBlockCursorElement,
 | |
|   scheduleMicroTask,
 | |
|   updateDOMBlockCursorElement,
 | |
| } from './LexicalUtils';
 | |
| 
 | |
| let activeEditorState: null | EditorState = null;
 | |
| let activeEditor: null | LexicalEditor = null;
 | |
| let isReadOnlyMode = false;
 | |
| let isAttemptingToRecoverFromReconcilerError = false;
 | |
| let infiniteTransformCount = 0;
 | |
| 
 | |
| const observerOptions = {
 | |
|   characterData: true,
 | |
|   childList: true,
 | |
|   subtree: true,
 | |
| };
 | |
| 
 | |
| export function isCurrentlyReadOnlyMode(): boolean {
 | |
|   return (
 | |
|     isReadOnlyMode ||
 | |
|     (activeEditorState !== null && activeEditorState._readOnly)
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function errorOnReadOnly(): void {
 | |
|   if (isReadOnlyMode) {
 | |
|     invariant(false, 'Cannot use method in read-only mode.');
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function errorOnInfiniteTransforms(): void {
 | |
|   if (infiniteTransformCount > 99) {
 | |
|     invariant(
 | |
|       false,
 | |
|       'One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.',
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function getActiveEditorState(): EditorState {
 | |
|   if (activeEditorState === null) {
 | |
|     invariant(
 | |
|       false,
 | |
|       'Unable to find an active editor state. ' +
 | |
|         'State helpers or node methods can only be used ' +
 | |
|         'synchronously during the callback of ' +
 | |
|         'editor.update(), editor.read(), or editorState.read().%s',
 | |
|       collectBuildInformation(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return activeEditorState;
 | |
| }
 | |
| 
 | |
| export function getActiveEditor(): LexicalEditor {
 | |
|   if (activeEditor === null) {
 | |
|     invariant(
 | |
|       false,
 | |
|       'Unable to find an active editor. ' +
 | |
|         'This method can only be used ' +
 | |
|         'synchronously during the callback of ' +
 | |
|         'editor.update() or editor.read().%s',
 | |
|       collectBuildInformation(),
 | |
|     );
 | |
|   }
 | |
|   return activeEditor;
 | |
| }
 | |
| 
 | |
| function collectBuildInformation(): string {
 | |
|   let compatibleEditors = 0;
 | |
|   const incompatibleEditors = new Set<string>();
 | |
|   const thisVersion = LexicalEditor.version;
 | |
|   if (typeof window !== 'undefined') {
 | |
|     for (const node of document.querySelectorAll('[contenteditable]')) {
 | |
|       const editor = getEditorPropertyFromDOMNode(node);
 | |
|       if (isLexicalEditor(editor)) {
 | |
|         compatibleEditors++;
 | |
|       } else if (editor) {
 | |
|         let version = String(
 | |
|           (
 | |
|             editor.constructor as typeof editor['constructor'] &
 | |
|               Record<string, unknown>
 | |
|           ).version || '<0.17.1',
 | |
|         );
 | |
|         if (version === thisVersion) {
 | |
|           version +=
 | |
|             ' (separately built, likely a bundler configuration issue)';
 | |
|         }
 | |
|         incompatibleEditors.add(version);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   let output = ` Detected on the page: ${compatibleEditors} compatible editor(s) with version ${thisVersion}`;
 | |
|   if (incompatibleEditors.size) {
 | |
|     output += ` and incompatible editors with versions ${Array.from(
 | |
|       incompatibleEditors,
 | |
|     ).join(', ')}`;
 | |
|   }
 | |
|   return output;
 | |
| }
 | |
| 
 | |
| export function internalGetActiveEditor(): LexicalEditor | null {
 | |
|   return activeEditor;
 | |
| }
 | |
| 
 | |
| export function internalGetActiveEditorState(): EditorState | null {
 | |
|   return activeEditorState;
 | |
| }
 | |
| 
 | |
| export function $applyTransforms(
 | |
|   editor: LexicalEditor,
 | |
|   node: LexicalNode,
 | |
|   transformsCache: Map<string, Array<Transform<LexicalNode>>>,
 | |
| ) {
 | |
|   const type = node.__type;
 | |
|   const registeredNode = getRegisteredNodeOrThrow(editor, type);
 | |
|   let transformsArr = transformsCache.get(type);
 | |
| 
 | |
|   if (transformsArr === undefined) {
 | |
|     transformsArr = Array.from(registeredNode.transforms);
 | |
|     transformsCache.set(type, transformsArr);
 | |
|   }
 | |
| 
 | |
|   const transformsArrLength = transformsArr.length;
 | |
| 
 | |
|   for (let i = 0; i < transformsArrLength; i++) {
 | |
|     transformsArr[i](node);
 | |
| 
 | |
|     if (!node.isAttached()) {
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $isNodeValidForTransform(
 | |
|   node: LexicalNode,
 | |
|   compositionKey: null | string,
 | |
| ): boolean {
 | |
|   return (
 | |
|     node !== undefined &&
 | |
|     // We don't want to transform nodes being composed
 | |
|     node.__key !== compositionKey &&
 | |
|     node.isAttached()
 | |
|   );
 | |
| }
 | |
| 
 | |
| function $normalizeAllDirtyTextNodes(
 | |
|   editorState: EditorState,
 | |
|   editor: LexicalEditor,
 | |
| ): void {
 | |
|   const dirtyLeaves = editor._dirtyLeaves;
 | |
|   const nodeMap = editorState._nodeMap;
 | |
| 
 | |
|   for (const nodeKey of dirtyLeaves) {
 | |
|     const node = nodeMap.get(nodeKey);
 | |
| 
 | |
|     if (
 | |
|       $isTextNode(node) &&
 | |
|       node.isAttached() &&
 | |
|       node.isSimpleText() &&
 | |
|       !node.isUnmergeable()
 | |
|     ) {
 | |
|       $normalizeTextNode(node);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Transform heuristic:
 | |
|  * 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1.
 | |
|  * The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too.
 | |
|  * 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1.
 | |
|  * If element transforms only generate additional dirty elements we only repeat step 2.
 | |
|  *
 | |
|  * Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and
 | |
|  * editor._subtrees which we reset in every loop.
 | |
|  */
 | |
| function $applyAllTransforms(
 | |
|   editorState: EditorState,
 | |
|   editor: LexicalEditor,
 | |
| ): void {
 | |
|   const dirtyLeaves = editor._dirtyLeaves;
 | |
|   const dirtyElements = editor._dirtyElements;
 | |
|   const nodeMap = editorState._nodeMap;
 | |
|   const compositionKey = $getCompositionKey();
 | |
|   const transformsCache = new Map();
 | |
| 
 | |
|   let untransformedDirtyLeaves = dirtyLeaves;
 | |
|   let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
 | |
|   let untransformedDirtyElements = dirtyElements;
 | |
|   let untransformedDirtyElementsLength = untransformedDirtyElements.size;
 | |
| 
 | |
|   while (
 | |
|     untransformedDirtyLeavesLength > 0 ||
 | |
|     untransformedDirtyElementsLength > 0
 | |
|   ) {
 | |
|     if (untransformedDirtyLeavesLength > 0) {
 | |
|       // We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms
 | |
|       editor._dirtyLeaves = new Set();
 | |
| 
 | |
|       for (const nodeKey of untransformedDirtyLeaves) {
 | |
|         const node = nodeMap.get(nodeKey);
 | |
| 
 | |
|         if (
 | |
|           $isTextNode(node) &&
 | |
|           node.isAttached() &&
 | |
|           node.isSimpleText() &&
 | |
|           !node.isUnmergeable()
 | |
|         ) {
 | |
|           $normalizeTextNode(node);
 | |
|         }
 | |
| 
 | |
|         if (
 | |
|           node !== undefined &&
 | |
|           $isNodeValidForTransform(node, compositionKey)
 | |
|         ) {
 | |
|           $applyTransforms(editor, node, transformsCache);
 | |
|         }
 | |
| 
 | |
|         dirtyLeaves.add(nodeKey);
 | |
|       }
 | |
| 
 | |
|       untransformedDirtyLeaves = editor._dirtyLeaves;
 | |
|       untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
 | |
| 
 | |
|       // We want to prioritize node transforms over element transforms
 | |
|       if (untransformedDirtyLeavesLength > 0) {
 | |
|         infiniteTransformCount++;
 | |
|         continue;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // All dirty leaves have been processed. Let's do elements!
 | |
|     // We have previously processed dirty leaves, so let's restart the editor leaves Set to track
 | |
|     // new ones caused by element transforms
 | |
|     editor._dirtyLeaves = new Set();
 | |
|     editor._dirtyElements = new Map();
 | |
| 
 | |
|     for (const currentUntransformedDirtyElement of untransformedDirtyElements) {
 | |
|       const nodeKey = currentUntransformedDirtyElement[0];
 | |
|       const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1];
 | |
|       if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) {
 | |
|         continue;
 | |
|       }
 | |
| 
 | |
|       const node = nodeMap.get(nodeKey);
 | |
| 
 | |
|       if (
 | |
|         node !== undefined &&
 | |
|         $isNodeValidForTransform(node, compositionKey)
 | |
|       ) {
 | |
|         $applyTransforms(editor, node, transformsCache);
 | |
|       }
 | |
| 
 | |
|       dirtyElements.set(nodeKey, intentionallyMarkedAsDirty);
 | |
|     }
 | |
| 
 | |
|     untransformedDirtyLeaves = editor._dirtyLeaves;
 | |
|     untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
 | |
|     untransformedDirtyElements = editor._dirtyElements;
 | |
|     untransformedDirtyElementsLength = untransformedDirtyElements.size;
 | |
|     infiniteTransformCount++;
 | |
|   }
 | |
| 
 | |
|   editor._dirtyLeaves = dirtyLeaves;
 | |
|   editor._dirtyElements = dirtyElements;
 | |
| }
 | |
| 
 | |
| type InternalSerializedNode = {
 | |
|   children?: Array<InternalSerializedNode>;
 | |
|   type: string;
 | |
|   version: number;
 | |
| };
 | |
| 
 | |
| export function $parseSerializedNode(
 | |
|   serializedNode: SerializedLexicalNode,
 | |
| ): LexicalNode {
 | |
|   const internalSerializedNode: InternalSerializedNode = serializedNode;
 | |
|   return $parseSerializedNodeImpl(
 | |
|     internalSerializedNode,
 | |
|     getActiveEditor()._nodes,
 | |
|   );
 | |
| }
 | |
| 
 | |
| function $parseSerializedNodeImpl<
 | |
|   SerializedNode extends InternalSerializedNode,
 | |
| >(
 | |
|   serializedNode: SerializedNode,
 | |
|   registeredNodes: RegisteredNodes,
 | |
| ): LexicalNode {
 | |
|   const type = serializedNode.type;
 | |
|   const registeredNode = registeredNodes.get(type);
 | |
| 
 | |
|   if (registeredNode === undefined) {
 | |
|     invariant(false, 'parseEditorState: type "%s" + not found', type);
 | |
|   }
 | |
| 
 | |
|   const nodeClass = registeredNode.klass;
 | |
| 
 | |
|   if (serializedNode.type !== nodeClass.getType()) {
 | |
|     invariant(
 | |
|       false,
 | |
|       'LexicalNode: Node %s does not implement .importJSON().',
 | |
|       nodeClass.name,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   const node = nodeClass.importJSON(serializedNode);
 | |
|   const children = serializedNode.children;
 | |
| 
 | |
|   if ($isElementNode(node) && Array.isArray(children)) {
 | |
|     for (let i = 0; i < children.length; i++) {
 | |
|       const serializedJSONChildNode = children[i];
 | |
|       const childNode = $parseSerializedNodeImpl(
 | |
|         serializedJSONChildNode,
 | |
|         registeredNodes,
 | |
|       );
 | |
|       node.append(childNode);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return node;
 | |
| }
 | |
| 
 | |
| export function parseEditorState(
 | |
|   serializedEditorState: SerializedEditorState,
 | |
|   editor: LexicalEditor,
 | |
|   updateFn: void | (() => void),
 | |
| ): EditorState {
 | |
|   const editorState = createEmptyEditorState();
 | |
|   const previousActiveEditorState = activeEditorState;
 | |
|   const previousReadOnlyMode = isReadOnlyMode;
 | |
|   const previousActiveEditor = activeEditor;
 | |
|   const previousDirtyElements = editor._dirtyElements;
 | |
|   const previousDirtyLeaves = editor._dirtyLeaves;
 | |
|   const previousCloneNotNeeded = editor._cloneNotNeeded;
 | |
|   const previousDirtyType = editor._dirtyType;
 | |
|   editor._dirtyElements = new Map();
 | |
|   editor._dirtyLeaves = new Set();
 | |
|   editor._cloneNotNeeded = new Set();
 | |
|   editor._dirtyType = 0;
 | |
|   activeEditorState = editorState;
 | |
|   isReadOnlyMode = false;
 | |
|   activeEditor = editor;
 | |
| 
 | |
|   try {
 | |
|     const registeredNodes = editor._nodes;
 | |
|     const serializedNode = serializedEditorState.root;
 | |
|     $parseSerializedNodeImpl(serializedNode, registeredNodes);
 | |
|     if (updateFn) {
 | |
|       updateFn();
 | |
|     }
 | |
| 
 | |
|     // Make the editorState immutable
 | |
|     editorState._readOnly = true;
 | |
| 
 | |
|     if (__DEV__) {
 | |
|       handleDEVOnlyPendingUpdateGuarantees(editorState);
 | |
|     }
 | |
|   } catch (error) {
 | |
|     if (error instanceof Error) {
 | |
|       editor._onError(error);
 | |
|     }
 | |
|   } finally {
 | |
|     editor._dirtyElements = previousDirtyElements;
 | |
|     editor._dirtyLeaves = previousDirtyLeaves;
 | |
|     editor._cloneNotNeeded = previousCloneNotNeeded;
 | |
|     editor._dirtyType = previousDirtyType;
 | |
|     activeEditorState = previousActiveEditorState;
 | |
|     isReadOnlyMode = previousReadOnlyMode;
 | |
|     activeEditor = previousActiveEditor;
 | |
|   }
 | |
| 
 | |
|   return editorState;
 | |
| }
 | |
| 
 | |
| // This technically isn't an update but given we need
 | |
| // exposure to the module's active bindings, we have this
 | |
| // function here
 | |
| 
 | |
| export function readEditorState<V>(
 | |
|   editor: LexicalEditor | null,
 | |
|   editorState: EditorState,
 | |
|   callbackFn: () => V,
 | |
| ): V {
 | |
|   const previousActiveEditorState = activeEditorState;
 | |
|   const previousReadOnlyMode = isReadOnlyMode;
 | |
|   const previousActiveEditor = activeEditor;
 | |
| 
 | |
|   activeEditorState = editorState;
 | |
|   isReadOnlyMode = true;
 | |
|   activeEditor = editor;
 | |
| 
 | |
|   try {
 | |
|     return callbackFn();
 | |
|   } finally {
 | |
|     activeEditorState = previousActiveEditorState;
 | |
|     isReadOnlyMode = previousReadOnlyMode;
 | |
|     activeEditor = previousActiveEditor;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function handleDEVOnlyPendingUpdateGuarantees(
 | |
|   pendingEditorState: EditorState,
 | |
| ): void {
 | |
|   // Given we can't Object.freeze the nodeMap as it's a Map,
 | |
|   // we instead replace its set, clear and delete methods.
 | |
|   const nodeMap = pendingEditorState._nodeMap;
 | |
| 
 | |
|   nodeMap.set = () => {
 | |
|     throw new Error('Cannot call set() on a frozen Lexical node map');
 | |
|   };
 | |
| 
 | |
|   nodeMap.clear = () => {
 | |
|     throw new Error('Cannot call clear() on a frozen Lexical node map');
 | |
|   };
 | |
| 
 | |
|   nodeMap.delete = () => {
 | |
|     throw new Error('Cannot call delete() on a frozen Lexical node map');
 | |
|   };
 | |
| }
 | |
| 
 | |
| export function $commitPendingUpdates(
 | |
|   editor: LexicalEditor,
 | |
|   recoveryEditorState?: EditorState,
 | |
| ): void {
 | |
|   const pendingEditorState = editor._pendingEditorState;
 | |
|   const rootElement = editor._rootElement;
 | |
|   const shouldSkipDOM = editor._headless || rootElement === null;
 | |
| 
 | |
|   if (pendingEditorState === null) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   // ======
 | |
|   // Reconciliation has started.
 | |
|   // ======
 | |
| 
 | |
|   const currentEditorState = editor._editorState;
 | |
|   const currentSelection = currentEditorState._selection;
 | |
|   const pendingSelection = pendingEditorState._selection;
 | |
|   const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES;
 | |
|   const previousActiveEditorState = activeEditorState;
 | |
|   const previousReadOnlyMode = isReadOnlyMode;
 | |
|   const previousActiveEditor = activeEditor;
 | |
|   const previouslyUpdating = editor._updating;
 | |
|   const observer = editor._observer;
 | |
|   let mutatedNodes = null;
 | |
|   editor._pendingEditorState = null;
 | |
|   editor._editorState = pendingEditorState;
 | |
| 
 | |
|   if (!shouldSkipDOM && needsUpdate && observer !== null) {
 | |
|     activeEditor = editor;
 | |
|     activeEditorState = pendingEditorState;
 | |
|     isReadOnlyMode = false;
 | |
|     // We don't want updates to sync block the reconciliation.
 | |
|     editor._updating = true;
 | |
|     try {
 | |
|       const dirtyType = editor._dirtyType;
 | |
|       const dirtyElements = editor._dirtyElements;
 | |
|       const dirtyLeaves = editor._dirtyLeaves;
 | |
|       observer.disconnect();
 | |
| 
 | |
|       mutatedNodes = $reconcileRoot(
 | |
|         currentEditorState,
 | |
|         pendingEditorState,
 | |
|         editor,
 | |
|         dirtyType,
 | |
|         dirtyElements,
 | |
|         dirtyLeaves,
 | |
|       );
 | |
|     } catch (error) {
 | |
|       // Report errors
 | |
|       if (error instanceof Error) {
 | |
|         editor._onError(error);
 | |
|       }
 | |
| 
 | |
|       // Reset editor and restore incoming editor state to the DOM
 | |
|       if (!isAttemptingToRecoverFromReconcilerError) {
 | |
|         resetEditor(editor, null, rootElement, pendingEditorState);
 | |
|         initMutationObserver(editor);
 | |
|         editor._dirtyType = FULL_RECONCILE;
 | |
|         isAttemptingToRecoverFromReconcilerError = true;
 | |
|         $commitPendingUpdates(editor, currentEditorState);
 | |
|         isAttemptingToRecoverFromReconcilerError = false;
 | |
|       } else {
 | |
|         // To avoid a possible situation of infinite loops, lets throw
 | |
|         throw error;
 | |
|       }
 | |
| 
 | |
|       return;
 | |
|     } finally {
 | |
|       observer.observe(rootElement as Node, observerOptions);
 | |
|       editor._updating = previouslyUpdating;
 | |
|       activeEditorState = previousActiveEditorState;
 | |
|       isReadOnlyMode = previousReadOnlyMode;
 | |
|       activeEditor = previousActiveEditor;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (!pendingEditorState._readOnly) {
 | |
|     pendingEditorState._readOnly = true;
 | |
|     if (__DEV__) {
 | |
|       handleDEVOnlyPendingUpdateGuarantees(pendingEditorState);
 | |
|       if ($isRangeSelection(pendingSelection)) {
 | |
|         Object.freeze(pendingSelection.anchor);
 | |
|         Object.freeze(pendingSelection.focus);
 | |
|       }
 | |
|       Object.freeze(pendingSelection);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const dirtyLeaves = editor._dirtyLeaves;
 | |
|   const dirtyElements = editor._dirtyElements;
 | |
|   const normalizedNodes = editor._normalizedNodes;
 | |
|   const tags = editor._updateTags;
 | |
|   const deferred = editor._deferred;
 | |
|   const nodeCount = pendingEditorState._nodeMap.size;
 | |
| 
 | |
|   if (needsUpdate) {
 | |
|     editor._dirtyType = NO_DIRTY_NODES;
 | |
|     editor._cloneNotNeeded.clear();
 | |
|     editor._dirtyLeaves = new Set();
 | |
|     editor._dirtyElements = new Map();
 | |
|     editor._normalizedNodes = new Set();
 | |
|     editor._updateTags = new Set();
 | |
|   }
 | |
|   $garbageCollectDetachedDecorators(editor, pendingEditorState);
 | |
| 
 | |
|   // ======
 | |
|   // Reconciliation has finished. Now update selection and trigger listeners.
 | |
|   // ======
 | |
| 
 | |
|   const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window);
 | |
| 
 | |
|   // Attempt to update the DOM selection, including focusing of the root element,
 | |
|   // and scroll into view if needed.
 | |
|   if (
 | |
|     editor._editable &&
 | |
|     // domSelection will be null in headless
 | |
|     domSelection !== null &&
 | |
|     (needsUpdate || pendingSelection === null || pendingSelection.dirty)
 | |
|   ) {
 | |
|     activeEditor = editor;
 | |
|     activeEditorState = pendingEditorState;
 | |
|     try {
 | |
|       if (observer !== null) {
 | |
|         observer.disconnect();
 | |
|       }
 | |
|       if (needsUpdate || pendingSelection === null || pendingSelection.dirty) {
 | |
|         const blockCursorElement = editor._blockCursorElement;
 | |
|         if (blockCursorElement !== null) {
 | |
|           removeDOMBlockCursorElement(
 | |
|             blockCursorElement,
 | |
|             editor,
 | |
|             rootElement as HTMLElement,
 | |
|           );
 | |
|         }
 | |
|         updateDOMSelection(
 | |
|           currentSelection,
 | |
|           pendingSelection,
 | |
|           editor,
 | |
|           domSelection,
 | |
|           tags,
 | |
|           rootElement as HTMLElement,
 | |
|           nodeCount,
 | |
|         );
 | |
|       }
 | |
|       updateDOMBlockCursorElement(
 | |
|         editor,
 | |
|         rootElement as HTMLElement,
 | |
|         pendingSelection,
 | |
|       );
 | |
|       if (observer !== null) {
 | |
|         observer.observe(rootElement as Node, observerOptions);
 | |
|       }
 | |
|     } finally {
 | |
|       activeEditor = previousActiveEditor;
 | |
|       activeEditorState = previousActiveEditorState;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (mutatedNodes !== null) {
 | |
|     triggerMutationListeners(
 | |
|       editor,
 | |
|       mutatedNodes,
 | |
|       tags,
 | |
|       dirtyLeaves,
 | |
|       currentEditorState,
 | |
|     );
 | |
|   }
 | |
|   if (
 | |
|     !$isRangeSelection(pendingSelection) &&
 | |
|     pendingSelection !== null &&
 | |
|     (currentSelection === null || !currentSelection.is(pendingSelection))
 | |
|   ) {
 | |
|     editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
 | |
|   }
 | |
|   /**
 | |
|    * Capture pendingDecorators after garbage collecting detached decorators
 | |
|    */
 | |
|   const pendingDecorators = editor._pendingDecorators;
 | |
|   if (pendingDecorators !== null) {
 | |
|     editor._decorators = pendingDecorators;
 | |
|     editor._pendingDecorators = null;
 | |
|     triggerListeners('decorator', editor, true, pendingDecorators);
 | |
|   }
 | |
| 
 | |
|   // If reconciler fails, we reset whole editor (so current editor state becomes empty)
 | |
|   // and attempt to re-render pendingEditorState. If that goes through we trigger
 | |
|   // listeners, but instead use recoverEditorState which is current editor state before reset
 | |
|   // This specifically important for collab that relies on prevEditorState from update
 | |
|   // listener to calculate delta of changed nodes/properties
 | |
|   triggerTextContentListeners(
 | |
|     editor,
 | |
|     recoveryEditorState || currentEditorState,
 | |
|     pendingEditorState,
 | |
|   );
 | |
|   triggerListeners('update', editor, true, {
 | |
|     dirtyElements,
 | |
|     dirtyLeaves,
 | |
|     editorState: pendingEditorState,
 | |
|     normalizedNodes,
 | |
|     prevEditorState: recoveryEditorState || currentEditorState,
 | |
|     tags,
 | |
|   });
 | |
|   triggerDeferredUpdateCallbacks(editor, deferred);
 | |
|   $triggerEnqueuedUpdates(editor);
 | |
| }
 | |
| 
 | |
| function triggerTextContentListeners(
 | |
|   editor: LexicalEditor,
 | |
|   currentEditorState: EditorState,
 | |
|   pendingEditorState: EditorState,
 | |
| ): void {
 | |
|   const currentTextContent = getEditorStateTextContent(currentEditorState);
 | |
|   const latestTextContent = getEditorStateTextContent(pendingEditorState);
 | |
| 
 | |
|   if (currentTextContent !== latestTextContent) {
 | |
|     triggerListeners('textcontent', editor, true, latestTextContent);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function triggerMutationListeners(
 | |
|   editor: LexicalEditor,
 | |
|   mutatedNodes: MutatedNodes,
 | |
|   updateTags: Set<string>,
 | |
|   dirtyLeaves: Set<string>,
 | |
|   prevEditorState: EditorState,
 | |
| ): void {
 | |
|   const listeners = Array.from(editor._listeners.mutation);
 | |
|   const listenersLength = listeners.length;
 | |
| 
 | |
|   for (let i = 0; i < listenersLength; i++) {
 | |
|     const [listener, klass] = listeners[i];
 | |
|     const mutatedNodesByType = mutatedNodes.get(klass);
 | |
|     if (mutatedNodesByType !== undefined) {
 | |
|       listener(mutatedNodesByType, {
 | |
|         dirtyLeaves,
 | |
|         prevEditorState,
 | |
|         updateTags,
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function triggerListeners(
 | |
|   type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable',
 | |
|   editor: LexicalEditor,
 | |
|   isCurrentlyEnqueuingUpdates: boolean,
 | |
|   ...payload: unknown[]
 | |
| ): void {
 | |
|   const previouslyUpdating = editor._updating;
 | |
|   editor._updating = isCurrentlyEnqueuingUpdates;
 | |
| 
 | |
|   try {
 | |
|     const listeners = Array.from<Listener>(editor._listeners[type]);
 | |
|     for (let i = 0; i < listeners.length; i++) {
 | |
|       // @ts-ignore
 | |
|       listeners[i].apply(null, payload);
 | |
|     }
 | |
|   } finally {
 | |
|     editor._updating = previouslyUpdating;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function triggerCommandListeners<
 | |
|   TCommand extends LexicalCommand<unknown>,
 | |
| >(
 | |
|   editor: LexicalEditor,
 | |
|   type: TCommand,
 | |
|   payload: CommandPayloadType<TCommand>,
 | |
| ): boolean {
 | |
|   if (editor._updating === false || activeEditor !== editor) {
 | |
|     let returnVal = false;
 | |
|     editor.update(() => {
 | |
|       returnVal = triggerCommandListeners(editor, type, payload);
 | |
|     });
 | |
|     return returnVal;
 | |
|   }
 | |
| 
 | |
|   const editors = getEditorsToPropagate(editor);
 | |
| 
 | |
|   for (let i = 4; i >= 0; i--) {
 | |
|     for (let e = 0; e < editors.length; e++) {
 | |
|       const currentEditor = editors[e];
 | |
|       const commandListeners = currentEditor._commands;
 | |
|       const listenerInPriorityOrder = commandListeners.get(type);
 | |
| 
 | |
|       if (listenerInPriorityOrder !== undefined) {
 | |
|         const listenersSet = listenerInPriorityOrder[i];
 | |
| 
 | |
|         if (listenersSet !== undefined) {
 | |
|           const listeners = Array.from(listenersSet);
 | |
|           const listenersLength = listeners.length;
 | |
| 
 | |
|           for (let j = 0; j < listenersLength; j++) {
 | |
|             if (listeners[j](payload, editor) === true) {
 | |
|               return true;
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| function $triggerEnqueuedUpdates(editor: LexicalEditor): void {
 | |
|   const queuedUpdates = editor._updates;
 | |
| 
 | |
|   if (queuedUpdates.length !== 0) {
 | |
|     const queuedUpdate = queuedUpdates.shift();
 | |
|     if (queuedUpdate) {
 | |
|       const [updateFn, options] = queuedUpdate;
 | |
|       $beginUpdate(editor, updateFn, options);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function triggerDeferredUpdateCallbacks(
 | |
|   editor: LexicalEditor,
 | |
|   deferred: Array<() => void>,
 | |
| ): void {
 | |
|   editor._deferred = [];
 | |
| 
 | |
|   if (deferred.length !== 0) {
 | |
|     const previouslyUpdating = editor._updating;
 | |
|     editor._updating = true;
 | |
| 
 | |
|     try {
 | |
|       for (let i = 0; i < deferred.length; i++) {
 | |
|         deferred[i]();
 | |
|       }
 | |
|     } finally {
 | |
|       editor._updating = previouslyUpdating;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function processNestedUpdates(
 | |
|   editor: LexicalEditor,
 | |
|   initialSkipTransforms?: boolean,
 | |
| ): boolean {
 | |
|   const queuedUpdates = editor._updates;
 | |
|   let skipTransforms = initialSkipTransforms || false;
 | |
| 
 | |
|   // Updates might grow as we process them, we so we'll need
 | |
|   // to handle each update as we go until the updates array is
 | |
|   // empty.
 | |
|   while (queuedUpdates.length !== 0) {
 | |
|     const queuedUpdate = queuedUpdates.shift();
 | |
|     if (queuedUpdate) {
 | |
|       const [nextUpdateFn, options] = queuedUpdate;
 | |
| 
 | |
|       let onUpdate;
 | |
|       let tag;
 | |
| 
 | |
|       if (options !== undefined) {
 | |
|         onUpdate = options.onUpdate;
 | |
|         tag = options.tag;
 | |
| 
 | |
|         if (options.skipTransforms) {
 | |
|           skipTransforms = true;
 | |
|         }
 | |
|         if (options.discrete) {
 | |
|           const pendingEditorState = editor._pendingEditorState;
 | |
|           invariant(
 | |
|             pendingEditorState !== null,
 | |
|             'Unexpected empty pending editor state on discrete nested update',
 | |
|           );
 | |
|           pendingEditorState._flushSync = true;
 | |
|         }
 | |
| 
 | |
|         if (onUpdate) {
 | |
|           editor._deferred.push(onUpdate);
 | |
|         }
 | |
| 
 | |
|         if (tag) {
 | |
|           editor._updateTags.add(tag);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       nextUpdateFn();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return skipTransforms;
 | |
| }
 | |
| 
 | |
| function $beginUpdate(
 | |
|   editor: LexicalEditor,
 | |
|   updateFn: () => void,
 | |
|   options?: EditorUpdateOptions,
 | |
| ): void {
 | |
|   const updateTags = editor._updateTags;
 | |
|   let onUpdate;
 | |
|   let tag;
 | |
|   let skipTransforms = false;
 | |
|   let discrete = false;
 | |
| 
 | |
|   if (options !== undefined) {
 | |
|     onUpdate = options.onUpdate;
 | |
|     tag = options.tag;
 | |
| 
 | |
|     if (tag != null) {
 | |
|       updateTags.add(tag);
 | |
|     }
 | |
| 
 | |
|     skipTransforms = options.skipTransforms || false;
 | |
|     discrete = options.discrete || false;
 | |
|   }
 | |
| 
 | |
|   if (onUpdate) {
 | |
|     editor._deferred.push(onUpdate);
 | |
|   }
 | |
| 
 | |
|   const currentEditorState = editor._editorState;
 | |
|   let pendingEditorState = editor._pendingEditorState;
 | |
|   let editorStateWasCloned = false;
 | |
| 
 | |
|   if (pendingEditorState === null || pendingEditorState._readOnly) {
 | |
|     pendingEditorState = editor._pendingEditorState = cloneEditorState(
 | |
|       pendingEditorState || currentEditorState,
 | |
|     );
 | |
|     editorStateWasCloned = true;
 | |
|   }
 | |
|   pendingEditorState._flushSync = discrete;
 | |
| 
 | |
|   const previousActiveEditorState = activeEditorState;
 | |
|   const previousReadOnlyMode = isReadOnlyMode;
 | |
|   const previousActiveEditor = activeEditor;
 | |
|   const previouslyUpdating = editor._updating;
 | |
|   activeEditorState = pendingEditorState;
 | |
|   isReadOnlyMode = false;
 | |
|   editor._updating = true;
 | |
|   activeEditor = editor;
 | |
| 
 | |
|   try {
 | |
|     if (editorStateWasCloned) {
 | |
|       if (editor._headless) {
 | |
|         if (currentEditorState._selection !== null) {
 | |
|           pendingEditorState._selection = currentEditorState._selection.clone();
 | |
|         }
 | |
|       } else {
 | |
|         pendingEditorState._selection = $internalCreateSelection(editor);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const startingCompositionKey = editor._compositionKey;
 | |
|     updateFn();
 | |
|     skipTransforms = processNestedUpdates(editor, skipTransforms);
 | |
|     applySelectionTransforms(pendingEditorState, editor);
 | |
| 
 | |
|     if (editor._dirtyType !== NO_DIRTY_NODES) {
 | |
|       if (skipTransforms) {
 | |
|         $normalizeAllDirtyTextNodes(pendingEditorState, editor);
 | |
|       } else {
 | |
|         $applyAllTransforms(pendingEditorState, editor);
 | |
|       }
 | |
| 
 | |
|       processNestedUpdates(editor);
 | |
|       $garbageCollectDetachedNodes(
 | |
|         currentEditorState,
 | |
|         pendingEditorState,
 | |
|         editor._dirtyLeaves,
 | |
|         editor._dirtyElements,
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     const endingCompositionKey = editor._compositionKey;
 | |
| 
 | |
|     if (startingCompositionKey !== endingCompositionKey) {
 | |
|       pendingEditorState._flushSync = true;
 | |
|     }
 | |
| 
 | |
|     const pendingSelection = pendingEditorState._selection;
 | |
| 
 | |
|     if ($isRangeSelection(pendingSelection)) {
 | |
|       const pendingNodeMap = pendingEditorState._nodeMap;
 | |
|       const anchorKey = pendingSelection.anchor.key;
 | |
|       const focusKey = pendingSelection.focus.key;
 | |
| 
 | |
|       if (
 | |
|         pendingNodeMap.get(anchorKey) === undefined ||
 | |
|         pendingNodeMap.get(focusKey) === undefined
 | |
|       ) {
 | |
|         invariant(
 | |
|           false,
 | |
|           'updateEditor: selection has been lost because the previously selected nodes have been removed and ' +
 | |
|             "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
 | |
|         );
 | |
|       }
 | |
|     } else if ($isNodeSelection(pendingSelection)) {
 | |
|       // TODO: we should also validate node selection?
 | |
|       if (pendingSelection._nodes.size === 0) {
 | |
|         pendingEditorState._selection = null;
 | |
|       }
 | |
|     }
 | |
|   } catch (error) {
 | |
|     // Report errors
 | |
|     if (error instanceof Error) {
 | |
|       editor._onError(error);
 | |
|     }
 | |
| 
 | |
|     // Restore existing editor state to the DOM
 | |
|     editor._pendingEditorState = currentEditorState;
 | |
|     editor._dirtyType = FULL_RECONCILE;
 | |
| 
 | |
|     editor._cloneNotNeeded.clear();
 | |
| 
 | |
|     editor._dirtyLeaves = new Set();
 | |
| 
 | |
|     editor._dirtyElements.clear();
 | |
| 
 | |
|     $commitPendingUpdates(editor);
 | |
|     return;
 | |
|   } finally {
 | |
|     activeEditorState = previousActiveEditorState;
 | |
|     isReadOnlyMode = previousReadOnlyMode;
 | |
|     activeEditor = previousActiveEditor;
 | |
|     editor._updating = previouslyUpdating;
 | |
|     infiniteTransformCount = 0;
 | |
|   }
 | |
| 
 | |
|   const shouldUpdate =
 | |
|     editor._dirtyType !== NO_DIRTY_NODES ||
 | |
|     editorStateHasDirtySelection(pendingEditorState, editor);
 | |
| 
 | |
|   if (shouldUpdate) {
 | |
|     if (pendingEditorState._flushSync) {
 | |
|       pendingEditorState._flushSync = false;
 | |
|       $commitPendingUpdates(editor);
 | |
|     } else if (editorStateWasCloned) {
 | |
|       scheduleMicroTask(() => {
 | |
|         $commitPendingUpdates(editor);
 | |
|       });
 | |
|     }
 | |
|   } else {
 | |
|     pendingEditorState._flushSync = false;
 | |
| 
 | |
|     if (editorStateWasCloned) {
 | |
|       updateTags.clear();
 | |
|       editor._deferred = [];
 | |
|       editor._pendingEditorState = null;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function updateEditor(
 | |
|   editor: LexicalEditor,
 | |
|   updateFn: () => void,
 | |
|   options?: EditorUpdateOptions,
 | |
| ): void {
 | |
|   if (editor._updating) {
 | |
|     editor._updates.push([updateFn, options]);
 | |
|   } else {
 | |
|     $beginUpdate(editor, updateFn, options);
 | |
|   }
 | |
| }
 |