mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-06-14 12:02:31 +03:00
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.
1036 lines
29 KiB
TypeScript
1036 lines
29 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 {SerializedEditorState} from './LexicalEditorState';
|
|
import type {LexicalNode, SerializedLexicalNode} from './LexicalNode';
|
|
|
|
import invariant from 'lexical/shared/invariant';
|
|
|
|
import {$isElementNode, $isTextNode, SELECTION_CHANGE_COMMAND} from '.';
|
|
import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants';
|
|
import {
|
|
CommandPayloadType,
|
|
EditorUpdateOptions,
|
|
LexicalCommand,
|
|
LexicalEditor,
|
|
Listener,
|
|
MutatedNodes,
|
|
RegisteredNodes,
|
|
resetEditor,
|
|
Transform,
|
|
} from './LexicalEditor';
|
|
import {
|
|
cloneEditorState,
|
|
createEmptyEditorState,
|
|
EditorState,
|
|
editorStateHasDirtySelection,
|
|
} from './LexicalEditorState';
|
|
import {
|
|
$garbageCollectDetachedDecorators,
|
|
$garbageCollectDetachedNodes,
|
|
} from './LexicalGC';
|
|
import {initMutationObserver} from './LexicalMutations';
|
|
import {$normalizeTextNode} from './LexicalNormalization';
|
|
import {$reconcileRoot} from './LexicalReconciler';
|
|
import {
|
|
$internalCreateSelection,
|
|
$isNodeSelection,
|
|
$isRangeSelection,
|
|
applySelectionTransforms,
|
|
updateDOMSelection,
|
|
} from './LexicalSelection';
|
|
import {
|
|
$getCompositionKey,
|
|
getDOMSelection,
|
|
getEditorPropertyFromDOMNode,
|
|
getEditorStateTextContent,
|
|
getEditorsToPropagate,
|
|
getRegisteredNodeOrThrow,
|
|
isLexicalEditor,
|
|
removeDOMBlockCursorElement,
|
|
scheduleMicroTask,
|
|
updateDOMBlockCursorElement,
|
|
} from './LexicalUtils';
|
|
|
|
let activeEditorState: null | EditorState = null;
|
|
let activeEditor: null | LexicalEditor = null;
|
|
let isReadOnlyMode = false;
|
|
let isAttemptingToRecoverFromReconcilerError = false;
|
|
let infiniteTransformCount = 0;
|
|
|
|
const observerOptions = {
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true,
|
|
};
|
|
|
|
export function isCurrentlyReadOnlyMode(): boolean {
|
|
return (
|
|
isReadOnlyMode ||
|
|
(activeEditorState !== null && activeEditorState._readOnly)
|
|
);
|
|
}
|
|
|
|
export function errorOnReadOnly(): void {
|
|
if (isReadOnlyMode) {
|
|
invariant(false, 'Cannot use method in read-only mode.');
|
|
}
|
|
}
|
|
|
|
export function errorOnInfiniteTransforms(): void {
|
|
if (infiniteTransformCount > 99) {
|
|
invariant(
|
|
false,
|
|
'One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function getActiveEditorState(): EditorState {
|
|
if (activeEditorState === null) {
|
|
invariant(
|
|
false,
|
|
'Unable to find an active editor state. ' +
|
|
'State helpers or node methods can only be used ' +
|
|
'synchronously during the callback of ' +
|
|
'editor.update(), editor.read(), or editorState.read().%s',
|
|
collectBuildInformation(),
|
|
);
|
|
}
|
|
|
|
return activeEditorState;
|
|
}
|
|
|
|
export function getActiveEditor(): LexicalEditor {
|
|
if (activeEditor === null) {
|
|
invariant(
|
|
false,
|
|
'Unable to find an active editor. ' +
|
|
'This method can only be used ' +
|
|
'synchronously during the callback of ' +
|
|
'editor.update() or editor.read().%s',
|
|
collectBuildInformation(),
|
|
);
|
|
}
|
|
return activeEditor;
|
|
}
|
|
|
|
function collectBuildInformation(): string {
|
|
let compatibleEditors = 0;
|
|
const incompatibleEditors = new Set<string>();
|
|
const thisVersion = LexicalEditor.version;
|
|
if (typeof window !== 'undefined') {
|
|
for (const node of document.querySelectorAll('[contenteditable]')) {
|
|
const editor = getEditorPropertyFromDOMNode(node);
|
|
if (isLexicalEditor(editor)) {
|
|
compatibleEditors++;
|
|
} else if (editor) {
|
|
let version = String(
|
|
(
|
|
editor.constructor as typeof editor['constructor'] &
|
|
Record<string, unknown>
|
|
).version || '<0.17.1',
|
|
);
|
|
if (version === thisVersion) {
|
|
version +=
|
|
' (separately built, likely a bundler configuration issue)';
|
|
}
|
|
incompatibleEditors.add(version);
|
|
}
|
|
}
|
|
}
|
|
let output = ` Detected on the page: ${compatibleEditors} compatible editor(s) with version ${thisVersion}`;
|
|
if (incompatibleEditors.size) {
|
|
output += ` and incompatible editors with versions ${Array.from(
|
|
incompatibleEditors,
|
|
).join(', ')}`;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export function internalGetActiveEditor(): LexicalEditor | null {
|
|
return activeEditor;
|
|
}
|
|
|
|
export function internalGetActiveEditorState(): EditorState | null {
|
|
return activeEditorState;
|
|
}
|
|
|
|
export function $applyTransforms(
|
|
editor: LexicalEditor,
|
|
node: LexicalNode,
|
|
transformsCache: Map<string, Array<Transform<LexicalNode>>>,
|
|
) {
|
|
const type = node.__type;
|
|
const registeredNode = getRegisteredNodeOrThrow(editor, type);
|
|
let transformsArr = transformsCache.get(type);
|
|
|
|
if (transformsArr === undefined) {
|
|
transformsArr = Array.from(registeredNode.transforms);
|
|
transformsCache.set(type, transformsArr);
|
|
}
|
|
|
|
const transformsArrLength = transformsArr.length;
|
|
|
|
for (let i = 0; i < transformsArrLength; i++) {
|
|
transformsArr[i](node);
|
|
|
|
if (!node.isAttached()) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function $isNodeValidForTransform(
|
|
node: LexicalNode,
|
|
compositionKey: null | string,
|
|
): boolean {
|
|
return (
|
|
node !== undefined &&
|
|
// We don't want to transform nodes being composed
|
|
node.__key !== compositionKey &&
|
|
node.isAttached()
|
|
);
|
|
}
|
|
|
|
function $normalizeAllDirtyTextNodes(
|
|
editorState: EditorState,
|
|
editor: LexicalEditor,
|
|
): void {
|
|
const dirtyLeaves = editor._dirtyLeaves;
|
|
const nodeMap = editorState._nodeMap;
|
|
|
|
for (const nodeKey of dirtyLeaves) {
|
|
const node = nodeMap.get(nodeKey);
|
|
|
|
if (
|
|
$isTextNode(node) &&
|
|
node.isAttached() &&
|
|
node.isSimpleText() &&
|
|
!node.isUnmergeable()
|
|
) {
|
|
$normalizeTextNode(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transform heuristic:
|
|
* 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1.
|
|
* The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too.
|
|
* 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1.
|
|
* If element transforms only generate additional dirty elements we only repeat step 2.
|
|
*
|
|
* Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and
|
|
* editor._subtrees which we reset in every loop.
|
|
*/
|
|
function $applyAllTransforms(
|
|
editorState: EditorState,
|
|
editor: LexicalEditor,
|
|
): void {
|
|
const dirtyLeaves = editor._dirtyLeaves;
|
|
const dirtyElements = editor._dirtyElements;
|
|
const nodeMap = editorState._nodeMap;
|
|
const compositionKey = $getCompositionKey();
|
|
const transformsCache = new Map();
|
|
|
|
let untransformedDirtyLeaves = dirtyLeaves;
|
|
let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
|
let untransformedDirtyElements = dirtyElements;
|
|
let untransformedDirtyElementsLength = untransformedDirtyElements.size;
|
|
|
|
while (
|
|
untransformedDirtyLeavesLength > 0 ||
|
|
untransformedDirtyElementsLength > 0
|
|
) {
|
|
if (untransformedDirtyLeavesLength > 0) {
|
|
// We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms
|
|
editor._dirtyLeaves = new Set();
|
|
|
|
for (const nodeKey of untransformedDirtyLeaves) {
|
|
const node = nodeMap.get(nodeKey);
|
|
|
|
if (
|
|
$isTextNode(node) &&
|
|
node.isAttached() &&
|
|
node.isSimpleText() &&
|
|
!node.isUnmergeable()
|
|
) {
|
|
$normalizeTextNode(node);
|
|
}
|
|
|
|
if (
|
|
node !== undefined &&
|
|
$isNodeValidForTransform(node, compositionKey)
|
|
) {
|
|
$applyTransforms(editor, node, transformsCache);
|
|
}
|
|
|
|
dirtyLeaves.add(nodeKey);
|
|
}
|
|
|
|
untransformedDirtyLeaves = editor._dirtyLeaves;
|
|
untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
|
|
|
// We want to prioritize node transforms over element transforms
|
|
if (untransformedDirtyLeavesLength > 0) {
|
|
infiniteTransformCount++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// All dirty leaves have been processed. Let's do elements!
|
|
// We have previously processed dirty leaves, so let's restart the editor leaves Set to track
|
|
// new ones caused by element transforms
|
|
editor._dirtyLeaves = new Set();
|
|
editor._dirtyElements = new Map();
|
|
|
|
for (const currentUntransformedDirtyElement of untransformedDirtyElements) {
|
|
const nodeKey = currentUntransformedDirtyElement[0];
|
|
const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1];
|
|
if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) {
|
|
continue;
|
|
}
|
|
|
|
const node = nodeMap.get(nodeKey);
|
|
|
|
if (
|
|
node !== undefined &&
|
|
$isNodeValidForTransform(node, compositionKey)
|
|
) {
|
|
$applyTransforms(editor, node, transformsCache);
|
|
}
|
|
|
|
dirtyElements.set(nodeKey, intentionallyMarkedAsDirty);
|
|
}
|
|
|
|
untransformedDirtyLeaves = editor._dirtyLeaves;
|
|
untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
|
untransformedDirtyElements = editor._dirtyElements;
|
|
untransformedDirtyElementsLength = untransformedDirtyElements.size;
|
|
infiniteTransformCount++;
|
|
}
|
|
|
|
editor._dirtyLeaves = dirtyLeaves;
|
|
editor._dirtyElements = dirtyElements;
|
|
}
|
|
|
|
type InternalSerializedNode = {
|
|
children?: Array<InternalSerializedNode>;
|
|
type: string;
|
|
version: number;
|
|
};
|
|
|
|
export function $parseSerializedNode(
|
|
serializedNode: SerializedLexicalNode,
|
|
): LexicalNode {
|
|
const internalSerializedNode: InternalSerializedNode = serializedNode;
|
|
return $parseSerializedNodeImpl(
|
|
internalSerializedNode,
|
|
getActiveEditor()._nodes,
|
|
);
|
|
}
|
|
|
|
function $parseSerializedNodeImpl<
|
|
SerializedNode extends InternalSerializedNode,
|
|
>(
|
|
serializedNode: SerializedNode,
|
|
registeredNodes: RegisteredNodes,
|
|
): LexicalNode {
|
|
const type = serializedNode.type;
|
|
const registeredNode = registeredNodes.get(type);
|
|
|
|
if (registeredNode === undefined) {
|
|
invariant(false, 'parseEditorState: type "%s" + not found', type);
|
|
}
|
|
|
|
const nodeClass = registeredNode.klass;
|
|
|
|
if (serializedNode.type !== nodeClass.getType()) {
|
|
invariant(
|
|
false,
|
|
'LexicalNode: Node %s does not implement .importJSON().',
|
|
nodeClass.name,
|
|
);
|
|
}
|
|
|
|
const node = nodeClass.importJSON(serializedNode);
|
|
const children = serializedNode.children;
|
|
|
|
if ($isElementNode(node) && Array.isArray(children)) {
|
|
for (let i = 0; i < children.length; i++) {
|
|
const serializedJSONChildNode = children[i];
|
|
const childNode = $parseSerializedNodeImpl(
|
|
serializedJSONChildNode,
|
|
registeredNodes,
|
|
);
|
|
node.append(childNode);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
export function parseEditorState(
|
|
serializedEditorState: SerializedEditorState,
|
|
editor: LexicalEditor,
|
|
updateFn: void | (() => void),
|
|
): EditorState {
|
|
const editorState = createEmptyEditorState();
|
|
const previousActiveEditorState = activeEditorState;
|
|
const previousReadOnlyMode = isReadOnlyMode;
|
|
const previousActiveEditor = activeEditor;
|
|
const previousDirtyElements = editor._dirtyElements;
|
|
const previousDirtyLeaves = editor._dirtyLeaves;
|
|
const previousCloneNotNeeded = editor._cloneNotNeeded;
|
|
const previousDirtyType = editor._dirtyType;
|
|
editor._dirtyElements = new Map();
|
|
editor._dirtyLeaves = new Set();
|
|
editor._cloneNotNeeded = new Set();
|
|
editor._dirtyType = 0;
|
|
activeEditorState = editorState;
|
|
isReadOnlyMode = false;
|
|
activeEditor = editor;
|
|
|
|
try {
|
|
const registeredNodes = editor._nodes;
|
|
const serializedNode = serializedEditorState.root;
|
|
$parseSerializedNodeImpl(serializedNode, registeredNodes);
|
|
if (updateFn) {
|
|
updateFn();
|
|
}
|
|
|
|
// Make the editorState immutable
|
|
editorState._readOnly = true;
|
|
|
|
if (__DEV__) {
|
|
handleDEVOnlyPendingUpdateGuarantees(editorState);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
editor._onError(error);
|
|
}
|
|
} finally {
|
|
editor._dirtyElements = previousDirtyElements;
|
|
editor._dirtyLeaves = previousDirtyLeaves;
|
|
editor._cloneNotNeeded = previousCloneNotNeeded;
|
|
editor._dirtyType = previousDirtyType;
|
|
activeEditorState = previousActiveEditorState;
|
|
isReadOnlyMode = previousReadOnlyMode;
|
|
activeEditor = previousActiveEditor;
|
|
}
|
|
|
|
return editorState;
|
|
}
|
|
|
|
// This technically isn't an update but given we need
|
|
// exposure to the module's active bindings, we have this
|
|
// function here
|
|
|
|
export function readEditorState<V>(
|
|
editor: LexicalEditor | null,
|
|
editorState: EditorState,
|
|
callbackFn: () => V,
|
|
): V {
|
|
const previousActiveEditorState = activeEditorState;
|
|
const previousReadOnlyMode = isReadOnlyMode;
|
|
const previousActiveEditor = activeEditor;
|
|
|
|
activeEditorState = editorState;
|
|
isReadOnlyMode = true;
|
|
activeEditor = editor;
|
|
|
|
try {
|
|
return callbackFn();
|
|
} finally {
|
|
activeEditorState = previousActiveEditorState;
|
|
isReadOnlyMode = previousReadOnlyMode;
|
|
activeEditor = previousActiveEditor;
|
|
}
|
|
}
|
|
|
|
function handleDEVOnlyPendingUpdateGuarantees(
|
|
pendingEditorState: EditorState,
|
|
): void {
|
|
// Given we can't Object.freeze the nodeMap as it's a Map,
|
|
// we instead replace its set, clear and delete methods.
|
|
const nodeMap = pendingEditorState._nodeMap;
|
|
|
|
nodeMap.set = () => {
|
|
throw new Error('Cannot call set() on a frozen Lexical node map');
|
|
};
|
|
|
|
nodeMap.clear = () => {
|
|
throw new Error('Cannot call clear() on a frozen Lexical node map');
|
|
};
|
|
|
|
nodeMap.delete = () => {
|
|
throw new Error('Cannot call delete() on a frozen Lexical node map');
|
|
};
|
|
}
|
|
|
|
export function $commitPendingUpdates(
|
|
editor: LexicalEditor,
|
|
recoveryEditorState?: EditorState,
|
|
): void {
|
|
const pendingEditorState = editor._pendingEditorState;
|
|
const rootElement = editor._rootElement;
|
|
const shouldSkipDOM = editor._headless || rootElement === null;
|
|
|
|
if (pendingEditorState === null) {
|
|
return;
|
|
}
|
|
|
|
// ======
|
|
// Reconciliation has started.
|
|
// ======
|
|
|
|
const currentEditorState = editor._editorState;
|
|
const currentSelection = currentEditorState._selection;
|
|
const pendingSelection = pendingEditorState._selection;
|
|
const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES;
|
|
const previousActiveEditorState = activeEditorState;
|
|
const previousReadOnlyMode = isReadOnlyMode;
|
|
const previousActiveEditor = activeEditor;
|
|
const previouslyUpdating = editor._updating;
|
|
const observer = editor._observer;
|
|
let mutatedNodes = null;
|
|
editor._pendingEditorState = null;
|
|
editor._editorState = pendingEditorState;
|
|
|
|
if (!shouldSkipDOM && needsUpdate && observer !== null) {
|
|
activeEditor = editor;
|
|
activeEditorState = pendingEditorState;
|
|
isReadOnlyMode = false;
|
|
// We don't want updates to sync block the reconciliation.
|
|
editor._updating = true;
|
|
try {
|
|
const dirtyType = editor._dirtyType;
|
|
const dirtyElements = editor._dirtyElements;
|
|
const dirtyLeaves = editor._dirtyLeaves;
|
|
observer.disconnect();
|
|
|
|
mutatedNodes = $reconcileRoot(
|
|
currentEditorState,
|
|
pendingEditorState,
|
|
editor,
|
|
dirtyType,
|
|
dirtyElements,
|
|
dirtyLeaves,
|
|
);
|
|
} catch (error) {
|
|
// Report errors
|
|
if (error instanceof Error) {
|
|
editor._onError(error);
|
|
}
|
|
|
|
// Reset editor and restore incoming editor state to the DOM
|
|
if (!isAttemptingToRecoverFromReconcilerError) {
|
|
resetEditor(editor, null, rootElement, pendingEditorState);
|
|
initMutationObserver(editor);
|
|
editor._dirtyType = FULL_RECONCILE;
|
|
isAttemptingToRecoverFromReconcilerError = true;
|
|
$commitPendingUpdates(editor, currentEditorState);
|
|
isAttemptingToRecoverFromReconcilerError = false;
|
|
} else {
|
|
// To avoid a possible situation of infinite loops, lets throw
|
|
throw error;
|
|
}
|
|
|
|
return;
|
|
} finally {
|
|
observer.observe(rootElement as Node, observerOptions);
|
|
editor._updating = previouslyUpdating;
|
|
activeEditorState = previousActiveEditorState;
|
|
isReadOnlyMode = previousReadOnlyMode;
|
|
activeEditor = previousActiveEditor;
|
|
}
|
|
}
|
|
|
|
if (!pendingEditorState._readOnly) {
|
|
pendingEditorState._readOnly = true;
|
|
if (__DEV__) {
|
|
handleDEVOnlyPendingUpdateGuarantees(pendingEditorState);
|
|
if ($isRangeSelection(pendingSelection)) {
|
|
Object.freeze(pendingSelection.anchor);
|
|
Object.freeze(pendingSelection.focus);
|
|
}
|
|
Object.freeze(pendingSelection);
|
|
}
|
|
}
|
|
|
|
const dirtyLeaves = editor._dirtyLeaves;
|
|
const dirtyElements = editor._dirtyElements;
|
|
const normalizedNodes = editor._normalizedNodes;
|
|
const tags = editor._updateTags;
|
|
const deferred = editor._deferred;
|
|
const nodeCount = pendingEditorState._nodeMap.size;
|
|
|
|
if (needsUpdate) {
|
|
editor._dirtyType = NO_DIRTY_NODES;
|
|
editor._cloneNotNeeded.clear();
|
|
editor._dirtyLeaves = new Set();
|
|
editor._dirtyElements = new Map();
|
|
editor._normalizedNodes = new Set();
|
|
editor._updateTags = new Set();
|
|
}
|
|
$garbageCollectDetachedDecorators(editor, pendingEditorState);
|
|
|
|
// ======
|
|
// Reconciliation has finished. Now update selection and trigger listeners.
|
|
// ======
|
|
|
|
const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window);
|
|
|
|
// Attempt to update the DOM selection, including focusing of the root element,
|
|
// and scroll into view if needed.
|
|
if (
|
|
editor._editable &&
|
|
// domSelection will be null in headless
|
|
domSelection !== null &&
|
|
(needsUpdate || pendingSelection === null || pendingSelection.dirty)
|
|
) {
|
|
activeEditor = editor;
|
|
activeEditorState = pendingEditorState;
|
|
try {
|
|
if (observer !== null) {
|
|
observer.disconnect();
|
|
}
|
|
if (needsUpdate || pendingSelection === null || pendingSelection.dirty) {
|
|
const blockCursorElement = editor._blockCursorElement;
|
|
if (blockCursorElement !== null) {
|
|
removeDOMBlockCursorElement(
|
|
blockCursorElement,
|
|
editor,
|
|
rootElement as HTMLElement,
|
|
);
|
|
}
|
|
updateDOMSelection(
|
|
currentSelection,
|
|
pendingSelection,
|
|
editor,
|
|
domSelection,
|
|
tags,
|
|
rootElement as HTMLElement,
|
|
nodeCount,
|
|
);
|
|
}
|
|
updateDOMBlockCursorElement(
|
|
editor,
|
|
rootElement as HTMLElement,
|
|
pendingSelection,
|
|
);
|
|
if (observer !== null) {
|
|
observer.observe(rootElement as Node, observerOptions);
|
|
}
|
|
} finally {
|
|
activeEditor = previousActiveEditor;
|
|
activeEditorState = previousActiveEditorState;
|
|
}
|
|
}
|
|
|
|
if (mutatedNodes !== null) {
|
|
triggerMutationListeners(
|
|
editor,
|
|
mutatedNodes,
|
|
tags,
|
|
dirtyLeaves,
|
|
currentEditorState,
|
|
);
|
|
}
|
|
if (
|
|
!$isRangeSelection(pendingSelection) &&
|
|
pendingSelection !== null &&
|
|
(currentSelection === null || !currentSelection.is(pendingSelection))
|
|
) {
|
|
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
|
}
|
|
/**
|
|
* Capture pendingDecorators after garbage collecting detached decorators
|
|
*/
|
|
const pendingDecorators = editor._pendingDecorators;
|
|
if (pendingDecorators !== null) {
|
|
editor._decorators = pendingDecorators;
|
|
editor._pendingDecorators = null;
|
|
triggerListeners('decorator', editor, true, pendingDecorators);
|
|
}
|
|
|
|
// If reconciler fails, we reset whole editor (so current editor state becomes empty)
|
|
// and attempt to re-render pendingEditorState. If that goes through we trigger
|
|
// listeners, but instead use recoverEditorState which is current editor state before reset
|
|
// This specifically important for collab that relies on prevEditorState from update
|
|
// listener to calculate delta of changed nodes/properties
|
|
triggerTextContentListeners(
|
|
editor,
|
|
recoveryEditorState || currentEditorState,
|
|
pendingEditorState,
|
|
);
|
|
triggerListeners('update', editor, true, {
|
|
dirtyElements,
|
|
dirtyLeaves,
|
|
editorState: pendingEditorState,
|
|
normalizedNodes,
|
|
prevEditorState: recoveryEditorState || currentEditorState,
|
|
tags,
|
|
});
|
|
triggerDeferredUpdateCallbacks(editor, deferred);
|
|
$triggerEnqueuedUpdates(editor);
|
|
}
|
|
|
|
function triggerTextContentListeners(
|
|
editor: LexicalEditor,
|
|
currentEditorState: EditorState,
|
|
pendingEditorState: EditorState,
|
|
): void {
|
|
const currentTextContent = getEditorStateTextContent(currentEditorState);
|
|
const latestTextContent = getEditorStateTextContent(pendingEditorState);
|
|
|
|
if (currentTextContent !== latestTextContent) {
|
|
triggerListeners('textcontent', editor, true, latestTextContent);
|
|
}
|
|
}
|
|
|
|
function triggerMutationListeners(
|
|
editor: LexicalEditor,
|
|
mutatedNodes: MutatedNodes,
|
|
updateTags: Set<string>,
|
|
dirtyLeaves: Set<string>,
|
|
prevEditorState: EditorState,
|
|
): void {
|
|
const listeners = Array.from(editor._listeners.mutation);
|
|
const listenersLength = listeners.length;
|
|
|
|
for (let i = 0; i < listenersLength; i++) {
|
|
const [listener, klass] = listeners[i];
|
|
const mutatedNodesByType = mutatedNodes.get(klass);
|
|
if (mutatedNodesByType !== undefined) {
|
|
listener(mutatedNodesByType, {
|
|
dirtyLeaves,
|
|
prevEditorState,
|
|
updateTags,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function triggerListeners(
|
|
type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable',
|
|
editor: LexicalEditor,
|
|
isCurrentlyEnqueuingUpdates: boolean,
|
|
...payload: unknown[]
|
|
): void {
|
|
const previouslyUpdating = editor._updating;
|
|
editor._updating = isCurrentlyEnqueuingUpdates;
|
|
|
|
try {
|
|
const listeners = Array.from<Listener>(editor._listeners[type]);
|
|
for (let i = 0; i < listeners.length; i++) {
|
|
// @ts-ignore
|
|
listeners[i].apply(null, payload);
|
|
}
|
|
} finally {
|
|
editor._updating = previouslyUpdating;
|
|
}
|
|
}
|
|
|
|
export function triggerCommandListeners<
|
|
TCommand extends LexicalCommand<unknown>,
|
|
>(
|
|
editor: LexicalEditor,
|
|
type: TCommand,
|
|
payload: CommandPayloadType<TCommand>,
|
|
): boolean {
|
|
if (editor._updating === false || activeEditor !== editor) {
|
|
let returnVal = false;
|
|
editor.update(() => {
|
|
returnVal = triggerCommandListeners(editor, type, payload);
|
|
});
|
|
return returnVal;
|
|
}
|
|
|
|
const editors = getEditorsToPropagate(editor);
|
|
|
|
for (let i = 4; i >= 0; i--) {
|
|
for (let e = 0; e < editors.length; e++) {
|
|
const currentEditor = editors[e];
|
|
const commandListeners = currentEditor._commands;
|
|
const listenerInPriorityOrder = commandListeners.get(type);
|
|
|
|
if (listenerInPriorityOrder !== undefined) {
|
|
const listenersSet = listenerInPriorityOrder[i];
|
|
|
|
if (listenersSet !== undefined) {
|
|
const listeners = Array.from(listenersSet);
|
|
const listenersLength = listeners.length;
|
|
|
|
for (let j = 0; j < listenersLength; j++) {
|
|
if (listeners[j](payload, editor) === true) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function $triggerEnqueuedUpdates(editor: LexicalEditor): void {
|
|
const queuedUpdates = editor._updates;
|
|
|
|
if (queuedUpdates.length !== 0) {
|
|
const queuedUpdate = queuedUpdates.shift();
|
|
if (queuedUpdate) {
|
|
const [updateFn, options] = queuedUpdate;
|
|
$beginUpdate(editor, updateFn, options);
|
|
}
|
|
}
|
|
}
|
|
|
|
function triggerDeferredUpdateCallbacks(
|
|
editor: LexicalEditor,
|
|
deferred: Array<() => void>,
|
|
): void {
|
|
editor._deferred = [];
|
|
|
|
if (deferred.length !== 0) {
|
|
const previouslyUpdating = editor._updating;
|
|
editor._updating = true;
|
|
|
|
try {
|
|
for (let i = 0; i < deferred.length; i++) {
|
|
deferred[i]();
|
|
}
|
|
} finally {
|
|
editor._updating = previouslyUpdating;
|
|
}
|
|
}
|
|
}
|
|
|
|
function processNestedUpdates(
|
|
editor: LexicalEditor,
|
|
initialSkipTransforms?: boolean,
|
|
): boolean {
|
|
const queuedUpdates = editor._updates;
|
|
let skipTransforms = initialSkipTransforms || false;
|
|
|
|
// Updates might grow as we process them, we so we'll need
|
|
// to handle each update as we go until the updates array is
|
|
// empty.
|
|
while (queuedUpdates.length !== 0) {
|
|
const queuedUpdate = queuedUpdates.shift();
|
|
if (queuedUpdate) {
|
|
const [nextUpdateFn, options] = queuedUpdate;
|
|
|
|
let onUpdate;
|
|
let tag;
|
|
|
|
if (options !== undefined) {
|
|
onUpdate = options.onUpdate;
|
|
tag = options.tag;
|
|
|
|
if (options.skipTransforms) {
|
|
skipTransforms = true;
|
|
}
|
|
if (options.discrete) {
|
|
const pendingEditorState = editor._pendingEditorState;
|
|
invariant(
|
|
pendingEditorState !== null,
|
|
'Unexpected empty pending editor state on discrete nested update',
|
|
);
|
|
pendingEditorState._flushSync = true;
|
|
}
|
|
|
|
if (onUpdate) {
|
|
editor._deferred.push(onUpdate);
|
|
}
|
|
|
|
if (tag) {
|
|
editor._updateTags.add(tag);
|
|
}
|
|
}
|
|
|
|
nextUpdateFn();
|
|
}
|
|
}
|
|
|
|
return skipTransforms;
|
|
}
|
|
|
|
function $beginUpdate(
|
|
editor: LexicalEditor,
|
|
updateFn: () => void,
|
|
options?: EditorUpdateOptions,
|
|
): void {
|
|
const updateTags = editor._updateTags;
|
|
let onUpdate;
|
|
let tag;
|
|
let skipTransforms = false;
|
|
let discrete = false;
|
|
|
|
if (options !== undefined) {
|
|
onUpdate = options.onUpdate;
|
|
tag = options.tag;
|
|
|
|
if (tag != null) {
|
|
updateTags.add(tag);
|
|
}
|
|
|
|
skipTransforms = options.skipTransforms || false;
|
|
discrete = options.discrete || false;
|
|
}
|
|
|
|
if (onUpdate) {
|
|
editor._deferred.push(onUpdate);
|
|
}
|
|
|
|
const currentEditorState = editor._editorState;
|
|
let pendingEditorState = editor._pendingEditorState;
|
|
let editorStateWasCloned = false;
|
|
|
|
if (pendingEditorState === null || pendingEditorState._readOnly) {
|
|
pendingEditorState = editor._pendingEditorState = cloneEditorState(
|
|
pendingEditorState || currentEditorState,
|
|
);
|
|
editorStateWasCloned = true;
|
|
}
|
|
pendingEditorState._flushSync = discrete;
|
|
|
|
const previousActiveEditorState = activeEditorState;
|
|
const previousReadOnlyMode = isReadOnlyMode;
|
|
const previousActiveEditor = activeEditor;
|
|
const previouslyUpdating = editor._updating;
|
|
activeEditorState = pendingEditorState;
|
|
isReadOnlyMode = false;
|
|
editor._updating = true;
|
|
activeEditor = editor;
|
|
|
|
try {
|
|
if (editorStateWasCloned) {
|
|
if (editor._headless) {
|
|
if (currentEditorState._selection !== null) {
|
|
pendingEditorState._selection = currentEditorState._selection.clone();
|
|
}
|
|
} else {
|
|
pendingEditorState._selection = $internalCreateSelection(editor);
|
|
}
|
|
}
|
|
|
|
const startingCompositionKey = editor._compositionKey;
|
|
updateFn();
|
|
skipTransforms = processNestedUpdates(editor, skipTransforms);
|
|
applySelectionTransforms(pendingEditorState, editor);
|
|
|
|
if (editor._dirtyType !== NO_DIRTY_NODES) {
|
|
if (skipTransforms) {
|
|
$normalizeAllDirtyTextNodes(pendingEditorState, editor);
|
|
} else {
|
|
$applyAllTransforms(pendingEditorState, editor);
|
|
}
|
|
|
|
processNestedUpdates(editor);
|
|
$garbageCollectDetachedNodes(
|
|
currentEditorState,
|
|
pendingEditorState,
|
|
editor._dirtyLeaves,
|
|
editor._dirtyElements,
|
|
);
|
|
}
|
|
|
|
const endingCompositionKey = editor._compositionKey;
|
|
|
|
if (startingCompositionKey !== endingCompositionKey) {
|
|
pendingEditorState._flushSync = true;
|
|
}
|
|
|
|
const pendingSelection = pendingEditorState._selection;
|
|
|
|
if ($isRangeSelection(pendingSelection)) {
|
|
const pendingNodeMap = pendingEditorState._nodeMap;
|
|
const anchorKey = pendingSelection.anchor.key;
|
|
const focusKey = pendingSelection.focus.key;
|
|
|
|
if (
|
|
pendingNodeMap.get(anchorKey) === undefined ||
|
|
pendingNodeMap.get(focusKey) === undefined
|
|
) {
|
|
invariant(
|
|
false,
|
|
'updateEditor: selection has been lost because the previously selected nodes have been removed and ' +
|
|
"selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
|
|
);
|
|
}
|
|
} else if ($isNodeSelection(pendingSelection)) {
|
|
// TODO: we should also validate node selection?
|
|
if (pendingSelection._nodes.size === 0) {
|
|
pendingEditorState._selection = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Report errors
|
|
if (error instanceof Error) {
|
|
editor._onError(error);
|
|
}
|
|
|
|
// Restore existing editor state to the DOM
|
|
editor._pendingEditorState = currentEditorState;
|
|
editor._dirtyType = FULL_RECONCILE;
|
|
|
|
editor._cloneNotNeeded.clear();
|
|
|
|
editor._dirtyLeaves = new Set();
|
|
|
|
editor._dirtyElements.clear();
|
|
|
|
$commitPendingUpdates(editor);
|
|
return;
|
|
} finally {
|
|
activeEditorState = previousActiveEditorState;
|
|
isReadOnlyMode = previousReadOnlyMode;
|
|
activeEditor = previousActiveEditor;
|
|
editor._updating = previouslyUpdating;
|
|
infiniteTransformCount = 0;
|
|
}
|
|
|
|
const shouldUpdate =
|
|
editor._dirtyType !== NO_DIRTY_NODES ||
|
|
editorStateHasDirtySelection(pendingEditorState, editor);
|
|
|
|
if (shouldUpdate) {
|
|
if (pendingEditorState._flushSync) {
|
|
pendingEditorState._flushSync = false;
|
|
$commitPendingUpdates(editor);
|
|
} else if (editorStateWasCloned) {
|
|
scheduleMicroTask(() => {
|
|
$commitPendingUpdates(editor);
|
|
});
|
|
}
|
|
} else {
|
|
pendingEditorState._flushSync = false;
|
|
|
|
if (editorStateWasCloned) {
|
|
updateTags.clear();
|
|
editor._deferred = [];
|
|
editor._pendingEditorState = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updateEditor(
|
|
editor: LexicalEditor,
|
|
updateFn: () => void,
|
|
options?: EditorUpdateOptions,
|
|
): void {
|
|
if (editor._updating) {
|
|
editor._updates.push([updateFn, options]);
|
|
} else {
|
|
$beginUpdate(editor, updateFn, options);
|
|
}
|
|
}
|