resources/js/wysiwyg/lexical/core/LexicalNode.ts
/**
* 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;
export type DOMConversionOutput = {
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
forChild?: DOMChildConversion;
node: null | LexicalNode | Array<LexicalNode>;
};
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();
}
}
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);
}
}