UNPKG

@lexical/history

Version:

This package contains selection history helpers for Lexical.

399 lines (386 loc) 14.8 kB
/** * 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;