BookStack/resources/js/wysiwyg/lexical/history/index.ts
Dan Brown 22d078b47f
Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
2024-09-18 13:43:39 +01:00

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