2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical';
11 import {mergeRegister} from '@lexical/utils';
19 CLEAR_HISTORY_COMMAND,
20 COMMAND_PRIORITY_EDITOR,
25 type MergeAction = 0 | 1 | 2;
26 const HISTORY_MERGE = 0;
27 const HISTORY_PUSH = 1;
28 const DISCARD_HISTORY_CANDIDATE = 2;
30 type ChangeType = 0 | 1 | 2 | 3 | 4;
32 const COMPOSING_CHARACTER = 1;
33 const INSERT_CHARACTER_AFTER_SELECTION = 2;
34 const DELETE_CHARACTER_BEFORE_SELECTION = 3;
35 const DELETE_CHARACTER_AFTER_SELECTION = 4;
37 export type HistoryStateEntry = {
38 editor: LexicalEditor;
39 editorState: EditorState;
41 export type HistoryState = {
42 current: null | HistoryStateEntry;
43 redoStack: Array<HistoryStateEntry>;
44 undoStack: Array<HistoryStateEntry>;
47 type IntentionallyMarkedAsDirtyElement = boolean;
49 function getDirtyNodes(
50 editorState: EditorState,
51 dirtyLeaves: Set<NodeKey>,
52 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
53 ): Array<LexicalNode> {
54 const nodeMap = editorState._nodeMap;
57 for (const dirtyLeafKey of dirtyLeaves) {
58 const dirtyLeaf = nodeMap.get(dirtyLeafKey);
60 if (dirtyLeaf !== undefined) {
61 nodes.push(dirtyLeaf);
65 for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) {
66 if (!intentionallyMarkedAsDirty) {
70 const dirtyElement = nodeMap.get(dirtyElementKey);
72 if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) {
73 nodes.push(dirtyElement);
80 function getChangeType(
81 prevEditorState: null | EditorState,
82 nextEditorState: EditorState,
83 dirtyLeavesSet: Set<NodeKey>,
84 dirtyElementsSet: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
88 prevEditorState === null ||
89 (dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing)
94 const nextSelection = nextEditorState._selection;
95 const prevSelection = prevEditorState._selection;
98 return COMPOSING_CHARACTER;
102 !$isRangeSelection(nextSelection) ||
103 !$isRangeSelection(prevSelection) ||
104 !prevSelection.isCollapsed() ||
105 !nextSelection.isCollapsed()
110 const dirtyNodes = getDirtyNodes(
116 if (dirtyNodes.length === 0) {
120 // Catching the case when inserting new text node into an element (e.g. first char in paragraph/list),
121 // or after existing node.
122 if (dirtyNodes.length > 1) {
123 const nextNodeMap = nextEditorState._nodeMap;
124 const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key);
125 const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key);
130 !prevEditorState._nodeMap.has(nextAnchorNode.__key) &&
131 $isTextNode(nextAnchorNode) &&
132 nextAnchorNode.__text.length === 1 &&
133 nextSelection.anchor.offset === 1
135 return INSERT_CHARACTER_AFTER_SELECTION;
141 const nextDirtyNode = dirtyNodes[0];
143 const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key);
146 !$isTextNode(prevDirtyNode) ||
147 !$isTextNode(nextDirtyNode) ||
148 prevDirtyNode.__mode !== nextDirtyNode.__mode
153 const prevText = prevDirtyNode.__text;
154 const nextText = nextDirtyNode.__text;
156 if (prevText === nextText) {
160 const nextAnchor = nextSelection.anchor;
161 const prevAnchor = prevSelection.anchor;
163 if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') {
167 const nextAnchorOffset = nextAnchor.offset;
168 const prevAnchorOffset = prevAnchor.offset;
169 const textDiff = nextText.length - prevText.length;
171 if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) {
172 return INSERT_CHARACTER_AFTER_SELECTION;
175 if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) {
176 return DELETE_CHARACTER_BEFORE_SELECTION;
179 if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) {
180 return DELETE_CHARACTER_AFTER_SELECTION;
186 function isTextNodeUnchanged(
188 prevEditorState: EditorState,
189 nextEditorState: EditorState,
191 const prevNode = prevEditorState._nodeMap.get(key);
192 const nextNode = nextEditorState._nodeMap.get(key);
194 const prevSelection = prevEditorState._selection;
195 const nextSelection = nextEditorState._selection;
196 const isDeletingLine =
197 $isRangeSelection(prevSelection) &&
198 $isRangeSelection(nextSelection) &&
199 prevSelection.anchor.type === 'element' &&
200 prevSelection.focus.type === 'element' &&
201 nextSelection.anchor.type === 'text' &&
202 nextSelection.focus.type === 'text';
206 $isTextNode(prevNode) &&
207 $isTextNode(nextNode) &&
208 prevNode.__parent === nextNode.__parent
210 // This has the assumption that object key order won't change if the
211 // content did not change, which should normally be safe given
212 // the manner in which nodes and exportJSON are typically implemented.
214 JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) ===
215 JSON.stringify(nextEditorState.read(() => nextNode.exportJSON()))
221 function createMergeActionGetter(
222 editor: LexicalEditor,
225 prevEditorState: null | EditorState,
226 nextEditorState: EditorState,
227 currentHistoryEntry: null | HistoryStateEntry,
228 dirtyLeaves: Set<NodeKey>,
229 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
232 let prevChangeTime = Date.now();
233 let prevChangeType = OTHER;
243 const changeTime = Date.now();
245 // If applying changes from history stack there's no need
246 // to run history logic again, as history entries already calculated
247 if (tags.has('historic')) {
248 prevChangeType = OTHER;
249 prevChangeTime = changeTime;
250 return DISCARD_HISTORY_CANDIDATE;
253 const changeType = getChangeType(
258 editor.isComposing(),
261 const mergeAction = (() => {
263 currentHistoryEntry === null || currentHistoryEntry.editor === editor;
264 const shouldPushHistory = tags.has('history-push');
265 const shouldMergeHistory =
266 !shouldPushHistory && isSameEditor && tags.has('history-merge');
268 if (shouldMergeHistory) {
269 return HISTORY_MERGE;
272 if (prevEditorState === null) {
276 const selection = nextEditorState._selection;
277 const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
279 if (!hasDirtyNodes) {
280 if (selection !== null) {
281 return HISTORY_MERGE;
284 return DISCARD_HISTORY_CANDIDATE;
288 shouldPushHistory === false &&
289 changeType !== OTHER &&
290 changeType === prevChangeType &&
291 changeTime < prevChangeTime + delay &&
294 return HISTORY_MERGE;
297 // A single node might have been marked as dirty, but not have changed
298 // due to some node transform reverting the change.
299 if (dirtyLeaves.size === 1) {
300 const dirtyLeafKey = Array.from(dirtyLeaves)[0];
302 isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
304 return HISTORY_MERGE;
311 prevChangeTime = changeTime;
312 prevChangeType = changeType;
318 function redo(editor: LexicalEditor, historyState: HistoryState): void {
319 const redoStack = historyState.redoStack;
320 const undoStack = historyState.undoStack;
322 if (redoStack.length !== 0) {
323 const current = historyState.current;
325 if (current !== null) {
326 undoStack.push(current);
327 editor.dispatchCommand(CAN_UNDO_COMMAND, true);
330 const historyStateEntry = redoStack.pop();
332 if (redoStack.length === 0) {
333 editor.dispatchCommand(CAN_REDO_COMMAND, false);
336 historyState.current = historyStateEntry || null;
338 if (historyStateEntry) {
339 historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
346 function undo(editor: LexicalEditor, historyState: HistoryState): void {
347 const redoStack = historyState.redoStack;
348 const undoStack = historyState.undoStack;
349 const undoStackLength = undoStack.length;
351 if (undoStackLength !== 0) {
352 const current = historyState.current;
353 const historyStateEntry = undoStack.pop();
355 if (current !== null) {
356 redoStack.push(current);
357 editor.dispatchCommand(CAN_REDO_COMMAND, true);
360 if (undoStack.length === 0) {
361 editor.dispatchCommand(CAN_UNDO_COMMAND, false);
364 historyState.current = historyStateEntry || null;
366 if (historyStateEntry) {
367 historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
374 function clearHistory(historyState: HistoryState) {
375 historyState.undoStack = [];
376 historyState.redoStack = [];
377 historyState.current = null;
381 * Registers necessary listeners to manage undo/redo history stack and related editor commands.
382 * It returns `unregister` callback that cleans up all listeners and should be called on editor unmount.
383 * @param editor - The lexical editor.
384 * @param historyState - The history state, containing the current state and the undo/redo stack.
385 * @param delay - The time (in milliseconds) the editor should delay generating a new history stack,
386 * instead of merging the current changes with the current stack.
387 * @returns The listeners cleanup callback function.
389 export function registerHistory(
390 editor: LexicalEditor,
391 historyState: HistoryState,
394 const getMergeAction = createMergeActionGetter(editor, delay);
396 const applyChange = ({
403 editorState: EditorState;
404 prevEditorState: EditorState;
405 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
406 dirtyLeaves: Set<NodeKey>;
409 const current = historyState.current;
410 const redoStack = historyState.redoStack;
411 const undoStack = historyState.undoStack;
412 const currentEditorState = current === null ? null : current.editorState;
414 if (current !== null && editorState === currentEditorState) {
418 const mergeAction = getMergeAction(
427 if (mergeAction === HISTORY_PUSH) {
428 if (redoStack.length !== 0) {
429 historyState.redoStack = [];
430 editor.dispatchCommand(CAN_REDO_COMMAND, false);
433 if (current !== null) {
437 editor.dispatchCommand(CAN_UNDO_COMMAND, true);
439 } else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
444 historyState.current = {
450 const unregister = mergeRegister(
451 editor.registerCommand(
454 undo(editor, historyState);
457 COMMAND_PRIORITY_EDITOR,
459 editor.registerCommand(
462 redo(editor, historyState);
465 COMMAND_PRIORITY_EDITOR,
467 editor.registerCommand(
468 CLEAR_EDITOR_COMMAND,
470 clearHistory(historyState);
473 COMMAND_PRIORITY_EDITOR,
475 editor.registerCommand(
476 CLEAR_HISTORY_COMMAND,
478 clearHistory(historyState);
479 editor.dispatchCommand(CAN_REDO_COMMAND, false);
480 editor.dispatchCommand(CAN_UNDO_COMMAND, false);
483 COMMAND_PRIORITY_EDITOR,
485 editor.registerUpdateListener(applyChange),
492 * Creates an empty history state.
493 * @returns - The empty history state, as an object.
495 export function createEmptyHistoryState(): HistoryState {