mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03:00
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.
This commit is contained in:
501
resources/js/wysiwyg/lexical/history/index.ts
Normal file
501
resources/js/wysiwyg/lexical/history/index.ts
Normal file
@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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: [],
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user