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