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: [], | ||
|  |   }; | ||
|  | } |