UNPKG

lexical

Version:

Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.

815 lines (784 loc) • 24.7 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type { DOMExportOutput, NodeKey, SerializedLexicalNode, } from '../LexicalNode'; import type { BaseSelection, PointType, RangeSelection, } from '../LexicalSelection'; import type { KlassConstructor, LexicalEditor, LexicalUpdateJSON, Spread, TextFormatType, } from 'lexical'; import invariant from '@lexical/internal/invariant'; import {$isTextNode, 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, LexicalNode} from '../LexicalNode'; import { $getSelection, $internalMakeRangeSelection, $isRangeSelection, moveSelectionPointToSibling, } from '../LexicalSelection'; import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { $getDOMSlot, $getNodeByKey, $isRootOrShadowRoot, isHTMLElement, removeFromParent, toggleTextFormatType, } from '../LexicalUtils'; export type SerializedElementNode< T extends SerializedLexicalNode = SerializedLexicalNode, > = Spread< { children: Array<T>; direction: 'ltr' | 'rtl' | null; format: ElementFormatType; indent: number; textFormat?: number; textStyle?: string; }, SerializedLexicalNode >; export type ElementFormatType = | 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; // 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 { /** @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; 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 = ''; } afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); if (this.__key === prevNode.__key) { this.__first = prevNode.__first; this.__last = prevNode.__last; this.__size = prevNode.__size; } 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; } getChildren<T extends LexicalNode>(): Array<T> { const children: Array<T> = []; let child: T | null = this.getFirstChild(); while (child !== null) { children.push(child); child = child.getNextSibling(); } return children; } getChildrenKeys(): Array<NodeKey> { const children: Array<NodeKey> = []; let child: LexicalNode | null = 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 { return this.getChildrenSize() === 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(): Array<TextNode> { const textNodes = []; 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; } getFirstDescendant<T extends LexicalNode>(): null | T { let node = this.getFirstChild<T>(); while ($isElementNode(node)) { const child = node.getFirstChild<T>(); if (child === null) { break; } node = child; } return node; } getLastDescendant<T extends LexicalNode>(): null | T { let node = this.getLastChild<T>(); while ($isElementNode(node)) { const child = node.getLastChild<T>(); if (child === null) { break; } node = child; } return node; } getDescendantByIndex<T extends LexicalNode>(index: number): null | T { const children = this.getChildren<T>(); 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 ); } getFirstChild<T extends LexicalNode>(): null | T { const self = this.getLatest(); const firstKey = self.__first; return firstKey === null ? null : $getNodeByKey<T>(firstKey); } getFirstChildOrThrow<T extends LexicalNode>(): T { const firstChild = this.getFirstChild<T>(); if (firstChild === null) { invariant(false, 'Expected node %s to have a first child.', this.__key); } return firstChild; } getLastChild<T extends LexicalNode>(): null | T { const self = this.getLatest(); const lastKey = self.__last; return lastKey === null ? null : $getNodeByKey<T>(lastKey); } getLastChildOrThrow<T extends LexicalNode>(): T { const lastChild = this.getLastChild<T>(); if (lastChild === null) { invariant(false, 'Expected node %s to have a last child.', this.__key); } return lastChild; } getChildAtIndex<T extends LexicalNode>(index: number): null | T { const size = this.getChildrenSize(); let node: null | T; let i; if (index < size / 2) { node = this.getFirstChild<T>(); i = 0; while (node !== null && i <= index) { if (i === index) { return node; } node = node.getNextSibling(); i++; } return null; } node = this.getLastChild<T>(); i = size - 1; while (node !== null && i >= index) { if (i === index) { return node; } node = node.getPreviousSibling(); i--; } return null; } getTextContent(): string { let textContent = ''; 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 { let textContentSize = 0; 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: Array<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), ); 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; }