@lexical/history
Version:
This package contains selection history helpers for Lexical.
399 lines (386 loc) • 14.8 kB
JavaScript
/**
* 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.
*
*/
'use strict';
var extension = require('@lexical/extension');
var utils = require('@lexical/utils');
var lexical = require('lexical');
/**
* 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.
*
*/
const HISTORY_MERGE = 0;
const HISTORY_PUSH = 1;
const DISCARD_HISTORY_CANDIDATE = 2;
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;
function getDirtyNodes(editorState, dirtyLeaves, dirtyElements) {
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 && !lexical.$isRootNode(dirtyElement)) {
nodes.push(dirtyElement);
}
}
return nodes;
}
function getChangeType(prevEditorState, nextEditorState, dirtyLeavesSet, dirtyElementsSet, isComposing) {
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 (!lexical.$isRangeSelection(nextSelection) || !lexical.$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) && lexical.$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 (!lexical.$isTextNode(prevDirtyNode) || !lexical.$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, prevEditorState, nextEditorState) {
const prevNode = prevEditorState._nodeMap.get(key);
const nextNode = nextEditorState._nodeMap.get(key);
const prevSelection = prevEditorState._selection;
const nextSelection = nextEditorState._selection;
const isDeletingLine = lexical.$isRangeSelection(prevSelection) && lexical.$isRangeSelection(nextSelection) && prevSelection.anchor.type === 'element' && prevSelection.focus.type === 'element' && nextSelection.anchor.type === 'text' && nextSelection.focus.type === 'text';
if (!isDeletingLine && lexical.$isTextNode(prevNode) && lexical.$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, delayOrStore, dateNow) {
let prevChangeTime = dateNow();
let prevChangeType = OTHER;
let compositionStartTime = prevChangeTime;
let compositionStartChangeType = OTHER;
let compositionStartState = null;
return (prevEditorState, nextEditorState, currentHistoryEntry, dirtyLeaves, dirtyElements, tags) => {
const changeTime = dateNow();
if (tags.has(lexical.COMPOSITION_START_TAG)) {
compositionStartTime = prevChangeTime;
compositionStartChangeType = prevChangeType;
compositionStartState = prevEditorState;
}
// If applying changes from history stack there's no need
// to run history logic again, as history entries already calculated
if (tags.has(lexical.HISTORIC_TAG)) {
prevChangeType = OTHER;
prevChangeTime = changeTime;
return DISCARD_HISTORY_CANDIDATE;
}
const isCompositionEnd = tags.has(lexical.COMPOSITION_END_TAG);
if (isCompositionEnd && compositionStartState) {
prevChangeTime = compositionStartTime;
prevChangeType = compositionStartChangeType;
prevEditorState = compositionStartState;
}
const changeType = getChangeType(prevEditorState, nextEditorState, dirtyLeaves, dirtyElements, editor.isComposing());
const mergeAction = (() => {
const isSameEditor = currentHistoryEntry === null || currentHistoryEntry.editor === editor;
const shouldPushHistory = tags.has(lexical.HISTORY_PUSH_TAG);
const shouldMergeHistory = !shouldPushHistory && isSameEditor && tags.has(lexical.HISTORY_MERGE_TAG);
if (shouldMergeHistory) {
return HISTORY_MERGE;
}
if (changeType === COMPOSING_CHARACTER) {
return DISCARD_HISTORY_CANDIDATE;
}
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;
}
const delay = typeof delayOrStore === 'number' ? delayOrStore : delayOrStore.peek();
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, historyState) {
const redoStack = historyState.redoStack;
const undoStack = historyState.undoStack;
if (redoStack.length !== 0) {
const current = historyState.current;
if (current !== null) {
undoStack.push(current);
editor.dispatchCommand(lexical.CAN_UNDO_COMMAND, true);
}
const historyStateEntry = redoStack.pop();
if (redoStack.length === 0) {
editor.dispatchCommand(lexical.CAN_REDO_COMMAND, false);
}
historyState.current = historyStateEntry || null;
if (historyStateEntry) {
historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
tag: lexical.HISTORIC_TAG
});
}
}
}
function undo(editor, historyState) {
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(lexical.CAN_REDO_COMMAND, true);
}
if (undoStack.length === 0) {
editor.dispatchCommand(lexical.CAN_UNDO_COMMAND, false);
}
historyState.current = historyStateEntry || null;
if (historyStateEntry) {
historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
tag: lexical.HISTORIC_TAG
});
}
}
}
function clearHistory(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.
*/
function registerHistory(editor, historyState, delay, dateNow = Date.now) {
const getMergeAction = createMergeActionGetter(editor, delay, dateNow);
const applyChange = ({
editorState,
prevEditorState,
dirtyLeaves,
dirtyElements,
tags
}) => {
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(lexical.CAN_REDO_COMMAND, false);
}
if (current !== null) {
undoStack.push({
...current
});
editor.dispatchCommand(lexical.CAN_UNDO_COMMAND, true);
}
} else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
return;
}
// Else we merge
historyState.current = {
editor,
editorState
};
};
return utils.mergeRegister(editor.registerCommand(lexical.UNDO_COMMAND, () => {
undo(editor, historyState);
return true;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.REDO_COMMAND, () => {
redo(editor, historyState);
return true;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.CLEAR_EDITOR_COMMAND, () => {
clearHistory(historyState);
return false;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.CLEAR_HISTORY_COMMAND, () => {
clearHistory(historyState);
editor.dispatchCommand(lexical.CAN_REDO_COMMAND, false);
editor.dispatchCommand(lexical.CAN_UNDO_COMMAND, false);
return true;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerUpdateListener(applyChange));
}
/**
* Creates an empty history state.
* @returns - The empty history state, as an object.
*/
function createEmptyHistoryState() {
return {
current: null,
redoStack: [],
undoStack: []
};
}
/**
* Registers necessary listeners to manage undo/redo history stack and related
* editor commands, via the \@lexical/history module.
*/
const HistoryExtension = lexical.defineExtension({
build: (editor, {
delay,
createInitialHistoryState,
disabled,
now
}) => extension.namedSignals({
delay,
disabled,
historyState: createInitialHistoryState(editor),
now
}),
config: lexical.safeCast({
createInitialHistoryState: createEmptyHistoryState,
delay: 300,
disabled: typeof window === 'undefined',
now: Date.now
}),
name: '@lexical/history/History',
register: (editor, config, state) => {
const stores = state.getOutput();
return extension.effect(() => stores.disabled.value ? undefined : registerHistory(editor, stores.historyState.value, stores.delay, () => stores.now.peek()()));
}
});
function getHistoryPeer(editor) {
return editor ? extension.getPeerDependencyFromEditor(editor, HistoryExtension.name) : null;
}
/**
* Registers necessary listeners to manage undo/redo history stack and related
* editor commands, via the \@lexical/history module, only if the parent editor
* has a history plugin implementation.
*/
const SharedHistoryExtension = lexical.defineExtension({
build: (editor, {
disabled,
parentEditor
}) => extension.namedSignals({
disabled,
parentEditor: parentEditor || editor._parentEditor
}),
config: lexical.safeCast({
disabled: false,
parentEditor: null
}),
dependencies: [lexical.configExtension(HistoryExtension, {
disabled: true
})],
name: '@lexical/history/SharedHistory',
register(editor, _config, state) {
return extension.effect(() => {
const {
disabled,
parentEditor
} = state.getOutput();
if (!disabled.value) {
const {
output
} = state.getDependency(HistoryExtension);
const parentPeer = getHistoryPeer(parentEditor.value);
if (!parentPeer) {
return;
}
const parentOutput = parentPeer.output;
extension.batch(() => {
output.delay.value = parentOutput.delay.value;
output.historyState.value = parentOutput.historyState.value;
output.now.value = parentOutput.now.value;
// Note that toggling the parent history will force this to be changed
output.disabled.value = parentOutput.disabled.value;
});
}
});
}
});
exports.HistoryExtension = HistoryExtension;
exports.SharedHistoryExtension = SharedHistoryExtension;
exports.createEmptyHistoryState = createEmptyHistoryState;
exports.registerHistory = registerHistory;