mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-19 18:22:16 +03:00
Code in lists could throw error on parse due to inner <code> tag being parsed but not actually used within a <pre>, so this updates the importDOM to disregard childdren for code blocks. This also improves the invariant implementation to not be so dev/debugger based, and to include vars in the output.
1243 lines
37 KiB
TypeScript
1243 lines
37 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.
|
|
*
|
|
*/
|
|
|
|
/* eslint-disable no-constant-condition */
|
|
import type {EditorConfig, LexicalEditor} from './LexicalEditor';
|
|
import type {BaseSelection, RangeSelection} from './LexicalSelection';
|
|
import type {Klass, KlassConstructor} from 'lexical';
|
|
|
|
import invariant from 'lexical/shared/invariant';
|
|
|
|
import {
|
|
$createParagraphNode,
|
|
$isDecoratorNode,
|
|
$isElementNode,
|
|
$isRootNode,
|
|
$isTextNode,
|
|
type DecoratorNode,
|
|
ElementNode,
|
|
} from '.';
|
|
import {
|
|
$getSelection,
|
|
$isNodeSelection,
|
|
$isRangeSelection,
|
|
$moveSelectionPointToEnd,
|
|
$updateElementSelectionOnCreateDeleteNode,
|
|
moveSelectionPointToSibling,
|
|
} from './LexicalSelection';
|
|
import {
|
|
errorOnReadOnly,
|
|
getActiveEditor,
|
|
getActiveEditorState,
|
|
} from './LexicalUpdates';
|
|
import {
|
|
$cloneWithProperties,
|
|
$getCompositionKey,
|
|
$getNodeByKey,
|
|
$isRootOrShadowRoot,
|
|
$maybeMoveChildrenSelectionToParent,
|
|
$setCompositionKey,
|
|
$setNodeKey,
|
|
$setSelection,
|
|
errorOnInsertTextNodeOnRoot,
|
|
internalMarkNodeAsDirty,
|
|
removeFromParent,
|
|
} from './LexicalUtils';
|
|
|
|
export type NodeMap = Map<NodeKey, LexicalNode>;
|
|
|
|
export type SerializedLexicalNode = {
|
|
type: string;
|
|
version: number;
|
|
};
|
|
|
|
export function $removeNode(
|
|
nodeToRemove: LexicalNode,
|
|
restoreSelection: boolean,
|
|
preserveEmptyParent?: boolean,
|
|
): void {
|
|
errorOnReadOnly();
|
|
const key = nodeToRemove.__key;
|
|
const parent = nodeToRemove.getParent();
|
|
if (parent === null) {
|
|
return;
|
|
}
|
|
const selection = $maybeMoveChildrenSelectionToParent(nodeToRemove);
|
|
let selectionMoved = false;
|
|
if ($isRangeSelection(selection) && restoreSelection) {
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
if (anchor.key === key) {
|
|
moveSelectionPointToSibling(
|
|
anchor,
|
|
nodeToRemove,
|
|
parent,
|
|
nodeToRemove.getPreviousSibling(),
|
|
nodeToRemove.getNextSibling(),
|
|
);
|
|
selectionMoved = true;
|
|
}
|
|
if (focus.key === key) {
|
|
moveSelectionPointToSibling(
|
|
focus,
|
|
nodeToRemove,
|
|
parent,
|
|
nodeToRemove.getPreviousSibling(),
|
|
nodeToRemove.getNextSibling(),
|
|
);
|
|
selectionMoved = true;
|
|
}
|
|
} else if (
|
|
$isNodeSelection(selection) &&
|
|
restoreSelection &&
|
|
nodeToRemove.isSelected()
|
|
) {
|
|
nodeToRemove.selectPrevious();
|
|
}
|
|
|
|
if ($isRangeSelection(selection) && restoreSelection && !selectionMoved) {
|
|
// Doing this is O(n) so lets avoid it unless we need to do it
|
|
const index = nodeToRemove.getIndexWithinParent();
|
|
removeFromParent(nodeToRemove);
|
|
$updateElementSelectionOnCreateDeleteNode(selection, parent, index, -1);
|
|
} else {
|
|
removeFromParent(nodeToRemove);
|
|
}
|
|
|
|
if (
|
|
!preserveEmptyParent &&
|
|
!$isRootOrShadowRoot(parent) &&
|
|
!parent.canBeEmpty() &&
|
|
parent.isEmpty()
|
|
) {
|
|
$removeNode(parent, restoreSelection);
|
|
}
|
|
if (restoreSelection && $isRootNode(parent) && parent.isEmpty()) {
|
|
parent.selectEnd();
|
|
}
|
|
}
|
|
|
|
export type DOMConversion<T extends HTMLElement = HTMLElement> = {
|
|
conversion: DOMConversionFn<T>;
|
|
priority?: 0 | 1 | 2 | 3 | 4;
|
|
};
|
|
|
|
export type DOMConversionFn<T extends HTMLElement = HTMLElement> = (
|
|
element: T,
|
|
) => DOMConversionOutput | null;
|
|
|
|
export type DOMChildConversion = (
|
|
lexicalNode: LexicalNode,
|
|
parentLexicalNode: LexicalNode | null | undefined,
|
|
) => LexicalNode | null | undefined;
|
|
|
|
export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
|
|
NodeName,
|
|
(node: T) => DOMConversion<T> | null
|
|
>;
|
|
type NodeName = string;
|
|
|
|
/**
|
|
* Output for a DOM conversion.
|
|
* Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode
|
|
* including all its children.
|
|
*
|
|
* You can specify a function to run for each converted child (forChild) or on all
|
|
* the child nodes after the conversion is complete (after).
|
|
* The key difference here is that forChild runs for every deeply nested child node
|
|
* of the current node, whereas after will run only once after the
|
|
* transformation of the node and all its children is complete.
|
|
*/
|
|
export type DOMConversionOutput = {
|
|
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
|
|
forChild?: DOMChildConversion;
|
|
node: null | LexicalNode | Array<LexicalNode> | 'ignore';
|
|
};
|
|
|
|
export type DOMExportOutputMap = Map<
|
|
Klass<LexicalNode>,
|
|
(editor: LexicalEditor, target: LexicalNode) => DOMExportOutput
|
|
>;
|
|
|
|
export type DOMExportOutput = {
|
|
after?: (
|
|
generatedElement: HTMLElement | Text | null | undefined,
|
|
) => HTMLElement | Text | null | undefined;
|
|
element: HTMLElement | Text | null;
|
|
};
|
|
|
|
export type NodeKey = string;
|
|
|
|
export class LexicalNode {
|
|
// Allow us to look up the type including static props
|
|
['constructor']!: KlassConstructor<typeof LexicalNode>;
|
|
/** @internal */
|
|
__type: string;
|
|
/** @internal */
|
|
//@ts-ignore We set the key in the constructor.
|
|
__key: string;
|
|
/** @internal */
|
|
__parent: null | NodeKey;
|
|
/** @internal */
|
|
__prev: null | NodeKey;
|
|
/** @internal */
|
|
__next: null | NodeKey;
|
|
|
|
// Flow doesn't support abstract classes unfortunately, so we can't _force_
|
|
// subclasses of Node to implement statics. All subclasses of Node should have
|
|
// a static getType and clone method though. We define getType and clone here so we can call it
|
|
// on any Node, and we throw this error by default since the subclass should provide
|
|
// their own implementation.
|
|
/**
|
|
* Returns the string type of this node. Every node must
|
|
* implement this and it MUST BE UNIQUE amongst nodes registered
|
|
* on the editor.
|
|
*
|
|
*/
|
|
static getType(): string {
|
|
invariant(
|
|
false,
|
|
'LexicalNode: Node %s does not implement .getType().',
|
|
this.name,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clones this node, creating a new node with a different key
|
|
* and adding it to the EditorState (but not attaching it anywhere!). All nodes must
|
|
* implement this method.
|
|
*
|
|
*/
|
|
static clone(_data: unknown): LexicalNode {
|
|
invariant(
|
|
false,
|
|
'LexicalNode: Node %s does not implement .clone().',
|
|
this.name,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Perform any state updates on the clone of prevNode that are not already
|
|
* handled by the constructor call in the static clone method. If you have
|
|
* state to update in your clone that is not handled directly by the
|
|
* constructor, it is advisable to override this method but it is required
|
|
* to include a call to `super.afterCloneFrom(prevNode)` in your
|
|
* implementation. This is only intended to be called by
|
|
* {@link $cloneWithProperties} function or via a super call.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* class ClassesTextNode extends TextNode {
|
|
* // Not shown: static getType, static importJSON, exportJSON, createDOM, updateDOM
|
|
* __classes = new Set<string>();
|
|
* static clone(node: ClassesTextNode): ClassesTextNode {
|
|
* // The inherited TextNode constructor is used here, so
|
|
* // classes is not set by this method.
|
|
* return new ClassesTextNode(node.__text, node.__key);
|
|
* }
|
|
* afterCloneFrom(node: this): void {
|
|
* // This calls TextNode.afterCloneFrom and LexicalNode.afterCloneFrom
|
|
* // for necessary state updates
|
|
* super.afterCloneFrom(node);
|
|
* this.__addClasses(node.__classes);
|
|
* }
|
|
* // This method is a private implementation detail, it is not
|
|
* // suitable for the public API because it does not call getWritable
|
|
* __addClasses(classNames: Iterable<string>): this {
|
|
* for (const className of classNames) {
|
|
* this.__classes.add(className);
|
|
* }
|
|
* return this;
|
|
* }
|
|
* addClass(...classNames: string[]): this {
|
|
* return this.getWritable().__addClasses(classNames);
|
|
* }
|
|
* removeClass(...classNames: string[]): this {
|
|
* const node = this.getWritable();
|
|
* for (const className of classNames) {
|
|
* this.__classes.delete(className);
|
|
* }
|
|
* return this;
|
|
* }
|
|
* getClasses(): Set<string> {
|
|
* return this.getLatest().__classes;
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
*/
|
|
afterCloneFrom(prevNode: this) {
|
|
this.__parent = prevNode.__parent;
|
|
this.__next = prevNode.__next;
|
|
this.__prev = prevNode.__prev;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
static importDOM?: () => DOMConversionMap<any> | null;
|
|
|
|
constructor(key?: NodeKey) {
|
|
this.__type = this.constructor.getType();
|
|
this.__parent = null;
|
|
this.__prev = null;
|
|
this.__next = null;
|
|
$setNodeKey(this, key);
|
|
|
|
if (__DEV__) {
|
|
if (this.__type !== 'root') {
|
|
errorOnReadOnly();
|
|
errorOnTypeKlassMismatch(this.__type, this.constructor);
|
|
}
|
|
}
|
|
}
|
|
// Getters and Traversers
|
|
|
|
/**
|
|
* Returns the string type of this node.
|
|
*/
|
|
getType(): string {
|
|
return this.__type;
|
|
}
|
|
|
|
isInline(): boolean {
|
|
invariant(
|
|
false,
|
|
'LexicalNode: Node %s does not implement .isInline().',
|
|
this.constructor.name,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns true if there is a path between this node and the RootNode, false otherwise.
|
|
* This is a way of determining if the node is "attached" EditorState. Unattached nodes
|
|
* won't be reconciled and will ultimatelt be cleaned up by the Lexical GC.
|
|
*/
|
|
isAttached(): boolean {
|
|
let nodeKey: string | null = this.__key;
|
|
while (nodeKey !== null) {
|
|
if (nodeKey === 'root') {
|
|
return true;
|
|
}
|
|
|
|
const node: LexicalNode | null = $getNodeByKey(nodeKey);
|
|
|
|
if (node === null) {
|
|
break;
|
|
}
|
|
nodeKey = node.__parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this node is contained within the provided Selection., false otherwise.
|
|
* Relies on the algorithms implemented in {@link BaseSelection.getNodes} to determine
|
|
* what's included.
|
|
*
|
|
* @param selection - The selection that we want to determine if the node is in.
|
|
*/
|
|
isSelected(selection?: null | BaseSelection): boolean {
|
|
const targetSelection = selection || $getSelection();
|
|
if (targetSelection == null) {
|
|
return false;
|
|
}
|
|
|
|
const isSelected = targetSelection
|
|
.getNodes()
|
|
.some((n) => n.__key === this.__key);
|
|
|
|
if ($isTextNode(this)) {
|
|
return isSelected;
|
|
}
|
|
// For inline images inside of element nodes.
|
|
// Without this change the image will be selected if the cursor is before or after it.
|
|
const isElementRangeSelection =
|
|
$isRangeSelection(targetSelection) &&
|
|
targetSelection.anchor.type === 'element' &&
|
|
targetSelection.focus.type === 'element';
|
|
|
|
if (isElementRangeSelection) {
|
|
if (targetSelection.isCollapsed()) {
|
|
return false;
|
|
}
|
|
|
|
const parentNode = this.getParent();
|
|
if ($isDecoratorNode(this) && this.isInline() && parentNode) {
|
|
const firstPoint = targetSelection.isBackward()
|
|
? targetSelection.focus
|
|
: targetSelection.anchor;
|
|
const firstElement = firstPoint.getNode() as ElementNode;
|
|
if (
|
|
firstPoint.offset === firstElement.getChildrenSize() &&
|
|
firstElement.is(parentNode) &&
|
|
firstElement.getLastChildOrThrow().is(this)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return isSelected;
|
|
}
|
|
|
|
/**
|
|
* Returns this nodes key.
|
|
*/
|
|
getKey(): NodeKey {
|
|
// Key is stable between copies
|
|
return this.__key;
|
|
}
|
|
|
|
/**
|
|
* Returns the zero-based index of this node within the parent.
|
|
*/
|
|
getIndexWithinParent(): number {
|
|
const parent = this.getParent();
|
|
if (parent === null) {
|
|
return -1;
|
|
}
|
|
let node = parent.getFirstChild();
|
|
let index = 0;
|
|
while (node !== null) {
|
|
if (this.is(node)) {
|
|
return index;
|
|
}
|
|
index++;
|
|
node = node.getNextSibling();
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Returns the parent of this node, or null if none is found.
|
|
*/
|
|
getParent<T extends ElementNode>(): T | null {
|
|
const parent = this.getLatest().__parent;
|
|
if (parent === null) {
|
|
return null;
|
|
}
|
|
return $getNodeByKey<T>(parent);
|
|
}
|
|
|
|
/**
|
|
* Returns the parent of this node, or throws if none is found.
|
|
*/
|
|
getParentOrThrow<T extends ElementNode>(): T {
|
|
const parent = this.getParent<T>();
|
|
if (parent === null) {
|
|
invariant(false, 'Expected node %s to have a parent.', this.__key);
|
|
}
|
|
return parent;
|
|
}
|
|
|
|
/**
|
|
* Returns the highest (in the EditorState tree)
|
|
* non-root ancestor of this node, or null if none is found. See {@link lexical!$isRootOrShadowRoot}
|
|
* for more information on which Elements comprise "roots".
|
|
*/
|
|
getTopLevelElement(): ElementNode | DecoratorNode<unknown> | null {
|
|
let node: ElementNode | this | null = this;
|
|
while (node !== null) {
|
|
const parent: ElementNode | null = node.getParent();
|
|
if ($isRootOrShadowRoot(parent)) {
|
|
invariant(
|
|
$isElementNode(node) || (node === this && $isDecoratorNode(node)),
|
|
'Children of root nodes must be elements or decorators',
|
|
);
|
|
return node;
|
|
}
|
|
node = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns the highest (in the EditorState tree)
|
|
* non-root ancestor of this node, or throws if none is found. See {@link lexical!$isRootOrShadowRoot}
|
|
* for more information on which Elements comprise "roots".
|
|
*/
|
|
getTopLevelElementOrThrow(): ElementNode | DecoratorNode<unknown> {
|
|
const parent = this.getTopLevelElement();
|
|
if (parent === null) {
|
|
invariant(
|
|
false,
|
|
'Expected node %s to have a top parent element.',
|
|
this.__key,
|
|
);
|
|
}
|
|
return parent;
|
|
}
|
|
|
|
/**
|
|
* Returns a list of the every ancestor of this node,
|
|
* all the way up to the RootNode.
|
|
*
|
|
*/
|
|
getParents(): Array<ElementNode> {
|
|
const parents: Array<ElementNode> = [];
|
|
let node = this.getParent();
|
|
while (node !== null) {
|
|
parents.push(node);
|
|
node = node.getParent();
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
/**
|
|
* Returns a list of the keys of every ancestor of this node,
|
|
* all the way up to the RootNode.
|
|
*
|
|
*/
|
|
getParentKeys(): Array<NodeKey> {
|
|
const parents = [];
|
|
let node = this.getParent();
|
|
while (node !== null) {
|
|
parents.push(node.__key);
|
|
node = node.getParent();
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
/**
|
|
* Returns the "previous" siblings - that is, the node that comes
|
|
* before this one in the same parent.
|
|
*
|
|
*/
|
|
getPreviousSibling<T extends LexicalNode>(): T | null {
|
|
const self = this.getLatest();
|
|
const prevKey = self.__prev;
|
|
return prevKey === null ? null : $getNodeByKey<T>(prevKey);
|
|
}
|
|
|
|
/**
|
|
* Returns the "previous" siblings - that is, the nodes that come between
|
|
* this one and the first child of it's parent, inclusive.
|
|
*
|
|
*/
|
|
getPreviousSiblings<T extends LexicalNode>(): Array<T> {
|
|
const siblings: Array<T> = [];
|
|
const parent = this.getParent();
|
|
if (parent === null) {
|
|
return siblings;
|
|
}
|
|
let node: null | T = parent.getFirstChild();
|
|
while (node !== null) {
|
|
if (node.is(this)) {
|
|
break;
|
|
}
|
|
siblings.push(node);
|
|
node = node.getNextSibling();
|
|
}
|
|
return siblings;
|
|
}
|
|
|
|
/**
|
|
* Returns the "next" siblings - that is, the node that comes
|
|
* after this one in the same parent
|
|
*
|
|
*/
|
|
getNextSibling<T extends LexicalNode>(): T | null {
|
|
const self = this.getLatest();
|
|
const nextKey = self.__next;
|
|
return nextKey === null ? null : $getNodeByKey<T>(nextKey);
|
|
}
|
|
|
|
/**
|
|
* Returns all "next" siblings - that is, the nodes that come between this
|
|
* one and the last child of it's parent, inclusive.
|
|
*
|
|
*/
|
|
getNextSiblings<T extends LexicalNode>(): Array<T> {
|
|
const siblings: Array<T> = [];
|
|
let node: null | T = this.getNextSibling();
|
|
while (node !== null) {
|
|
siblings.push(node);
|
|
node = node.getNextSibling();
|
|
}
|
|
return siblings;
|
|
}
|
|
|
|
/**
|
|
* Returns the closest common ancestor of this node and the provided one or null
|
|
* if one cannot be found.
|
|
*
|
|
* @param node - the other node to find the common ancestor of.
|
|
*/
|
|
getCommonAncestor<T extends ElementNode = ElementNode>(
|
|
node: LexicalNode,
|
|
): T | null {
|
|
const a = this.getParents();
|
|
const b = node.getParents();
|
|
if ($isElementNode(this)) {
|
|
a.unshift(this);
|
|
}
|
|
if ($isElementNode(node)) {
|
|
b.unshift(node);
|
|
}
|
|
const aLength = a.length;
|
|
const bLength = b.length;
|
|
if (aLength === 0 || bLength === 0 || a[aLength - 1] !== b[bLength - 1]) {
|
|
return null;
|
|
}
|
|
const bSet = new Set(b);
|
|
for (let i = 0; i < aLength; i++) {
|
|
const ancestor = a[i] as T;
|
|
if (bSet.has(ancestor)) {
|
|
return ancestor;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the provided node is the exact same one as this node, from Lexical's perspective.
|
|
* Always use this instead of referential equality.
|
|
*
|
|
* @param object - the node to perform the equality comparison on.
|
|
*/
|
|
is(object: LexicalNode | null | undefined): boolean {
|
|
if (object == null) {
|
|
return false;
|
|
}
|
|
return this.__key === object.__key;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this node logical precedes the target node in the editor state.
|
|
*
|
|
* @param targetNode - the node we're testing to see if it's after this one.
|
|
*/
|
|
isBefore(targetNode: LexicalNode): boolean {
|
|
if (this === targetNode) {
|
|
return false;
|
|
}
|
|
if (targetNode.isParentOf(this)) {
|
|
return true;
|
|
}
|
|
if (this.isParentOf(targetNode)) {
|
|
return false;
|
|
}
|
|
const commonAncestor = this.getCommonAncestor(targetNode);
|
|
let indexA = 0;
|
|
let indexB = 0;
|
|
let node: this | ElementNode | LexicalNode = this;
|
|
while (true) {
|
|
const parent: ElementNode = node.getParentOrThrow();
|
|
if (parent === commonAncestor) {
|
|
indexA = node.getIndexWithinParent();
|
|
break;
|
|
}
|
|
node = parent;
|
|
}
|
|
node = targetNode;
|
|
while (true) {
|
|
const parent: ElementNode = node.getParentOrThrow();
|
|
if (parent === commonAncestor) {
|
|
indexB = node.getIndexWithinParent();
|
|
break;
|
|
}
|
|
node = parent;
|
|
}
|
|
return indexA < indexB;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this node is the parent of the target node, false otherwise.
|
|
*
|
|
* @param targetNode - the would-be child node.
|
|
*/
|
|
isParentOf(targetNode: LexicalNode): boolean {
|
|
const key = this.__key;
|
|
if (key === targetNode.__key) {
|
|
return false;
|
|
}
|
|
let node: ElementNode | LexicalNode | null = targetNode;
|
|
while (node !== null) {
|
|
if (node.__key === key) {
|
|
return true;
|
|
}
|
|
node = node.getParent();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// TO-DO: this function can be simplified a lot
|
|
/**
|
|
* Returns a list of nodes that are between this node and
|
|
* the target node in the EditorState.
|
|
*
|
|
* @param targetNode - the node that marks the other end of the range of nodes to be returned.
|
|
*/
|
|
getNodesBetween(targetNode: LexicalNode): Array<LexicalNode> {
|
|
const isBefore = this.isBefore(targetNode);
|
|
const nodes = [];
|
|
const visited = new Set();
|
|
let node: LexicalNode | this | null = this;
|
|
while (true) {
|
|
if (node === null) {
|
|
break;
|
|
}
|
|
const key = node.__key;
|
|
if (!visited.has(key)) {
|
|
visited.add(key);
|
|
nodes.push(node);
|
|
}
|
|
if (node === targetNode) {
|
|
break;
|
|
}
|
|
const child: LexicalNode | null = $isElementNode(node)
|
|
? isBefore
|
|
? node.getFirstChild()
|
|
: node.getLastChild()
|
|
: null;
|
|
if (child !== null) {
|
|
node = child;
|
|
continue;
|
|
}
|
|
const nextSibling: LexicalNode | null = isBefore
|
|
? node.getNextSibling()
|
|
: node.getPreviousSibling();
|
|
if (nextSibling !== null) {
|
|
node = nextSibling;
|
|
continue;
|
|
}
|
|
const parent: LexicalNode | null = node.getParentOrThrow();
|
|
if (!visited.has(parent.__key)) {
|
|
nodes.push(parent);
|
|
}
|
|
if (parent === targetNode) {
|
|
break;
|
|
}
|
|
let parentSibling = null;
|
|
let ancestor: LexicalNode | null = parent;
|
|
do {
|
|
if (ancestor === null) {
|
|
invariant(false, 'getNodesBetween: ancestor is null');
|
|
}
|
|
parentSibling = isBefore
|
|
? ancestor.getNextSibling()
|
|
: ancestor.getPreviousSibling();
|
|
ancestor = ancestor.getParent();
|
|
if (ancestor !== null) {
|
|
if (parentSibling === null && !visited.has(ancestor.__key)) {
|
|
nodes.push(ancestor);
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
} while (parentSibling === null);
|
|
node = parentSibling;
|
|
}
|
|
if (!isBefore) {
|
|
nodes.reverse();
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this node has been marked dirty during this update cycle.
|
|
*
|
|
*/
|
|
isDirty(): boolean {
|
|
const editor = getActiveEditor();
|
|
const dirtyLeaves = editor._dirtyLeaves;
|
|
return dirtyLeaves !== null && dirtyLeaves.has(this.__key);
|
|
}
|
|
|
|
/**
|
|
* Returns the latest version of the node from the active EditorState.
|
|
* This is used to avoid getting values from stale node references.
|
|
*
|
|
*/
|
|
getLatest(): this {
|
|
const latest = $getNodeByKey<this>(this.__key);
|
|
if (latest === null) {
|
|
invariant(
|
|
false,
|
|
'Lexical node does not exist in active editor state. Avoid using the same node references between nested closures from editorState.read/editor.update.',
|
|
);
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
/**
|
|
* Returns a mutable version of the node using {@link $cloneWithProperties}
|
|
* if necessary. Will throw an error if called outside of a Lexical Editor
|
|
* {@link LexicalEditor.update} callback.
|
|
*
|
|
*/
|
|
getWritable(): this {
|
|
errorOnReadOnly();
|
|
const editorState = getActiveEditorState();
|
|
const editor = getActiveEditor();
|
|
const nodeMap = editorState._nodeMap;
|
|
const key = this.__key;
|
|
// Ensure we get the latest node from pending state
|
|
const latestNode = this.getLatest();
|
|
const cloneNotNeeded = editor._cloneNotNeeded;
|
|
const selection = $getSelection();
|
|
if (selection !== null) {
|
|
selection.setCachedNodes(null);
|
|
}
|
|
if (cloneNotNeeded.has(key)) {
|
|
// Transforms clear the dirty node set on each iteration to keep track on newly dirty nodes
|
|
internalMarkNodeAsDirty(latestNode);
|
|
return latestNode;
|
|
}
|
|
const mutableNode = $cloneWithProperties(latestNode);
|
|
cloneNotNeeded.add(key);
|
|
internalMarkNodeAsDirty(mutableNode);
|
|
// Update reference in node map
|
|
nodeMap.set(key, mutableNode);
|
|
|
|
return mutableNode;
|
|
}
|
|
|
|
/**
|
|
* Returns the text content of the node. Override this for
|
|
* custom nodes that should have a representation in plain text
|
|
* format (for copy + paste, for example)
|
|
*
|
|
*/
|
|
getTextContent(): string {
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Returns the length of the string produced by calling getTextContent on this node.
|
|
*
|
|
*/
|
|
getTextContentSize(): number {
|
|
return this.getTextContent().length;
|
|
}
|
|
|
|
// View
|
|
|
|
/**
|
|
* Called during the reconciliation process to determine which nodes
|
|
* to insert into the DOM for this Lexical Node.
|
|
*
|
|
* This method must return exactly one HTMLElement. Nested elements are not supported.
|
|
*
|
|
* Do not attempt to update the Lexical EditorState during this phase of the update lifecyle.
|
|
*
|
|
* @param _config - allows access to things like the EditorTheme (to apply classes) during reconciliation.
|
|
* @param _editor - allows access to the editor for context during reconciliation.
|
|
*
|
|
* */
|
|
createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
|
|
invariant(false, 'createDOM: base method not extended');
|
|
}
|
|
|
|
/**
|
|
* Called when a node changes and should update the DOM
|
|
* in whatever way is necessary to make it align with any changes that might
|
|
* have happened during the update.
|
|
*
|
|
* Returning "true" here will cause lexical to unmount and recreate the DOM node
|
|
* (by calling createDOM). You would need to do this if the element tag changes,
|
|
* for instance.
|
|
*
|
|
* */
|
|
updateDOM(
|
|
_prevNode: unknown,
|
|
_dom: HTMLElement,
|
|
_config: EditorConfig,
|
|
): boolean {
|
|
invariant(false, 'updateDOM: base method not extended');
|
|
}
|
|
|
|
/**
|
|
* Controls how the this node is serialized to HTML. This is important for
|
|
* copy and paste between Lexical and non-Lexical editors, or Lexical editors with different namespaces,
|
|
* in which case the primary transfer format is HTML. It's also important if you're serializing
|
|
* to HTML for any other reason via {@link @lexical/html!$generateHtmlFromNodes}. You could
|
|
* also use this method to build your own HTML renderer.
|
|
*
|
|
* */
|
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
const element = this.createDOM(editor._config, editor);
|
|
return {element};
|
|
}
|
|
|
|
/**
|
|
* Controls how the this node is serialized to JSON. This is important for
|
|
* copy and paste between Lexical editors sharing the same namespace. It's also important
|
|
* if you're serializing to JSON for persistent storage somewhere.
|
|
* See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
|
|
*
|
|
* */
|
|
exportJSON(): SerializedLexicalNode {
|
|
invariant(false, 'exportJSON: base method not extended');
|
|
}
|
|
|
|
/**
|
|
* Controls how the this node is deserialized from JSON. This is usually boilerplate,
|
|
* but provides an abstraction between the node implementation and serialized interface that can
|
|
* be important if you ever make breaking changes to a node schema (by adding or removing properties).
|
|
* See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
|
|
*
|
|
* */
|
|
static importJSON(_serializedNode: SerializedLexicalNode): LexicalNode {
|
|
invariant(
|
|
false,
|
|
'LexicalNode: Node %s does not implement .importJSON().',
|
|
this.name,
|
|
);
|
|
}
|
|
/**
|
|
* @experimental
|
|
*
|
|
* Registers the returned function as a transform on the node during
|
|
* Editor initialization. Most such use cases should be addressed via
|
|
* the {@link LexicalEditor.registerNodeTransform} API.
|
|
*
|
|
* Experimental - use at your own risk.
|
|
*/
|
|
static transform(): ((node: LexicalNode) => void) | null {
|
|
return null;
|
|
}
|
|
|
|
// Setters and mutators
|
|
|
|
/**
|
|
* Removes this LexicalNode from the EditorState. If the node isn't re-inserted
|
|
* somewhere, the Lexical garbage collector will eventually clean it up.
|
|
*
|
|
* @param preserveEmptyParent - If falsy, the node's parent will be removed if
|
|
* it's empty after the removal operation. This is the default behavior, subject to
|
|
* other node heuristics such as {@link ElementNode#canBeEmpty}
|
|
* */
|
|
remove(preserveEmptyParent?: boolean): void {
|
|
$removeNode(this, true, preserveEmptyParent);
|
|
}
|
|
|
|
/**
|
|
* Replaces this LexicalNode with the provided node, optionally transferring the children
|
|
* of the replaced node to the replacing node.
|
|
*
|
|
* @param replaceWith - The node to replace this one with.
|
|
* @param includeChildren - Whether or not to transfer the children of this node to the replacing node.
|
|
* */
|
|
replace<N extends LexicalNode>(replaceWith: N, includeChildren?: boolean): N {
|
|
errorOnReadOnly();
|
|
let selection = $getSelection();
|
|
if (selection !== null) {
|
|
selection = selection.clone();
|
|
}
|
|
errorOnInsertTextNodeOnRoot(this, replaceWith);
|
|
const self = this.getLatest();
|
|
const toReplaceKey = this.__key;
|
|
const key = replaceWith.__key;
|
|
const writableReplaceWith = replaceWith.getWritable();
|
|
const writableParent = this.getParentOrThrow().getWritable();
|
|
const size = writableParent.__size;
|
|
removeFromParent(writableReplaceWith);
|
|
const prevSibling = self.getPreviousSibling();
|
|
const nextSibling = self.getNextSibling();
|
|
const prevKey = self.__prev;
|
|
const nextKey = self.__next;
|
|
const parentKey = self.__parent;
|
|
$removeNode(self, false, true);
|
|
|
|
if (prevSibling === null) {
|
|
writableParent.__first = key;
|
|
} else {
|
|
const writablePrevSibling = prevSibling.getWritable();
|
|
writablePrevSibling.__next = key;
|
|
}
|
|
writableReplaceWith.__prev = prevKey;
|
|
if (nextSibling === null) {
|
|
writableParent.__last = key;
|
|
} else {
|
|
const writableNextSibling = nextSibling.getWritable();
|
|
writableNextSibling.__prev = key;
|
|
}
|
|
writableReplaceWith.__next = nextKey;
|
|
writableReplaceWith.__parent = parentKey;
|
|
writableParent.__size = size;
|
|
if (includeChildren) {
|
|
invariant(
|
|
$isElementNode(this) && $isElementNode(writableReplaceWith),
|
|
'includeChildren should only be true for ElementNodes',
|
|
);
|
|
this.getChildren().forEach((child: LexicalNode) => {
|
|
writableReplaceWith.append(child);
|
|
});
|
|
}
|
|
if ($isRangeSelection(selection)) {
|
|
$setSelection(selection);
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
if (anchor.key === toReplaceKey) {
|
|
$moveSelectionPointToEnd(anchor, writableReplaceWith);
|
|
}
|
|
if (focus.key === toReplaceKey) {
|
|
$moveSelectionPointToEnd(focus, writableReplaceWith);
|
|
}
|
|
}
|
|
if ($getCompositionKey() === toReplaceKey) {
|
|
$setCompositionKey(key);
|
|
}
|
|
return writableReplaceWith;
|
|
}
|
|
|
|
/**
|
|
* Inserts a node after this LexicalNode (as the next sibling).
|
|
*
|
|
* @param nodeToInsert - The node to insert after this one.
|
|
* @param restoreSelection - Whether or not to attempt to resolve the
|
|
* selection to the appropriate place after the operation is complete.
|
|
* */
|
|
insertAfter(nodeToInsert: LexicalNode, restoreSelection = true): LexicalNode {
|
|
errorOnReadOnly();
|
|
errorOnInsertTextNodeOnRoot(this, nodeToInsert);
|
|
const writableSelf = this.getWritable();
|
|
const writableNodeToInsert = nodeToInsert.getWritable();
|
|
const oldParent = writableNodeToInsert.getParent();
|
|
const selection = $getSelection();
|
|
let elementAnchorSelectionOnNode = false;
|
|
let elementFocusSelectionOnNode = false;
|
|
if (oldParent !== null) {
|
|
// TODO: this is O(n), can we improve?
|
|
const oldIndex = nodeToInsert.getIndexWithinParent();
|
|
removeFromParent(writableNodeToInsert);
|
|
if ($isRangeSelection(selection)) {
|
|
const oldParentKey = oldParent.__key;
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
elementAnchorSelectionOnNode =
|
|
anchor.type === 'element' &&
|
|
anchor.key === oldParentKey &&
|
|
anchor.offset === oldIndex + 1;
|
|
elementFocusSelectionOnNode =
|
|
focus.type === 'element' &&
|
|
focus.key === oldParentKey &&
|
|
focus.offset === oldIndex + 1;
|
|
}
|
|
}
|
|
const nextSibling = this.getNextSibling();
|
|
const writableParent = this.getParentOrThrow().getWritable();
|
|
const insertKey = writableNodeToInsert.__key;
|
|
const nextKey = writableSelf.__next;
|
|
if (nextSibling === null) {
|
|
writableParent.__last = insertKey;
|
|
} else {
|
|
const writableNextSibling = nextSibling.getWritable();
|
|
writableNextSibling.__prev = insertKey;
|
|
}
|
|
writableParent.__size++;
|
|
writableSelf.__next = insertKey;
|
|
writableNodeToInsert.__next = nextKey;
|
|
writableNodeToInsert.__prev = writableSelf.__key;
|
|
writableNodeToInsert.__parent = writableSelf.__parent;
|
|
if (restoreSelection && $isRangeSelection(selection)) {
|
|
const index = this.getIndexWithinParent();
|
|
$updateElementSelectionOnCreateDeleteNode(
|
|
selection,
|
|
writableParent,
|
|
index + 1,
|
|
);
|
|
const writableParentKey = writableParent.__key;
|
|
if (elementAnchorSelectionOnNode) {
|
|
selection.anchor.set(writableParentKey, index + 2, 'element');
|
|
}
|
|
if (elementFocusSelectionOnNode) {
|
|
selection.focus.set(writableParentKey, index + 2, 'element');
|
|
}
|
|
}
|
|
return nodeToInsert;
|
|
}
|
|
|
|
/**
|
|
* Inserts a node before this LexicalNode (as the previous sibling).
|
|
*
|
|
* @param nodeToInsert - The node to insert before this one.
|
|
* @param restoreSelection - Whether or not to attempt to resolve the
|
|
* selection to the appropriate place after the operation is complete.
|
|
* */
|
|
insertBefore(
|
|
nodeToInsert: LexicalNode,
|
|
restoreSelection = true,
|
|
): LexicalNode {
|
|
errorOnReadOnly();
|
|
errorOnInsertTextNodeOnRoot(this, nodeToInsert);
|
|
const writableSelf = this.getWritable();
|
|
const writableNodeToInsert = nodeToInsert.getWritable();
|
|
const insertKey = writableNodeToInsert.__key;
|
|
removeFromParent(writableNodeToInsert);
|
|
const prevSibling = this.getPreviousSibling();
|
|
const writableParent = this.getParentOrThrow().getWritable();
|
|
const prevKey = writableSelf.__prev;
|
|
// TODO: this is O(n), can we improve?
|
|
const index = this.getIndexWithinParent();
|
|
if (prevSibling === null) {
|
|
writableParent.__first = insertKey;
|
|
} else {
|
|
const writablePrevSibling = prevSibling.getWritable();
|
|
writablePrevSibling.__next = insertKey;
|
|
}
|
|
writableParent.__size++;
|
|
writableSelf.__prev = insertKey;
|
|
writableNodeToInsert.__prev = prevKey;
|
|
writableNodeToInsert.__next = writableSelf.__key;
|
|
writableNodeToInsert.__parent = writableSelf.__parent;
|
|
const selection = $getSelection();
|
|
if (restoreSelection && $isRangeSelection(selection)) {
|
|
const parent = this.getParentOrThrow();
|
|
$updateElementSelectionOnCreateDeleteNode(selection, parent, index);
|
|
}
|
|
return nodeToInsert;
|
|
}
|
|
|
|
/**
|
|
* Whether or not this node has a required parent. Used during copy + paste operations
|
|
* to normalize nodes that would otherwise be orphaned. For example, ListItemNodes without
|
|
* a ListNode parent or TextNodes with a ParagraphNode parent.
|
|
*
|
|
* */
|
|
isParentRequired(): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The creation logic for any required parent. Should be implemented if {@link isParentRequired} returns true.
|
|
*
|
|
* */
|
|
createParentElementNode(): ElementNode {
|
|
return $createParagraphNode();
|
|
}
|
|
|
|
selectStart(): RangeSelection {
|
|
return this.selectPrevious();
|
|
}
|
|
|
|
selectEnd(): RangeSelection {
|
|
return this.selectNext(0, 0);
|
|
}
|
|
|
|
/**
|
|
* Moves selection to the previous sibling of this node, at the specified offsets.
|
|
*
|
|
* @param anchorOffset - The anchor offset for selection.
|
|
* @param focusOffset - The focus offset for selection
|
|
* */
|
|
selectPrevious(anchorOffset?: number, focusOffset?: number): RangeSelection {
|
|
errorOnReadOnly();
|
|
const prevSibling = this.getPreviousSibling();
|
|
const parent = this.getParentOrThrow();
|
|
if (prevSibling === null) {
|
|
return parent.select(0, 0);
|
|
}
|
|
if ($isElementNode(prevSibling)) {
|
|
return prevSibling.select();
|
|
} else if (!$isTextNode(prevSibling)) {
|
|
const index = prevSibling.getIndexWithinParent() + 1;
|
|
return parent.select(index, index);
|
|
}
|
|
return prevSibling.select(anchorOffset, focusOffset);
|
|
}
|
|
|
|
/**
|
|
* Moves selection to the next sibling of this node, at the specified offsets.
|
|
*
|
|
* @param anchorOffset - The anchor offset for selection.
|
|
* @param focusOffset - The focus offset for selection
|
|
* */
|
|
selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection {
|
|
errorOnReadOnly();
|
|
const nextSibling = this.getNextSibling();
|
|
const parent = this.getParentOrThrow();
|
|
if (nextSibling === null) {
|
|
return parent.select();
|
|
}
|
|
if ($isElementNode(nextSibling)) {
|
|
return nextSibling.select(0, 0);
|
|
} else if (!$isTextNode(nextSibling)) {
|
|
const index = nextSibling.getIndexWithinParent();
|
|
return parent.select(index, index);
|
|
}
|
|
return nextSibling.select(anchorOffset, focusOffset);
|
|
}
|
|
|
|
/**
|
|
* Marks a node dirty, triggering transforms and
|
|
* forcing it to be reconciled during the update cycle.
|
|
*
|
|
* */
|
|
markDirty(): void {
|
|
this.getWritable();
|
|
}
|
|
|
|
/**
|
|
* Insert the DOM of this node into that of the parent.
|
|
* Allows this node to implement custom DOM attachment logic.
|
|
* Boolean result indicates if the insertion was handled by the function.
|
|
* A true return value prevents default insertion logic from taking place.
|
|
*/
|
|
insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function errorOnTypeKlassMismatch(
|
|
type: string,
|
|
klass: Klass<LexicalNode>,
|
|
): void {
|
|
const registeredNode = getActiveEditor()._nodes.get(type);
|
|
// Common error - split in its own invariant
|
|
if (registeredNode === undefined) {
|
|
invariant(
|
|
false,
|
|
'Create node: Attempted to create node %s that was not configured to be used on the editor.',
|
|
klass.name,
|
|
);
|
|
}
|
|
const editorKlass = registeredNode.klass;
|
|
if (editorKlass !== klass) {
|
|
invariant(
|
|
false,
|
|
'Create node: Type %s in node %s does not match registered node %s with the same type',
|
|
type,
|
|
klass.name,
|
|
editorKlass.name,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a series of nodes after this LexicalNode (as next siblings)
|
|
*
|
|
* @param firstToInsert - The first node to insert after this one.
|
|
* @param lastToInsert - The last node to insert after this one. Must be a
|
|
* later sibling of FirstNode. If not provided, it will be its last sibling.
|
|
*/
|
|
export function insertRangeAfter(
|
|
node: LexicalNode,
|
|
firstToInsert: LexicalNode,
|
|
lastToInsert?: LexicalNode,
|
|
) {
|
|
const lastToInsert2 =
|
|
lastToInsert || firstToInsert.getParentOrThrow().getLastChild()!;
|
|
let current = firstToInsert;
|
|
const nodesToInsert = [firstToInsert];
|
|
while (current !== lastToInsert2) {
|
|
if (!current.getNextSibling()) {
|
|
invariant(
|
|
false,
|
|
'insertRangeAfter: lastToInsert must be a later sibling of firstToInsert',
|
|
);
|
|
}
|
|
current = current.getNextSibling()!;
|
|
nodesToInsert.push(current);
|
|
}
|
|
|
|
let currentNode: LexicalNode = node;
|
|
for (const nodeToInsert of nodesToInsert) {
|
|
currentNode = currentNode.insertAfter(nodeToInsert);
|
|
}
|
|
}
|