lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
1,023 lines (990 loc) • 33.3 kB
text/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 {
BaseStaticNodeConfig,
KlassConstructor,
LexicalEditor,
LexicalUpdateJSON,
Spread,
TextFormatType,
} from 'lexical';
import invariant from '@lexical/internal/invariant';
import {$isTextNode, type TextNode} from '../index';
import {
DOUBLE_LINE_BREAK,
ELEMENT_FORMAT_TO_TYPE,
ELEMENT_TYPE_TO_FORMAT,
TEXT_TYPE_TO_FORMAT,
} from '../LexicalConstants';
import {ElementDOMSlot} from '../LexicalDOMSlot';
import {
$isEphemeral,
type DOMExportOutput,
LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type SlotChildNode,
type SlotHostNode,
} from '../LexicalNode';
import {
$getSelection,
$internalMakeRangeSelection,
$isRangeSelection,
type BaseSelection,
moveSelectionPointToSibling,
type PointType,
type RangeSelection,
} from '../LexicalSelection';
import {
$errorOnSlotCycleChild,
$getSlot,
$getSlotNames,
$getSlotsTextContent,
$getSlotsTextContentSize,
} from '../LexicalSlot';
import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates';
import {
$getDOMSlot,
$getNodeByKey,
$isRootOrShadowRoot,
$removeFromParent,
isHTMLElement,
toggleTextFormatType,
} from '../LexicalUtils';
export type SerializedElementNode<
T extends SerializedLexicalNode = SerializedLexicalNode,
> = Spread<
{
children: T[];
direction: 'ltr' | 'rtl' | null;
format: ElementFormatType;
indent: number;
textFormat?: number;
textStyle?: string;
},
SerializedLexicalNode
>;
export type ElementFormatType =
| 'left'
| 'start'
| 'center'
| 'right'
| 'end'
| 'justify'
| '';
/**
* Wrap any shadow-root child of `node` that is neither an ElementNode nor a
* DecoratorNode in a paragraph, so the slot-frame invariant set by
* `getTopLevelElement` continues to hold for external inputs (URL doc
* payloads, imported JSON, paste round-trips) that may carry shapes the
* in-editor mutation paths can no longer produce.
*
* Single-node helper: runs as the `$config` `$transform` on ElementNode so
* the existing dirty-node transform cycle drives the normalization. The
* in-editor mutation paths (insertText, insertNodes, append/splice via the
* public API) still fail-fast on the invariant.
*
* @internal
*/
function $normalizeShadowRootChildren(node: ElementNode): void {
if ($isRootOrShadowRoot(node)) {
let block: ElementNode | null = null;
for (const child of node.getChildren()) {
block = child.isInline()
? (block || child.replace(child.createParentElementNode())).append(
child,
)
: null;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface ElementNode {
getTopLevelElement(): ElementNode | null;
getTopLevelElementOrThrow(): ElementNode;
}
/** @noInheritDoc */
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class ElementNode
extends LexicalNode
implements SlotHostNode, SlotChildNode
{
/** @internal */
declare ['constructor']: KlassConstructor<typeof ElementNode>;
/** @internal */
__first: null | NodeKey;
/** @internal */
__last: null | NodeKey;
/** @internal */
__size: number;
/** @internal */
__format: number;
/** @internal */
__style: string;
/** @internal */
__indent: number;
/** @internal */
__dir: 'ltr' | 'rtl' | null;
/** @internal */
__textFormat: number;
/** @internal */
__textStyle: string;
/** @internal */
__slotHost: null | NodeKey;
/** @internal */
__slots: null | Map<string, NodeKey>;
// Specific type information is discarded for backwards compatibility,
// there is nothing meaninful to gain from requiring `{extends: ElementNode}`
// with the current shape here (just a `$transform`)
$config(): BaseStaticNodeConfig {
return this.config(Symbol.for('ElementNode'), {
/*
* Built-in normalize for shadow-root ElementNodes: wraps any direct child
* that is neither an ElementNode nor a DecoratorNode in a paragraph, so
* the slot-frame invariant set by `getTopLevelElement` continues to hold
* for external inputs (URL doc payloads, imported JSON, paste round-trips)
* that may carry shapes the in-editor mutation paths can no longer
* produce. In-editor mutation paths still fail-fast on the invariant.
*
* Runs as a static transform so the existing dirty-node transform cycle
* drives it — typing paths cover their own dirty bookkeeping, hydrate
* paths (`setEditorState`) dirty-mark slot hosts so the cycle picks them
* up.
*/
$transform: $normalizeShadowRootChildren,
extends: LexicalNode,
});
}
constructor(key?: NodeKey) {
super(key);
this.__first = null;
this.__last = null;
this.__size = 0;
this.__format = 0;
this.__style = '';
this.__indent = 0;
this.__dir = null;
this.__textFormat = 0;
this.__textStyle = '';
this.__slotHost = null;
this.__slots = null;
}
afterCloneFrom(prevNode: this) {
super.afterCloneFrom(prevNode);
if (this.__key === prevNode.__key) {
this.__first = prevNode.__first;
this.__last = prevNode.__last;
this.__size = prevNode.__size;
this.__slotHost = prevNode.__slotHost;
invariant(
this.__slotHost === null || this.__parent === null,
'ElementNode: node %s is both slotted into host %s and a child of parent %s; __slotHost and __parent are mutually exclusive',
this.__key,
String(this.__slotHost),
String(this.__parent),
);
// Copy-on-write: share the map across versions; the LexicalSlot
// mutators clone it on a version's first write (owner ledger), so a
// host cloned for any non-slot change pays no per-version Map copy.
this.__slots = prevNode.__slots;
}
this.__indent = prevNode.__indent;
this.__format = prevNode.__format;
this.__style = prevNode.__style;
this.__dir = prevNode.__dir;
this.__textFormat = prevNode.__textFormat;
this.__textStyle = prevNode.__textStyle;
}
getFormat(): number {
const self = this.getLatest();
return self.__format;
}
getFormatType(): ElementFormatType {
const format = this.getFormat();
return ELEMENT_FORMAT_TO_TYPE[format] || '';
}
getStyle(): string {
const self = this.getLatest();
return self.__style;
}
getIndent(): number {
const self = this.getLatest();
return self.__indent;
}
/**
* Returns the children of this node, in document order.
*/
getChildren(): LexicalNode[];
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getChildren() as T[]`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the results with a type guard instead.
*/
getChildren<T extends LexicalNode>(): T[];
getChildren(): LexicalNode[] {
const children: LexicalNode[] = [];
let child = this.getFirstChild();
while (child !== null) {
children.push(child);
child = child.getNextSibling();
}
return children;
}
getChildrenKeys(): NodeKey[] {
const children: NodeKey[] = [];
let child = this.getFirstChild();
while (child !== null) {
children.push(child.__key);
child = child.getNextSibling();
}
return children;
}
getChildrenSize(): number {
const self = this.getLatest();
return self.__size;
}
isEmpty(): boolean {
// A host that holds content only in its slots is not empty: otherwise
// $removeNode would cascade-prune it once its last child is gone and orphan
// the slot subtrees.
return this.getChildrenSize() === 0 && $getSlotNames(this).length === 0;
}
isDirty(): boolean {
const editor = getActiveEditor();
const dirtyElements = editor._dirtyElements;
return dirtyElements !== null && dirtyElements.has(this.__key);
}
isLastChild(): boolean {
const self = this.getLatest();
const parentLastChild = this.getParentOrThrow().getLastChild();
return parentLastChild !== null && parentLastChild.is(self);
}
getAllTextNodes(): TextNode[] {
const textNodes: TextNode[] = [];
// Slots are read slots-first, ahead of the linked-list children, to match
// getTextContent. This is a content read; descendant navigation
// (getFirstDescendant / getLastDescendant) stays children-only so slots
// never leak into selection placement. A slot value is always a non-inline
// element or decorator (setSlot enforces this), so only element slots
// contribute text nodes.
for (const name of $getSlotNames(this)) {
const slot = $getSlot(this, name);
if ($isElementNode(slot)) {
textNodes.push(...slot.getAllTextNodes());
}
}
let child: LexicalNode | null = this.getFirstChild();
while (child !== null) {
if ($isTextNode(child)) {
textNodes.push(child);
}
if ($isElementNode(child)) {
const subChildrenNodes = child.getAllTextNodes();
textNodes.push(...subChildrenNodes);
}
child = child.getNextSibling();
}
return textNodes;
}
/**
* Returns the deepest first descendant of this node,
* or null if it has no children.
*
* Descendant navigation is children-only by design: it feeds selectStart /
* selectEnd and selection, which must not see slots (slots are isolated).
*/
getFirstDescendant(): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getFirstDescendant() as T | null`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getFirstDescendant<T extends LexicalNode>(): null | T;
getFirstDescendant(): null | LexicalNode {
let node = this.getFirstChild();
while ($isElementNode(node)) {
const child = node.getFirstChild();
if (child === null) {
break;
}
node = child;
}
return node;
}
/**
* Returns the deepest last descendant of this node,
* or null if it has no children.
*/
getLastDescendant(): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getLastDescendant() as T | null`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getLastDescendant<T extends LexicalNode>(): null | T;
getLastDescendant(): null | LexicalNode {
let node = this.getLastChild();
while ($isElementNode(node)) {
const child = node.getLastChild();
if (child === null) {
break;
}
node = child;
}
return node;
}
/**
* Returns the deepest descendant corresponding to the child at the given
* index, or null if this node has no children.
*/
getDescendantByIndex(index: number): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getDescendantByIndex(index) as T | null`, and
* will be removed in a future release. Call this method without a type
* argument and narrow the result with a type guard instead.
*/
getDescendantByIndex<T extends LexicalNode>(index: number): null | T;
getDescendantByIndex(index: number): null | LexicalNode {
const children = this.getChildren();
const childrenLength = children.length;
// For non-empty element nodes, we resolve its descendant
// (either a leaf node or the bottom-most element)
if (index >= childrenLength) {
const resolvedNode = children[childrenLength - 1];
return (
($isElementNode(resolvedNode) && resolvedNode.getLastDescendant()) ||
resolvedNode ||
null
);
}
const resolvedNode = children[index];
return (
($isElementNode(resolvedNode) && resolvedNode.getFirstDescendant()) ||
resolvedNode ||
null
);
}
/**
* Returns the first child of this node, or null if it has no children.
*/
getFirstChild(): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getFirstChild() as T | null`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getFirstChild<T extends LexicalNode>(): null | T;
getFirstChild(): null | LexicalNode {
const self = this.getLatest();
const firstKey = self.__first;
return firstKey === null ? null : $getNodeByKey(firstKey);
}
/**
* Returns the first child of this node, or throws if it has no children.
*/
getFirstChildOrThrow(): LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getFirstChildOrThrow() as T`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getFirstChildOrThrow<T extends LexicalNode>(): T;
getFirstChildOrThrow(): LexicalNode {
const firstChild = this.getFirstChild();
if (firstChild === null) {
invariant(false, 'Expected node %s to have a first child.', this.__key);
}
return firstChild;
}
/**
* Returns the last child of this node, or null if it has no children.
*/
getLastChild(): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getLastChild() as T | null`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getLastChild<T extends LexicalNode>(): null | T;
getLastChild(): null | LexicalNode {
const self = this.getLatest();
const lastKey = self.__last;
return lastKey === null ? null : $getNodeByKey(lastKey);
}
/**
* Returns the last child of this node, or throws if it has no children.
*/
getLastChildOrThrow(): LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getLastChildOrThrow() as T`, and will be
* removed in a future release. Call this method without a type argument
* and narrow the result with a type guard instead.
*/
getLastChildOrThrow<T extends LexicalNode>(): T;
getLastChildOrThrow(): LexicalNode {
const lastChild = this.getLastChild();
if (lastChild === null) {
invariant(false, 'Expected node %s to have a last child.', this.__key);
}
return lastChild;
}
/**
* Returns the child of this node at the given index, or null if
* the index is out of range.
*/
getChildAtIndex(index: number): null | LexicalNode;
/**
* @deprecated The type parameter is an unchecked and unsafe cast,
* equivalent to `element.getChildAtIndex(index) as T | null`, and will
* be removed in a future release. Call this method without a type
* argument and narrow the result with a type guard instead.
*/
getChildAtIndex<T extends LexicalNode>(index: number): null | T;
getChildAtIndex(index: number): null | LexicalNode {
const size = this.getChildrenSize();
let node: null | LexicalNode;
let i;
if (index < size / 2) {
node = this.getFirstChild();
i = 0;
while (node !== null && i <= index) {
if (i === index) {
return node;
}
node = node.getNextSibling();
i++;
}
return null;
}
node = this.getLastChild();
i = size - 1;
while (node !== null && i >= index) {
if (i === index) {
return node;
}
node = node.getPreviousSibling();
i--;
}
return null;
}
getTextContent(): string {
// Slots are read slots-first, ahead of the linked-list children.
let textContent = $getSlotsTextContent(this);
const children = this.getChildren();
const childrenLength = children.length;
for (let i = 0; i < childrenLength; i++) {
const child = children[i];
textContent += child.getTextContent();
if (
// this is an inline $textContentRequiresDoubleLinebreakAtEnd(child)
$isElementNode(child) &&
i !== childrenLength - 1 &&
!child.isInline()
) {
textContent += DOUBLE_LINE_BREAK;
}
}
return textContent;
}
getTextContentSize(): number {
// Slots are counted slots-first, ahead of the linked-list children.
let textContentSize = $getSlotsTextContentSize(this);
const children = this.getChildren();
const childrenLength = children.length;
for (let i = 0; i < childrenLength; i++) {
const child = children[i];
textContentSize += child.getTextContentSize();
if (
// This is an inline $textContentRequiresDoubleLinebreakAtEnd(child)
$isElementNode(child) &&
i !== childrenLength - 1 &&
!child.isInline()
) {
textContentSize += DOUBLE_LINE_BREAK.length;
}
}
return textContentSize;
}
getDirection(): 'ltr' | 'rtl' | null {
const self = this.getLatest();
return self.__dir;
}
getTextFormat(): number {
const self = this.getLatest();
return self.__textFormat;
}
hasFormat(type: ElementFormatType): boolean {
if (type !== '') {
const formatFlag = ELEMENT_TYPE_TO_FORMAT[type];
return (this.getFormat() & formatFlag) !== 0;
}
return false;
}
hasTextFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.getTextFormat() & formatFlag) !== 0;
}
/**
* Returns the format flags applied to the node as a 32-bit integer.
*
* @returns a number representing the TextFormatTypes applied to the node.
*/
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
const self = this.getLatest();
const format = self.__textFormat;
return toggleTextFormatType(format, type, alignWithFormat);
}
getTextStyle(): string {
const self = this.getLatest();
return self.__textStyle;
}
// Mutators
select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
errorOnReadOnly();
const selection = $getSelection();
let anchorOffset = _anchorOffset;
let focusOffset = _focusOffset;
const childrenCount = this.getChildrenSize();
if (!this.canBeEmpty()) {
if (_anchorOffset === 0 && _focusOffset === 0) {
const firstChild = this.getFirstChild();
if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
return firstChild.select(0, 0);
}
} else if (
(_anchorOffset === undefined || _anchorOffset === childrenCount) &&
(_focusOffset === undefined || _focusOffset === childrenCount)
) {
const lastChild = this.getLastChild();
if ($isTextNode(lastChild) || $isElementNode(lastChild)) {
return lastChild.select();
}
}
}
if (anchorOffset === undefined) {
anchorOffset = childrenCount;
}
if (focusOffset === undefined) {
focusOffset = childrenCount;
}
const key = this.__key;
if (!$isRangeSelection(selection)) {
return $internalMakeRangeSelection(
key,
anchorOffset,
key,
focusOffset,
'element',
'element',
);
} else {
selection.anchor.set(key, anchorOffset, 'element');
selection.focus.set(key, focusOffset, 'element');
selection.dirty = true;
}
return selection;
}
selectStart(): RangeSelection {
const firstNode = this.getFirstDescendant();
return firstNode ? firstNode.selectStart() : this.select();
}
selectEnd(): RangeSelection {
const lastNode = this.getLastDescendant();
return lastNode ? lastNode.selectEnd() : this.select();
}
clear(): this {
const writableSelf = this.getWritable();
const children = this.getChildren();
children.forEach(child => child.remove());
return writableSelf;
}
append(...nodesToAppend: LexicalNode[]): this {
return this.splice(this.getChildrenSize(), 0, nodesToAppend);
}
setDirection(direction: 'ltr' | 'rtl' | null): this {
const self = this.getWritable();
self.__dir = direction;
return self;
}
setFormat(type: ElementFormatType): this {
const self = this.getWritable();
self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] || 0 : 0;
return this;
}
setStyle(style: string): this {
const self = this.getWritable();
self.__style = style || '';
return this;
}
setTextFormat(type: number): this {
const self = this.getWritable();
self.__textFormat = type;
return self;
}
setTextStyle(style: string): this {
const self = this.getWritable();
self.__textStyle = style;
return self;
}
setIndent(indentLevel: number): this {
const self = this.getWritable();
self.__indent = indentLevel;
return this;
}
splice(
start: number,
deleteCount: number,
nodesToInsert: LexicalNode[],
): this {
invariant(
!$isEphemeral(this),
'ElementNode.splice: Ephemeral nodes can not mutate their children (key %s type %s)',
this.__key,
this.__type,
);
const oldSize = this.getChildrenSize();
const writableSelf = this.getWritable();
invariant(
start + deleteCount <= oldSize,
'ElementNode.splice: start + deleteCount > oldSize (%s + %s > %s)',
String(start),
String(deleteCount),
String(oldSize),
);
// Before any mutation: a child insertion must not close a cycle through a
// slot up-link (the reverse direction of $setSlot's cycle invariant).
for (const nodeToInsert of nodesToInsert) {
$errorOnSlotCycleChild(writableSelf, nodeToInsert);
}
const writableSelfKey = writableSelf.__key;
const nodesToInsertKeys = [];
const nodesToRemoveKeys = [];
const nodeAfterRange = this.getChildAtIndex(start + deleteCount);
let nodeBeforeRange = null;
let newSize = oldSize - deleteCount + nodesToInsert.length;
if (start !== 0) {
if (start === oldSize) {
nodeBeforeRange = this.getLastChild();
} else {
const node = this.getChildAtIndex(start);
if (node !== null) {
nodeBeforeRange = node.getPreviousSibling();
}
}
}
if (deleteCount > 0) {
let nodeToDelete =
nodeBeforeRange === null
? this.getFirstChild()
: nodeBeforeRange.getNextSibling();
for (let i = 0; i < deleteCount; i++) {
if (nodeToDelete === null) {
invariant(false, 'splice: sibling not found');
}
const nextSibling = nodeToDelete.getNextSibling();
const nodeKeyToDelete = nodeToDelete.__key;
const writableNodeToDelete = nodeToDelete.getWritable();
$removeFromParent(writableNodeToDelete);
nodesToRemoveKeys.push(nodeKeyToDelete);
nodeToDelete = nextSibling;
}
}
let prevNode = nodeBeforeRange;
for (const nodeToInsert of nodesToInsert) {
if (prevNode !== null && nodeToInsert.is(prevNode)) {
nodeBeforeRange = prevNode = prevNode.getPreviousSibling();
}
const writableNodeToInsert = nodeToInsert.getWritable();
if (writableNodeToInsert.__parent === writableSelfKey) {
newSize--;
}
$removeFromParent(writableNodeToInsert);
const nodeKeyToInsert = nodeToInsert.__key;
if (prevNode === null) {
writableSelf.__first = nodeKeyToInsert;
writableNodeToInsert.__prev = null;
} else {
const writablePrevNode = prevNode.getWritable();
writablePrevNode.__next = nodeKeyToInsert;
writableNodeToInsert.__prev = writablePrevNode.__key;
}
if (nodeToInsert.__key === writableSelfKey) {
invariant(false, 'append: attempting to append self');
}
// Set child parent to self
writableNodeToInsert.__parent = writableSelfKey;
nodesToInsertKeys.push(nodeKeyToInsert);
prevNode = nodeToInsert;
}
if (start + deleteCount === oldSize) {
if (prevNode !== null) {
const writablePrevNode = prevNode.getWritable();
writablePrevNode.__next = null;
writableSelf.__last = prevNode.__key;
}
} else if (nodeAfterRange !== null) {
const writableNodeAfterRange = nodeAfterRange.getWritable();
if (prevNode !== null) {
const writablePrevNode = prevNode.getWritable();
writableNodeAfterRange.__prev = prevNode.__key;
writablePrevNode.__next = nodeAfterRange.__key;
} else {
writableNodeAfterRange.__prev = null;
}
}
writableSelf.__size = newSize;
// In case of deletion we need to adjust selection, unlink removed nodes
// and clean up node itself if it becomes empty. None of these needed
// for insertion-only cases
if (nodesToRemoveKeys.length) {
// Adjusting selection, in case node that was anchor/focus will be deleted
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const nodesToRemoveKeySet = new Set(nodesToRemoveKeys);
const nodesToInsertKeySet = new Set(nodesToInsertKeys);
const {anchor, focus} = selection;
if (isPointRemoved(anchor, nodesToRemoveKeySet, nodesToInsertKeySet)) {
moveSelectionPointToSibling(
anchor,
anchor.getNode(),
this,
nodeBeforeRange,
nodeAfterRange,
);
}
if (isPointRemoved(focus, nodesToRemoveKeySet, nodesToInsertKeySet)) {
moveSelectionPointToSibling(
focus,
focus.getNode(),
this,
nodeBeforeRange,
nodeAfterRange,
);
}
// Cleanup if node can't be empty
if (newSize === 0 && !this.canBeEmpty() && !$isRootOrShadowRoot(this)) {
this.remove();
}
}
}
return writableSelf;
}
/**
* @experimental
*
* An ElementNode subclass can override this to control where its children
* are inserted into the DOM, e.g. to add a wrapping node or accessory nodes
* before or after the children. The root of the node returned by createDOM
* must still be exactly one HTMLElement.
*/
getDOMSlot(element: HTMLElement): ElementDOMSlot<HTMLElement> {
return new ElementDOMSlot(element);
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const {element} = super.exportDOM(editor);
if (isHTMLElement(element)) {
const indent = this.getIndent();
if (indent > 0) {
// padding-inline-start is not widely supported in email HTML
// (see https://www.caniemail.com/features/css-padding-inline-start-end/),
// If you want to use HTML output for email, consider overriding the serialization
// to use `padding-right` in RTL languages, `padding-left` in `LTR` languages, or
// `text-indent` if you are ok with first-line indents.
// We recommend keeping multiples of 40px to maintain consistency with list-items
// (see https://github.com/facebook/lexical/pull/4025)
element.style.paddingInlineStart = `${indent * 40}px`;
// Authoritative round-trip signal. padding-inline-start can be a
// non-40px multiple (custom `--lexical-indent-base-value`) or a
// `calc(...)` expression on the live DOM, neither of which the
// padding-based heuristic in setNodeIndentFromDOM can recover.
element.setAttribute('data-lexical-indent', String(indent));
}
const direction = this.getDirection();
if (direction) {
element.dir = direction;
}
}
return {element};
}
// JSON serialization
exportJSON(): SerializedElementNode {
const json: SerializedElementNode = {
children: [],
direction: this.getDirection(),
format: this.getFormatType(),
indent: this.getIndent(),
// As an exception here we invoke super at the end for historical reasons.
// Namely, to preserve the order of the properties and not to break the tests
// that use the serialized string representation.
...super.exportJSON(),
};
const textFormat = this.getTextFormat();
const textStyle = this.getTextStyle();
// Only persist for cases when there are no TextNode children from which
// these would be set on reconcile (#7968)
if (
(textFormat !== 0 || textStyle !== '') &&
!$isRootOrShadowRoot(this) &&
!this.getChildren().some($isTextNode)
) {
if (textFormat !== 0) {
json.textFormat = textFormat;
}
if (textStyle !== '') {
json.textStyle = textStyle;
}
}
return json;
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedElementNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setFormat(serializedNode.format)
.setIndent(serializedNode.indent)
.setDirection(serializedNode.direction)
.setTextFormat(serializedNode.textFormat || 0)
.setTextStyle(serializedNode.textStyle || '');
}
// These are intended to be extends for specific element heuristics.
insertNewAfter(
selection: RangeSelection,
restoreSelection?: boolean,
): null | LexicalNode {
return null;
}
canIndent(): boolean {
return true;
}
/*
* This method controls the behavior of the node during backwards
* deletion (i.e., backspace) when selection is at the beginning of
* the node (offset 0). You may use this to have the node replace
* itself, change its state, or do nothing. When you do make such
* a change, you should return true.
*
* When true is returned, the collapse phase will stop.
* When false is returned, and isInline() is true, and getPreviousSibling() is null,
* then this function will be called on its parent.
*/
collapseAtStart(selection: RangeSelection): boolean {
return false;
}
excludeFromCopy(destination?: 'clone' | 'html'): boolean {
return false;
}
/** @deprecated @internal */
canReplaceWith(replacement: LexicalNode): boolean {
return true;
}
/** @deprecated @internal */
canInsertAfter(node: LexicalNode): boolean {
return true;
}
canBeEmpty(): boolean {
return true;
}
canInsertTextBefore(): boolean {
return true;
}
canInsertTextAfter(): boolean {
return true;
}
/**
* If the method is overridden and returns true, ensure that `canBeEmpty()`
* returns false for the inline node to work correctly
*/
isInline(): boolean {
return false;
}
// A shadow root is a Node that behaves like RootNode. The shadow root (and RootNode) mark the
// end of the hierarchy, most implementations should treat it as there's nothing (upwards)
// beyond this point. For example, node.getTopLevelElement(), when performed inside a TableCellNode
// will return the immediate first child underneath TableCellNode instead of RootNode.
isShadowRoot(): boolean {
return false;
}
/** @deprecated @internal */
canMergeWith(node: ElementNode): boolean {
return false;
}
extractWithChild(
child: LexicalNode,
selection: BaseSelection | null,
destination: 'clone' | 'html',
): boolean {
return false;
}
/**
* Determines whether this node, when empty, can merge with a first block
* of nodes being inserted.
*
* This method is specifically called in {@link RangeSelection.insertNodes}
* to determine merging behavior during nodes insertion.
*
* @example
* // In a ListItemNode or QuoteNode implementation:
* canMergeWhenEmpty(): true {
* return true;
* }
*/
canMergeWhenEmpty(): boolean {
return false;
}
/** @internal */
reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void {
const slot = $getDOMSlot(this, dom, editor);
let currentDOM = slot.getFirstChild();
for (
let currentNode = this.getFirstChild();
currentNode;
currentNode = currentNode.getNextSibling()
) {
const correctDOM = editor.getElementByKey(currentNode.getKey());
if (correctDOM === null) {
continue;
}
if (currentDOM == null) {
slot.insertChild(correctDOM);
currentDOM = correctDOM;
} else if (currentDOM !== correctDOM) {
slot.replaceChild(correctDOM, currentDOM);
}
currentDOM = currentDOM.nextSibling;
}
}
}
export function $isElementNode(
node: LexicalNode | null | undefined,
): node is ElementNode {
return node instanceof ElementNode;
}
function isPointRemoved(
point: PointType,
nodesToRemoveKeySet: Set<NodeKey>,
nodesToInsertKeySet: Set<NodeKey>,
): boolean {
let node: ElementNode | TextNode | null = point.getNode();
while (node) {
const nodeKey = node.__key;
if (nodesToRemoveKeySet.has(nodeKey) && !nodesToInsertKeySet.has(nodeKey)) {
return true;
}
node = node.getParent();
}
return false;
}