/** * 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; undoStack: Array; }; type IntentionallyMarkedAsDirtyElement = boolean; function getDirtyNodes( editorState: EditorState, dirtyLeaves: Set, dirtyElements: Map, ): Array { 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, dirtyElementsSet: Map, 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, dirtyElements: Map, tags: Set, ) => 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; dirtyLeaves: Set; tags: Set; }): 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: [], }; }