502 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			502 lines
		
	
	
		
			13 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 {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical';
 | |
| 
 | |
| import {mergeRegister} from '@lexical/utils';
 | |
| import {
 | |
|   $isRangeSelection,
 | |
|   $isRootNode,
 | |
|   $isTextNode,
 | |
|   CAN_REDO_COMMAND,
 | |
|   CAN_UNDO_COMMAND,
 | |
|   CLEAR_EDITOR_COMMAND,
 | |
|   CLEAR_HISTORY_COMMAND,
 | |
|   COMMAND_PRIORITY_EDITOR,
 | |
|   REDO_COMMAND,
 | |
|   UNDO_COMMAND,
 | |
| } from 'lexical';
 | |
| 
 | |
| type MergeAction = 0 | 1 | 2;
 | |
| const HISTORY_MERGE = 0;
 | |
| const HISTORY_PUSH = 1;
 | |
| const DISCARD_HISTORY_CANDIDATE = 2;
 | |
| 
 | |
| type ChangeType = 0 | 1 | 2 | 3 | 4;
 | |
| const OTHER = 0;
 | |
| const COMPOSING_CHARACTER = 1;
 | |
| const INSERT_CHARACTER_AFTER_SELECTION = 2;
 | |
| const DELETE_CHARACTER_BEFORE_SELECTION = 3;
 | |
| const DELETE_CHARACTER_AFTER_SELECTION = 4;
 | |
| 
 | |
| export type HistoryStateEntry = {
 | |
|   editor: LexicalEditor;
 | |
|   editorState: EditorState;
 | |
| };
 | |
| export type HistoryState = {
 | |
|   current: null | HistoryStateEntry;
 | |
|   redoStack: Array<HistoryStateEntry>;
 | |
|   undoStack: Array<HistoryStateEntry>;
 | |
| };
 | |
| 
 | |
| type IntentionallyMarkedAsDirtyElement = boolean;
 | |
| 
 | |
| function getDirtyNodes(
 | |
|   editorState: EditorState,
 | |
|   dirtyLeaves: Set<NodeKey>,
 | |
|   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
 | |
| ): Array<LexicalNode> {
 | |
|   const nodeMap = editorState._nodeMap;
 | |
|   const nodes = [];
 | |
| 
 | |
|   for (const dirtyLeafKey of dirtyLeaves) {
 | |
|     const dirtyLeaf = nodeMap.get(dirtyLeafKey);
 | |
| 
 | |
|     if (dirtyLeaf !== undefined) {
 | |
|       nodes.push(dirtyLeaf);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) {
 | |
|     if (!intentionallyMarkedAsDirty) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     const dirtyElement = nodeMap.get(dirtyElementKey);
 | |
| 
 | |
|     if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) {
 | |
|       nodes.push(dirtyElement);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return nodes;
 | |
| }
 | |
| 
 | |
| function getChangeType(
 | |
|   prevEditorState: null | EditorState,
 | |
|   nextEditorState: EditorState,
 | |
|   dirtyLeavesSet: Set<NodeKey>,
 | |
|   dirtyElementsSet: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
 | |
|   isComposing: boolean,
 | |
| ): ChangeType {
 | |
|   if (
 | |
|     prevEditorState === null ||
 | |
|     (dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing)
 | |
|   ) {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const nextSelection = nextEditorState._selection;
 | |
|   const prevSelection = prevEditorState._selection;
 | |
| 
 | |
|   if (isComposing) {
 | |
|     return COMPOSING_CHARACTER;
 | |
|   }
 | |
| 
 | |
|   if (
 | |
|     !$isRangeSelection(nextSelection) ||
 | |
|     !$isRangeSelection(prevSelection) ||
 | |
|     !prevSelection.isCollapsed() ||
 | |
|     !nextSelection.isCollapsed()
 | |
|   ) {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const dirtyNodes = getDirtyNodes(
 | |
|     nextEditorState,
 | |
|     dirtyLeavesSet,
 | |
|     dirtyElementsSet,
 | |
|   );
 | |
| 
 | |
|   if (dirtyNodes.length === 0) {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   // Catching the case when inserting new text node into an element (e.g. first char in paragraph/list),
 | |
|   // or after existing node.
 | |
|   if (dirtyNodes.length > 1) {
 | |
|     const nextNodeMap = nextEditorState._nodeMap;
 | |
|     const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key);
 | |
|     const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key);
 | |
| 
 | |
|     if (
 | |
|       nextAnchorNode &&
 | |
|       prevAnchorNode &&
 | |
|       !prevEditorState._nodeMap.has(nextAnchorNode.__key) &&
 | |
|       $isTextNode(nextAnchorNode) &&
 | |
|       nextAnchorNode.__text.length === 1 &&
 | |
|       nextSelection.anchor.offset === 1
 | |
|     ) {
 | |
|       return INSERT_CHARACTER_AFTER_SELECTION;
 | |
|     }
 | |
| 
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const nextDirtyNode = dirtyNodes[0];
 | |
| 
 | |
|   const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key);
 | |
| 
 | |
|   if (
 | |
|     !$isTextNode(prevDirtyNode) ||
 | |
|     !$isTextNode(nextDirtyNode) ||
 | |
|     prevDirtyNode.__mode !== nextDirtyNode.__mode
 | |
|   ) {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const prevText = prevDirtyNode.__text;
 | |
|   const nextText = nextDirtyNode.__text;
 | |
| 
 | |
|   if (prevText === nextText) {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const nextAnchor = nextSelection.anchor;
 | |
|   const prevAnchor = prevSelection.anchor;
 | |
| 
 | |
|   if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') {
 | |
|     return OTHER;
 | |
|   }
 | |
| 
 | |
|   const nextAnchorOffset = nextAnchor.offset;
 | |
|   const prevAnchorOffset = prevAnchor.offset;
 | |
|   const textDiff = nextText.length - prevText.length;
 | |
| 
 | |
|   if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) {
 | |
|     return INSERT_CHARACTER_AFTER_SELECTION;
 | |
|   }
 | |
| 
 | |
|   if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) {
 | |
|     return DELETE_CHARACTER_BEFORE_SELECTION;
 | |
|   }
 | |
| 
 | |
|   if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) {
 | |
|     return DELETE_CHARACTER_AFTER_SELECTION;
 | |
|   }
 | |
| 
 | |
|   return OTHER;
 | |
| }
 | |
| 
 | |
| function isTextNodeUnchanged(
 | |
|   key: NodeKey,
 | |
|   prevEditorState: EditorState,
 | |
|   nextEditorState: EditorState,
 | |
| ): boolean {
 | |
|   const prevNode = prevEditorState._nodeMap.get(key);
 | |
|   const nextNode = nextEditorState._nodeMap.get(key);
 | |
| 
 | |
|   const prevSelection = prevEditorState._selection;
 | |
|   const nextSelection = nextEditorState._selection;
 | |
|   const isDeletingLine =
 | |
|     $isRangeSelection(prevSelection) &&
 | |
|     $isRangeSelection(nextSelection) &&
 | |
|     prevSelection.anchor.type === 'element' &&
 | |
|     prevSelection.focus.type === 'element' &&
 | |
|     nextSelection.anchor.type === 'text' &&
 | |
|     nextSelection.focus.type === 'text';
 | |
| 
 | |
|   if (
 | |
|     !isDeletingLine &&
 | |
|     $isTextNode(prevNode) &&
 | |
|     $isTextNode(nextNode) &&
 | |
|     prevNode.__parent === nextNode.__parent
 | |
|   ) {
 | |
|     // This has the assumption that object key order won't change if the
 | |
|     // content did not change, which should normally be safe given
 | |
|     // the manner in which nodes and exportJSON are typically implemented.
 | |
|     return (
 | |
|       JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) ===
 | |
|       JSON.stringify(nextEditorState.read(() => nextNode.exportJSON()))
 | |
|     );
 | |
|   }
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| function createMergeActionGetter(
 | |
|   editor: LexicalEditor,
 | |
|   delay: number,
 | |
| ): (
 | |
|   prevEditorState: null | EditorState,
 | |
|   nextEditorState: EditorState,
 | |
|   currentHistoryEntry: null | HistoryStateEntry,
 | |
|   dirtyLeaves: Set<NodeKey>,
 | |
|   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
 | |
|   tags: Set<string>,
 | |
| ) => MergeAction {
 | |
|   let prevChangeTime = Date.now();
 | |
|   let prevChangeType = OTHER;
 | |
| 
 | |
|   return (
 | |
|     prevEditorState,
 | |
|     nextEditorState,
 | |
|     currentHistoryEntry,
 | |
|     dirtyLeaves,
 | |
|     dirtyElements,
 | |
|     tags,
 | |
|   ) => {
 | |
|     const changeTime = Date.now();
 | |
| 
 | |
|     // If applying changes from history stack there's no need
 | |
|     // to run history logic again, as history entries already calculated
 | |
|     if (tags.has('historic')) {
 | |
|       prevChangeType = OTHER;
 | |
|       prevChangeTime = changeTime;
 | |
|       return DISCARD_HISTORY_CANDIDATE;
 | |
|     }
 | |
| 
 | |
|     const changeType = getChangeType(
 | |
|       prevEditorState,
 | |
|       nextEditorState,
 | |
|       dirtyLeaves,
 | |
|       dirtyElements,
 | |
|       editor.isComposing(),
 | |
|     );
 | |
| 
 | |
|     const mergeAction = (() => {
 | |
|       const isSameEditor =
 | |
|         currentHistoryEntry === null || currentHistoryEntry.editor === editor;
 | |
|       const shouldPushHistory = tags.has('history-push');
 | |
|       const shouldMergeHistory =
 | |
|         !shouldPushHistory && isSameEditor && tags.has('history-merge');
 | |
| 
 | |
|       if (shouldMergeHistory) {
 | |
|         return HISTORY_MERGE;
 | |
|       }
 | |
| 
 | |
|       if (prevEditorState === null) {
 | |
|         return HISTORY_PUSH;
 | |
|       }
 | |
| 
 | |
|       const selection = nextEditorState._selection;
 | |
|       const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
 | |
| 
 | |
|       if (!hasDirtyNodes) {
 | |
|         if (selection !== null) {
 | |
|           return HISTORY_MERGE;
 | |
|         }
 | |
| 
 | |
|         return DISCARD_HISTORY_CANDIDATE;
 | |
|       }
 | |
| 
 | |
|       if (
 | |
|         shouldPushHistory === false &&
 | |
|         changeType !== OTHER &&
 | |
|         changeType === prevChangeType &&
 | |
|         changeTime < prevChangeTime + delay &&
 | |
|         isSameEditor
 | |
|       ) {
 | |
|         return HISTORY_MERGE;
 | |
|       }
 | |
| 
 | |
|       // A single node might have been marked as dirty, but not have changed
 | |
|       // due to some node transform reverting the change.
 | |
|       if (dirtyLeaves.size === 1) {
 | |
|         const dirtyLeafKey = Array.from(dirtyLeaves)[0];
 | |
|         if (
 | |
|           isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
 | |
|         ) {
 | |
|           return HISTORY_MERGE;
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       return HISTORY_PUSH;
 | |
|     })();
 | |
| 
 | |
|     prevChangeTime = changeTime;
 | |
|     prevChangeType = changeType;
 | |
| 
 | |
|     return mergeAction;
 | |
|   };
 | |
| }
 | |
| 
 | |
| function redo(editor: LexicalEditor, historyState: HistoryState): void {
 | |
|   const redoStack = historyState.redoStack;
 | |
|   const undoStack = historyState.undoStack;
 | |
| 
 | |
|   if (redoStack.length !== 0) {
 | |
|     const current = historyState.current;
 | |
| 
 | |
|     if (current !== null) {
 | |
|       undoStack.push(current);
 | |
|       editor.dispatchCommand(CAN_UNDO_COMMAND, true);
 | |
|     }
 | |
| 
 | |
|     const historyStateEntry = redoStack.pop();
 | |
| 
 | |
|     if (redoStack.length === 0) {
 | |
|       editor.dispatchCommand(CAN_REDO_COMMAND, false);
 | |
|     }
 | |
| 
 | |
|     historyState.current = historyStateEntry || null;
 | |
| 
 | |
|     if (historyStateEntry) {
 | |
|       historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
 | |
|         tag: 'historic',
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function undo(editor: LexicalEditor, historyState: HistoryState): void {
 | |
|   const redoStack = historyState.redoStack;
 | |
|   const undoStack = historyState.undoStack;
 | |
|   const undoStackLength = undoStack.length;
 | |
| 
 | |
|   if (undoStackLength !== 0) {
 | |
|     const current = historyState.current;
 | |
|     const historyStateEntry = undoStack.pop();
 | |
| 
 | |
|     if (current !== null) {
 | |
|       redoStack.push(current);
 | |
|       editor.dispatchCommand(CAN_REDO_COMMAND, true);
 | |
|     }
 | |
| 
 | |
|     if (undoStack.length === 0) {
 | |
|       editor.dispatchCommand(CAN_UNDO_COMMAND, false);
 | |
|     }
 | |
| 
 | |
|     historyState.current = historyStateEntry || null;
 | |
| 
 | |
|     if (historyStateEntry) {
 | |
|       historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
 | |
|         tag: 'historic',
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function clearHistory(historyState: HistoryState) {
 | |
|   historyState.undoStack = [];
 | |
|   historyState.redoStack = [];
 | |
|   historyState.current = null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Registers necessary listeners to manage undo/redo history stack and related editor commands.
 | |
|  * It returns `unregister` callback that cleans up all listeners and should be called on editor unmount.
 | |
|  * @param editor - The lexical editor.
 | |
|  * @param historyState - The history state, containing the current state and the undo/redo stack.
 | |
|  * @param delay - The time (in milliseconds) the editor should delay generating a new history stack,
 | |
|  * instead of merging the current changes with the current stack.
 | |
|  * @returns The listeners cleanup callback function.
 | |
|  */
 | |
| export function registerHistory(
 | |
|   editor: LexicalEditor,
 | |
|   historyState: HistoryState,
 | |
|   delay: number,
 | |
| ): () => void {
 | |
|   const getMergeAction = createMergeActionGetter(editor, delay);
 | |
| 
 | |
|   const applyChange = ({
 | |
|     editorState,
 | |
|     prevEditorState,
 | |
|     dirtyLeaves,
 | |
|     dirtyElements,
 | |
|     tags,
 | |
|   }: {
 | |
|     editorState: EditorState;
 | |
|     prevEditorState: EditorState;
 | |
|     dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
 | |
|     dirtyLeaves: Set<NodeKey>;
 | |
|     tags: Set<string>;
 | |
|   }): void => {
 | |
|     const current = historyState.current;
 | |
|     const redoStack = historyState.redoStack;
 | |
|     const undoStack = historyState.undoStack;
 | |
|     const currentEditorState = current === null ? null : current.editorState;
 | |
| 
 | |
|     if (current !== null && editorState === currentEditorState) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const mergeAction = getMergeAction(
 | |
|       prevEditorState,
 | |
|       editorState,
 | |
|       current,
 | |
|       dirtyLeaves,
 | |
|       dirtyElements,
 | |
|       tags,
 | |
|     );
 | |
| 
 | |
|     if (mergeAction === HISTORY_PUSH) {
 | |
|       if (redoStack.length !== 0) {
 | |
|         historyState.redoStack = [];
 | |
|         editor.dispatchCommand(CAN_REDO_COMMAND, false);
 | |
|       }
 | |
| 
 | |
|       if (current !== null) {
 | |
|         undoStack.push({
 | |
|           ...current,
 | |
|         });
 | |
|         editor.dispatchCommand(CAN_UNDO_COMMAND, true);
 | |
|       }
 | |
|     } else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Else we merge
 | |
|     historyState.current = {
 | |
|       editor,
 | |
|       editorState,
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   const unregister = mergeRegister(
 | |
|     editor.registerCommand(
 | |
|       UNDO_COMMAND,
 | |
|       () => {
 | |
|         undo(editor, historyState);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand(
 | |
|       REDO_COMMAND,
 | |
|       () => {
 | |
|         redo(editor, historyState);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand(
 | |
|       CLEAR_EDITOR_COMMAND,
 | |
|       () => {
 | |
|         clearHistory(historyState);
 | |
|         return false;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand(
 | |
|       CLEAR_HISTORY_COMMAND,
 | |
|       () => {
 | |
|         clearHistory(historyState);
 | |
|         editor.dispatchCommand(CAN_REDO_COMMAND, false);
 | |
|         editor.dispatchCommand(CAN_UNDO_COMMAND, false);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerUpdateListener(applyChange),
 | |
|   );
 | |
| 
 | |
|   return unregister;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Creates an empty history state.
 | |
|  * @returns - The empty history state, as an object.
 | |
|  */
 | |
| export function createEmptyHistoryState(): HistoryState {
 | |
|   return {
 | |
|     current: null,
 | |
|     redoStack: [],
 | |
|     undoStack: [],
 | |
|   };
 | |
| }
 |