UNPKG

lexical

Version:

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

1,519 lines (1,427 loc) 159 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 {LexicalEditor} from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from '@lexical/internal/invariant'; import { $caretFromPoint, $caretRangeFromSelection, $comparePointCaretNext, $createLineBreakNode, $createParagraphNode, $createTextNode, $extendCaretToRange, $getAdjacentChildCaret, $getCaretRange, $getCaretRangeInDirection, $getChildCaret, $getSiblingCaret, $getTextNodeOffset, $insertNodeToNearestRootAtCaret, $isChildCaret, $isDecoratorNode, $isElementNode, $isExtendableTextPointCaret, $isLineBreakNode, $isParagraphNode, $isRootNode, $isSiblingCaret, $isTextNode, $isTextPointCaret, $normalizeCaret, $removeTextFromCaretRange, $rewindSiblingCaret, $setPointFromCaret, $setSelection, $setSelectionFromCaretRange, $updateRangeSelectionFromCaretRange, type CaretRange, type ChildCaret, COLLABORATION_TAG, type LineBreakNode, type NodeCaret, type PointCaret, SKIP_SCROLL_INTO_VIEW_TAG, type TextNode, } from '.'; import {IS_FIREFOX} from './environment'; import {TEXT_TYPE_TO_FORMAT} from './LexicalConstants'; import { markCollapsedSelectionFormat, markSelectionChangeFromDOMUpdate, } from './LexicalEvents'; import {getIsProcessingMutations} from './LexicalMutations'; import {insertRangeAfter, type LexicalNode, type NodeKey} from './LexicalNode'; import {$normalizeSelection} from './LexicalNormalization'; import { $getSlot, $getSlotFrame, $getSlotHost, $getSlotHostKey, $getSlotMap, $getSlotNames, } from './LexicalSlot'; import { getActiveEditor, getActiveEditorState, isCurrentlyReadOnlyMode, } from './LexicalUpdates'; import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags'; import { $findMatchingParent, $getCompositionKey, $getDOMSlot, $getDOMTextNode, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeFromDOM, $getRoot, $hasAncestor, $isInlineElementOrDecoratorNode, $isRootOrShadowRoot, $isSelectionCapturedInDecoratorInput, $isTokenOrSegmented, $isTokenOrTab, $setCompositionKey, doesContainSurrogatePair, getActiveElement, getActiveElementDeep, getComposedStaticRange, getDOMSelection, getDOMSelectionPoints, getDOMSelectionRange, getElementByKeyOrThrow, getNearestEditorFromDOMNode, getNodeKeyFromDOMNode, getWindow, INTERNAL_$isBlock, isDOMCapturingSelection, isDOMDocumentNode, isDOMShadowRoot, isDOMTextNode, isHTMLElement, isSelectionWithinEditor, removeDOMBlockCursorElement, scrollIntoViewIfNeeded, toggleTextFormatType, } from './LexicalUtils'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; import { $isInlineFormattable, type TextFormatType, } from './nodes/LexicalTextNode'; const __DEV__ = process.env.NODE_ENV !== 'production'; export type TextPointType = { _selection: BaseSelection; getNode: () => TextNode; is: (point: PointType) => boolean; isBefore: (point: PointType) => boolean; key: NodeKey; offset: number; set: ( key: NodeKey, offset: number, type: 'text' | 'element', onlyIfChanged?: boolean, ) => void; type: 'text'; }; export type ElementPointType = { _selection: BaseSelection; getNode: () => ElementNode; is: (point: PointType) => boolean; isBefore: (point: PointType) => boolean; key: NodeKey; offset: number; set: ( key: NodeKey, offset: number, type: 'text' | 'element', onlyIfChanged?: boolean, ) => void; type: 'element'; }; export type PointType = TextPointType | ElementPointType; export class Point { key: NodeKey; offset: number; type: 'text' | 'element'; _selection: BaseSelection | null; constructor(key: NodeKey, offset: number, type: 'text' | 'element') { if (__DEV__) { // This prevents a circular reference error when serialized as JSON, // which happens on unit test failures Object.defineProperty(this, '_selection', { enumerable: false, writable: true, }); } this._selection = null; this.key = key; this.offset = offset; this.type = type; } is(point: PointType): boolean { return ( this.key === point.key && this.offset === point.offset && this.type === point.type ); } isBefore(b: PointType): boolean { if (this.key === b.key) { return this.offset < b.offset; } const aCaret = $normalizeCaret($caretFromPoint(this, 'next')); const bCaret = $normalizeCaret($caretFromPoint(b, 'next')); return $comparePointCaretNext(aCaret, bCaret) < 0; } getNode(): LexicalNode { const key = this.key; const node = $getNodeByKey(key); if (node === null) { invariant(false, 'Point.getNode: node not found'); } return node; } set( key: NodeKey, offset: number, type: 'text' | 'element', onlyIfChanged?: boolean, ): void { const selection = this._selection; const oldKey = this.key; if ( onlyIfChanged && this.key === key && this.offset === offset && this.type === type ) { return; } this.key = key; this.offset = offset; this.type = type; if (__DEV__) { const node = $getNodeByKey(key); invariant( type === 'text' ? $isTextNode(node) : $isElementNode(node), 'PointType.set: node with key %s is %s and can not be used for a %s point', key, node ? node.__type : '[not found]', type, ); } if (!isCurrentlyReadOnlyMode()) { if ($getCompositionKey() === oldKey) { $setCompositionKey(key); } if (selection !== null) { selection.setCachedNodes(null); if ($isRangeSelection(selection)) { selection._cachedIsBackward = null; } selection.dirty = true; } } } } export function $createPoint( key: NodeKey, offset: number, type: 'text' | 'element', ): PointType { // @ts-expect-error: intentionally cast as we use a class for perf reasons return new Point(key, offset, type); } function selectPointOnNode(point: PointType, node: LexicalNode): void { let key = node.__key; let offset = point.offset; let type: 'element' | 'text' = 'element'; if ($isTextNode(node)) { type = 'text'; const textContentLength = node.getTextContentSize(); if (offset > textContentLength) { offset = textContentLength; } } else if (!$isElementNode(node)) { const nextSibling = node.getNextSibling(); if ($isTextNode(nextSibling)) { key = nextSibling.__key; offset = 0; type = 'text'; } else { const parentNode = node.getParent(); if (parentNode) { key = parentNode.__key; offset = node.getIndexWithinParent() + 1; } } } point.set(key, offset, type); } export function $moveSelectionPointToEnd( point: PointType, node: LexicalNode, ): void { if ($isElementNode(node)) { const lastNode = node.getLastDescendant(); if ($isElementNode(lastNode) || $isTextNode(lastNode)) { selectPointOnNode(point, lastNode); } else { selectPointOnNode(point, node); } } else { selectPointOnNode(point, node); } } function $transferStartingElementPointToTextPoint( start: ElementPointType, end: PointType, format: number, style: string, ): void { const element = start.getNode(); const placementNode = element.getChildAtIndex(start.offset); const textNode = $createTextNode(); textNode.setFormat(format); textNode.setStyle(style); if ($isParagraphNode(placementNode)) { placementNode.splice(0, 0, [textNode]); } else if (placementNode !== null) { // root or shadow-root + element-mode anchor before a non-paragraph // child (typically a sibling block decorator): wrap the new text in // a paragraph so it stays a valid block-level child of the root or // slot frame. The last-offset branch below already covers shadow // roots; the in-the-middle case used to drop a raw text node next // to the decorator, which leaves the text without a block ancestor // and breaks every downstream getTopLevelElement / $findMatchingParent // walk (Cmd+A, Enter, etc.). const target = $isRootOrShadowRoot(element) ? $createParagraphNode().append(textNode) : textNode; placementNode.insertBefore(target); } else if ($isRootOrShadowRoot(element)) { // root or shadow-root + last-offset typing: reuse the empty trailing // block when one exists (typical state after a sibling block decorator // was deleted) instead of appending a fresh paragraph. The old behavior // left a phantom empty paragraph above the user's input. const lastChild = element.getLastChild(); if ( $isElementNode(lastChild) && !lastChild.isInline() && lastChild.isEmpty() ) { lastChild.append(textNode); } else { element.append($createParagraphNode().append(textNode)); } } else { element.append(textNode); } // Transfer the element point to a text point. if (start.is(end)) { end.set(textNode.__key, 0, 'text'); } start.set(textNode.__key, 0, 'text'); } export interface BaseSelection { _cachedNodes: LexicalNode[] | null; dirty: boolean; clone(): BaseSelection; extract(): LexicalNode[]; getNodes(): LexicalNode[]; getTextContent(): string; insertText(text: string): void; insertRawText(text: string): void; is(selection: null | BaseSelection): boolean; insertNodes(nodes: LexicalNode[]): void; getStartEndPoints(): null | [PointType, PointType]; isCollapsed(): boolean; isBackward(): boolean; getCachedNodes(): LexicalNode[] | null; setCachedNodes(nodes: LexicalNode[] | null): void; } export class NodeSelection implements BaseSelection { _nodes: Set<NodeKey>; _cachedNodes: LexicalNode[] | null; dirty: boolean; constructor(objects: Set<NodeKey>) { this._cachedNodes = null; this._nodes = objects; this.dirty = false; } getCachedNodes(): LexicalNode[] | null { return this._cachedNodes; } setCachedNodes(nodes: LexicalNode[] | null): void { this._cachedNodes = nodes; } is(selection: null | BaseSelection): boolean { if (!$isNodeSelection(selection)) { return false; } const a: Set<NodeKey> = this._nodes; const b: Set<NodeKey> = selection._nodes; return a.size === b.size && Array.from(a).every(key => b.has(key)); } isCollapsed(): boolean { return false; } isBackward(): boolean { return false; } getStartEndPoints(): null { return null; } add(key: NodeKey): void { this.dirty = true; this._nodes.add(key); this._cachedNodes = null; } delete(key: NodeKey): void { this.dirty = true; this._nodes.delete(key); this._cachedNodes = null; } clear(): void { this.dirty = true; this._nodes.clear(); this._cachedNodes = null; } has(key: NodeKey): boolean { return this._nodes.has(key); } clone(): NodeSelection { return new NodeSelection(new Set(this._nodes)); } extract(): LexicalNode[] { return this.getNodes(); } insertRawText(text: string): void { // Do nothing? } insertText(): void { // Do nothing? } insertNodes(nodes: LexicalNode[]) { // Slotted nodes are fixed parts of their host with no parent, so they // can't be inserted around or removed (see $removeNode's slot guard). // Skip them; if nothing tree-resident is selected there's nowhere to // anchor the insertion. const selectedNodes = this.getNodes().filter( node => $getSlotHostKey(node) === null, ); const selectedNodesLength = selectedNodes.length; if (selectedNodesLength === 0) { return; } const lastSelectedNode = selectedNodes[selectedNodesLength - 1]; let selectionAtEnd: RangeSelection; // Insert nodes if ($isTextNode(lastSelectedNode)) { selectionAtEnd = lastSelectedNode.select(); } else { const index = lastSelectedNode.getIndexWithinParent() + 1; selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index); } selectionAtEnd.insertNodes(nodes); // Remove selected nodes for (let i = 0; i < selectedNodesLength; i++) { selectedNodes[i].remove(); } } getNodes(): LexicalNode[] { const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } const objects = this._nodes; const nodes = []; for (const object of objects) { const node = $getNodeByKey(object); if (node !== null) { nodes.push(node); } } if (!isCurrentlyReadOnlyMode()) { this._cachedNodes = nodes; } return nodes; } getTextContent(): string { const nodes = this.getNodes(); let textContent = ''; for (let i = 0; i < nodes.length; i++) { textContent += nodes[i].getTextContent(); } return textContent; } /** * Remove all nodes in the NodeSelection. If there were any nodes, * replace the selection with a new RangeSelection at the previous * location of the first node. */ deleteNodes(): void { // Slotted nodes are fixed parts of their host; skip them so we neither // build a caret from a parentless node nor hit $removeNode's slot guard. const nodes = this.getNodes().filter( node => $getSlotHostKey(node) === null, ); if (($getSelection() || $getPreviousSelection()) === this && nodes[0]) { const firstCaret = $getSiblingCaret(nodes[0], 'next'); $setSelectionFromCaretRange($getCaretRange(firstCaret, firstCaret)); } for (const node of nodes) { node.remove(); } $ensureRootHasParagraph(); } } function $ensureRootHasParagraph(): void { const root = $getRoot(); if (root.isEmpty()) { const paragraph = $createParagraphNode(); root.append(paragraph); paragraph.select(); } } export function $isRangeSelection(x: unknown): x is RangeSelection { return x instanceof RangeSelection; } export class RangeSelection implements BaseSelection { format: number; style: string; anchor: PointType; focus: PointType; _cachedNodes: LexicalNode[] | null; /** @internal */ _cachedIsBackward: boolean | null; dirty: boolean; constructor( anchor: PointType, focus: PointType, format: number, style: string, ) { this.anchor = anchor; this.focus = focus; anchor._selection = this; focus._selection = this; this._cachedNodes = null; this._cachedIsBackward = null; this.format = format; this.style = style; this.dirty = false; } getCachedNodes(): LexicalNode[] | null { return this._cachedNodes; } setCachedNodes(nodes: LexicalNode[] | null): void { this._cachedNodes = nodes; } /** * Used to check if the provided selections is equal to this one by value, * including anchor, focus, format, and style properties. * @param selection - the Selection to compare this one to. * @returns true if the Selections are equal, false otherwise. */ is(selection: null | BaseSelection): boolean { if (!$isRangeSelection(selection)) { return false; } return ( this.anchor.is(selection.anchor) && this.focus.is(selection.focus) && this.format === selection.format && this.style === selection.style ); } /** * Returns whether the Selection is "collapsed", meaning the anchor and focus are * the same node and have the same offset. * * @returns true if the Selection is collapsed, false otherwise. */ isCollapsed(): boolean { return this.anchor.is(this.focus); } /** * Gets all the nodes in the Selection. Uses caching to make it generally suitable * for use in hot paths. * * See also the {@link CaretRange} APIs (starting with * {@link $caretRangeFromSelection}), which are likely to provide a better * foundation for any operation where partial selection is relevant * (e.g. the anchor or focus are inside an ElementNode and TextNode) * * @returns an Array containing all the nodes in the Selection */ getNodes(): LexicalNode[] { const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } const range = $getCaretRangeInDirection( $caretRangeFromSelection(this), 'next', ); const nodes = $getNodesFromCaretRangeCompat(range); if (__DEV__) { if (this.isCollapsed() && nodes.length > 1) { invariant( false, 'RangeSelection.getNodes() returned %s > 1 nodes in a collapsed selection', String(nodes.length), ); } } if (!isCurrentlyReadOnlyMode()) { this._cachedNodes = nodes; } return nodes; } /** * Sets this Selection to be of type "text" at the provided anchor and focus values. * * @param anchorNode - the anchor node to set on the Selection * @param anchorOffset - the offset to set on the Selection * @param focusNode - the focus node to set on the Selection * @param focusOffset - the focus offset to set on the Selection */ setTextNodeRange( anchorNode: TextNode, anchorOffset: number, focusNode: TextNode, focusOffset: number, ): this { this.anchor.set(anchorNode.__key, anchorOffset, 'text'); this.focus.set(focusNode.__key, focusOffset, 'text'); return this; } /** * Gets the (plain) text content of all the nodes in the selection. * * @returns a string representing the text content of all the nodes in the Selection */ getTextContent(): string { const nodes = this.getNodes(); if (nodes.length === 0) { return ''; } const firstNode = nodes[0]; const lastNode = nodes[nodes.length - 1]; const anchor = this.anchor; const focus = this.focus; const isBefore = anchor.isBefore(focus); const [anchorOffset, focusOffset] = $getCharacterOffsets(this); let textContent = ''; let prevWasElement = true; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ($isElementNode(node) && !node.isInline()) { if (!prevWasElement) { textContent += '\n'; } // Slots are isolated shadow roots, so getNodes() never descends into // them; append their text here (slots-first, mirroring // ElementNode.getTextContent) so a selection enclosing the host // carries its slot content. let slotText = ''; for (const slotName of $getSlotNames(node)) { const slot = $getSlot(node, slotName); if (slot !== null) { slotText += slot.getTextContent(); } } if (slotText !== '') { textContent += slotText; prevWasElement = false; } else if (node.isEmpty()) { prevWasElement = false; } else { prevWasElement = true; } } else { prevWasElement = false; if ($isTextNode(node)) { let text = node.getTextContent(); if (node === firstNode) { if (node === lastNode) { if ( anchor.type !== 'element' || focus.type !== 'element' || focus.offset === anchor.offset ) { text = anchorOffset < focusOffset ? text.slice(anchorOffset, focusOffset) : text.slice(focusOffset, anchorOffset); } } else { text = isBefore ? text.slice(anchorOffset) : text.slice(focusOffset); } } else if (node === lastNode) { text = isBefore ? text.slice(0, focusOffset) : text.slice(0, anchorOffset); } textContent += text; } else if ( ($isDecoratorNode(node) || $isLineBreakNode(node)) && (node !== lastNode || !this.isCollapsed()) ) { textContent += node.getTextContent(); } } } return textContent; } /** * Attempts to map a DOM selection range onto this Lexical Selection, * setting the anchor, focus, and type accordingly * * @param range a DOM Selection range conforming to the StaticRange interface. */ applyDOMRange(range: StaticRange): void { const editor = getActiveEditor(); const currentEditorState = editor.getEditorState(); const lastSelection = currentEditorState._selection; const resolvedSelectionPoints = $internalResolveSelectionPoints( range.startContainer, range.startOffset, range.endContainer, range.endOffset, editor, lastSelection, ); if (resolvedSelectionPoints === null) { return; } const [anchorPoint, focusPoint, dirty] = resolvedSelectionPoints; this.anchor.set( anchorPoint.key, anchorPoint.offset, anchorPoint.type, true, ); this.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type, true); if (dirty) { this.dirty = true; } // Firefox will use an element point rather than a text point in some cases, // so we normalize for that $normalizeSelection(this); } /** * Creates a new RangeSelection, copying over all the property values from this one. * * @returns a new RangeSelection with the same property values as this one. */ clone(): RangeSelection { const anchor = this.anchor; const focus = this.focus; const selection = new RangeSelection( $createPoint(anchor.key, anchor.offset, anchor.type), $createPoint(focus.key, focus.offset, focus.type), this.format, this.style, ); return selection; } /** * Toggles the provided format on all the TextNodes in the Selection. * * @param format a string TextFormatType to toggle on the TextNodes in the selection */ toggleFormat(format: TextFormatType): void { this.format = toggleTextFormatType(this.format, format, null); this.dirty = true; } /** * Sets the value of the format property on the Selection * * @param format - the format to set at the value of the format property. */ setFormat(format: number): void { this.format = format; this.dirty = true; } /** * Sets the value of the style property on the Selection * * @param style - the style to set at the value of the style property. */ setStyle(style: string): void { this.style = style; this.dirty = true; } /** * Returns whether the provided TextFormatType is present on the Selection. This will be true if all text nodes in the Selection * have the specified format. * * @param type the TextFormatType to check for. * @returns true if the provided format is currently toggled on the Selection, false otherwise. */ hasFormat(type: TextFormatType): boolean { const formatFlag = TEXT_TYPE_TO_FORMAT[type]; return (this.format & formatFlag) !== 0; } /** * Attempts to insert the provided text into the EditorState at the current Selection. * converts tabs, newlines, and carriage returns into LexicalNodes. * * @param text the text to insert into the Selection */ insertRawText(text: string): void { this.insertNodes($generateNodesFromRawText(text)); } /** * Insert the provided text into the EditorState at the current Selection. * * @param text the text to insert into the Selection */ insertText(text: string): void { // Now that "removeText" has been improved and does not depend on // insertText, insertText can be greatly simplified. The next // commented version is a WIP (about 5 tests fail). // // this.removeText(); // if (text === '') { // return; // } // const anchorNode = this.anchor.getNode(); // const textNode = $createTextNode(text); // textNode.setFormat(this.format); // textNode.setStyle(this.style); // if ($isTextNode(anchorNode)) { // const parent = anchorNode.getParentOrThrow(); // if (this.anchor.offset === 0) { // if (parent.isInline() && !anchorNode.__prev) { // parent.insertBefore(textNode); // } else { // anchorNode.insertBefore(textNode); // } // } else if (this.anchor.offset === anchorNode.getTextContentSize()) { // if (parent.isInline() && !anchorNode.__next) { // parent.insertAfter(textNode); // } else { // anchorNode.insertAfter(textNode); // } // } else { // const [before] = anchorNode.splitText(this.anchor.offset); // before.insertAfter(textNode); // } // } else { // anchorNode.splice(this.anchor.offset, 0, [textNode]); // } // const nodeToSelect = textNode.isAttached() ? textNode : anchorNode; // nodeToSelect.selectEnd(); // // When composing, we need to adjust the anchor offset so that // // we correctly replace that right range. // if ( // textNode.isComposing() && // this.anchor.type === 'text' && // anchorNode.getTextContent() !== '' // ) { // this.anchor.offset -= text.length; // } const anchor = this.anchor; const focus = this.focus; const format = this.format; const style = this.style; let firstPoint = anchor; let endPoint = focus; if (!this.isCollapsed() && focus.isBefore(anchor)) { firstPoint = focus; endPoint = anchor; } if (firstPoint.type === 'element') { $transferStartingElementPointToTextPoint( firstPoint, endPoint, format, style, ); } if (endPoint.type === 'element') { $setPointFromCaret( endPoint, $normalizeCaret($caretFromPoint(endPoint, 'next')), ); } const startOffset = firstPoint.offset; let endOffset = endPoint.offset; const selectedNodes = this.getNodes(); const selectedNodesLength = selectedNodes.length; let firstNode: TextNode = selectedNodes[0] as TextNode; invariant( $isTextNode(firstNode), 'insertText: first node is not a text node', ); const firstNodeText = firstNode.getTextContent(); const firstNodeTextLength = firstNodeText.length; const firstNodeParent = firstNode.getParentOrThrow(); const lastIndex = selectedNodesLength - 1; let lastNode = selectedNodes[lastIndex]; if (selectedNodesLength === 1 && endPoint.type === 'element') { endOffset = firstNodeTextLength; endPoint.set(firstPoint.key, endOffset, 'text'); } if ( this.isCollapsed() && startOffset === firstNodeTextLength && ($isTokenOrSegmented(firstNode) || !firstNode.canInsertTextAfter() || (!firstNodeParent.canInsertTextAfter() && firstNode.getNextSibling() === null)) ) { const candidateNextSibling = firstNode.getNextSibling(); let nextSibling: TextNode; if ( !$isTextNode(candidateNextSibling) || !candidateNextSibling.canInsertTextBefore() || $isTokenOrSegmented(candidateNextSibling) ) { nextSibling = $createTextNode(); nextSibling.setFormat(format); nextSibling.setStyle(style); if (!firstNodeParent.canInsertTextAfter()) { firstNodeParent.insertAfter(nextSibling); } else { firstNode.insertAfter(nextSibling); } } else { nextSibling = candidateNextSibling; } nextSibling.select(0, 0); firstNode = nextSibling; if (text !== '') { this.insertText(text); return; } } else if ( this.isCollapsed() && startOffset === 0 && ($isTokenOrSegmented(firstNode) || !firstNode.canInsertTextBefore() || (!firstNodeParent.canInsertTextBefore() && firstNode.getPreviousSibling() === null)) ) { const candidatePrevSibling = firstNode.getPreviousSibling(); let prevSibling: TextNode; if ( !$isTextNode(candidatePrevSibling) || $isTokenOrSegmented(candidatePrevSibling) ) { prevSibling = $createTextNode(); prevSibling.setFormat(format); if (!firstNodeParent.canInsertTextBefore()) { firstNodeParent.insertBefore(prevSibling); } else { firstNode.insertBefore(prevSibling); } } else { prevSibling = candidatePrevSibling; } prevSibling.select(); firstNode = prevSibling; if (text !== '') { this.insertText(text); return; } } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) { if ($getCompositionKey() !== null) { // Preserve the DOM element for the browser's composition tracker. // The subclass instance stays in normal mode until composition ends, // when $cleanupComposedSubclass replaces it with a plain TextNode. firstNode = firstNode .setMode('normal') .setFormat(format) .setStyle(style); } else { const textNode = $createTextNode(firstNode.getTextContent()); textNode.setFormat(format); firstNode.replace(textNode); firstNode = textNode; } } else if (!this.isCollapsed() && text !== '') { // When the firstNode or lastNode parents are elements that // do not allow text to be inserted before or after, we first // clear the content. Then we normalize selection, then insert // the new content. const lastNodeParent = lastNode.getParent(); if ( !firstNodeParent.canInsertTextBefore() || !firstNodeParent.canInsertTextAfter() || ($isElementNode(lastNodeParent) && (!lastNodeParent.canInsertTextBefore() || !lastNodeParent.canInsertTextAfter())) ) { this.insertText(''); $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null); this.insertText(text); return; } } if (selectedNodesLength === 1) { if ($isTokenOrTab(firstNode)) { const textNode = $createTextNode(text); textNode.select(); firstNode.replace(textNode); return; } const firstNodeFormat = firstNode.getFormat(); const firstNodeStyle = firstNode.getStyle(); if ( startOffset === endOffset && (firstNodeFormat !== format || firstNodeStyle !== style) ) { if (firstNode.getTextContent() === '') { firstNode.setFormat(format); firstNode.setStyle(style); } else { const textNode = $createTextNode(text); textNode.setFormat(format); textNode.setStyle(style); textNode.select(); if (startOffset === 0) { firstNode.insertBefore(textNode, false); } else { const [targetNode] = firstNode.splitText(startOffset); targetNode.insertAfter(textNode, false); } // When composing, we need to adjust the anchor offset so that // we correctly replace that right range. if (textNode.isComposing() && this.anchor.type === 'text') { this.anchor.offset -= text.length; this._cachedNodes = null; this._cachedIsBackward = null; } return; } } else if ($isTabNode(firstNode)) { // We don't need to check for delCount because there is only the entire selected node case // that can hit here for content size 1 and with canInsertTextBeforeAfter false const textNode = $createTextNode(text); textNode.setFormat(format); textNode.setStyle(style); textNode.select(); firstNode.replace(textNode); return; } const delCount = endOffset - startOffset; firstNode = firstNode.spliceText(startOffset, delCount, text, true); if (firstNode.getTextContent() === '') { firstNode.remove(); } else if (this.anchor.type === 'text') { this.format = firstNodeFormat; this.style = firstNodeStyle; if (firstNode.isComposing()) { // When composing, we need to adjust the anchor offset so that // we correctly replace that right range. this.anchor.offset -= text.length; this._cachedNodes = null; this._cachedIsBackward = null; } } } else { const markedNodeKeysForKeep = new Set([ ...firstNode.getParentKeys(), ...lastNode.getParentKeys(), ]); // We have to get the parent elements before the next section, // as in that section we might mutate the lastNode. const firstElement = $isElementNode(firstNode) ? firstNode : firstNode.getParentOrThrow(); let lastElement = $isElementNode(lastNode) ? lastNode : lastNode.getParentOrThrow(); let lastElementChild = lastNode; // If the last element is inline, we should instead look at getting // the nodes of its parent, rather than itself. This behavior will // then better match how text node insertions work. We will need to // also update the last element's child accordingly as we do this. if (!firstElement.is(lastElement) && lastElement.isInline()) { // Keep traversing till we have a non-inline element parent. do { lastElementChild = lastElement; lastElement = lastElement.getParentOrThrow(); } while (lastElement.isInline()); } // Handle mutations to the last node. if ( (endPoint.type === 'text' && (endOffset !== 0 || lastNode.getTextContent() === '')) || (endPoint.type === 'element' && lastNode.getIndexWithinParent() < endOffset) ) { if ( $isTextNode(lastNode) && !$isTokenOrTab(lastNode) && endOffset !== lastNode.getTextContentSize() ) { if (lastNode.isSegmented()) { const textNode = $createTextNode(lastNode.getTextContent()); lastNode.replace(textNode); lastNode = textNode; } // root node selections only select whole nodes, so no text splice is necessary if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') { invariant( $isTextNode(lastNode), 'insertText: lastNode is not a TextNode', ); lastNode = lastNode.spliceText(0, endOffset, ''); } markedNodeKeysForKeep.add(lastNode.__key); } else { const lastNodeParent = lastNode.getParentOrThrow(); if ( !lastNodeParent.canBeEmpty() && lastNodeParent.getChildrenSize() === 1 ) { lastNodeParent.remove(); } else { lastNode.remove(); } } } else { markedNodeKeysForKeep.add(lastNode.__key); } // Either move the remaining nodes of the last parent to after // the first child, or remove them entirely. If the last parent // is the same as the first parent, this logic also works. const lastNodeChildren = lastElement.getChildren(); const selectedNodesSet = new Set(selectedNodes); const firstAndLastElementsAreEqual = firstElement.is(lastElement); // We choose a target to insert all nodes after. In the case of having // and inline starting parent element with a starting node that has no // siblings, we should insert after the starting parent element, otherwise // we will incorrectly merge into the starting parent element. // TODO: should we keep on traversing parents if we're inside another // nested inline element? const insertionTarget = firstElement.isInline() && firstNode.getNextSibling() === null ? firstElement : firstNode; for (let i = lastNodeChildren.length - 1; i >= 0; i--) { const lastNodeChild = lastNodeChildren[i]; if ( lastNodeChild.is(firstNode) || ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode)) ) { break; } if (lastNodeChild.isAttached()) { if ( !selectedNodesSet.has(lastNodeChild) || lastNodeChild.is(lastElementChild) ) { if (!firstAndLastElementsAreEqual) { insertionTarget.insertAfter(lastNodeChild, false); } } else { lastNodeChild.remove(); } } } if (!firstAndLastElementsAreEqual) { // Check if we have already moved out all the nodes of the // last parent, and if so, traverse the parent tree and mark // them all as being able to deleted too. let parent: ElementNode | null = lastElement; let lastRemovedParent = null; while (parent !== null) { const children = parent.getChildren(); const childrenLength = children.length; if ( childrenLength === 0 || children[childrenLength - 1].is(lastRemovedParent) ) { markedNodeKeysForKeep.delete(parent.__key); lastRemovedParent = parent; } parent = parent.getParent(); } } // Ensure we do splicing after moving of nodes, as splicing // can have side-effects (in the case of hashtags). if (!$isTokenOrTab(firstNode)) { firstNode = firstNode.spliceText( startOffset, firstNodeTextLength - startOffset, text, true, ); if (firstNode.getTextContent() === '') { firstNode.remove(); } else if (this.anchor.type === 'text') { this.format = firstNode.getFormat(); this.style = firstNode.getStyle(); if (firstNode.isComposing()) { // When composing, we need to adjust the anchor offset so that // we correctly replace that right range. this.anchor.offset -= text.length; this._cachedNodes = null; this._cachedIsBackward = null; } } } else if (startOffset === firstNodeTextLength) { firstNode.select(); } else { const textNode = $createTextNode(text); textNode.select(); firstNode.replace(textNode); } // Remove all selected nodes that haven't already been removed. for (let i = 1; i < selectedNodesLength; i++) { const selectedNode = selectedNodes[i]; const key = selectedNode.__key; if (!markedNodeKeysForKeep.has(key)) { selectedNode.remove(); } } } } /** * Removes the text in the Selection, adjusting the EditorState accordingly. */ removeText(): void { const isCurrentSelection = $getSelection() === this; const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this)); $updateRangeSelectionFromCaretRange(this, newRange); if (isCurrentSelection && $getSelection() !== this) { $setSelection(this); } } // TO-DO: Migrate this method to the new utility function $forEachSelectedTextNode (share similar logic) /** * Applies the provided format to the TextNodes in the Selection, splitting or * merging nodes as necessary. * * @param formatType the format type to apply to the nodes in the Selection. * @param alignWithFormat a 32-bit integer representing formatting flags to align with. */ formatText( formatType: TextFormatType, alignWithFormat: number | null = null, ): void { $formatText(this, formatType, alignWithFormat); } /** * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the * current Selection according to a set of heuristics that determine how surrounding nodes * should be changed, replaced, or moved to accommodate the incoming ones. * * @param nodes - the nodes to insert */ insertNodes(nodes: LexicalNode[]): void { if (nodes.length === 0) { return; } if (!this.isCollapsed()) { this.removeText(); } // @experimental named-slots. Anchor on a slot value root (e.g. after a // slot-scoped Cmd+A leaves the selection on the slot's element point) // has __parent === null, so the block-finding walk below would throw. // Redirect into the slot subtree by collapsing the selection at the // slot's first child and re-running insertNodes. const anchorNode = this.anchor.getNode(); if ( this.anchor.type === 'element' && $isElementNode(anchorNode) && $getSlotHostKey(anchorNode) !== null ) { // A container (shadow-root) value redirects into its first child; an // empty one has no child to redirect into (its caret target is the // reconciler's terminating <br>), so seed a paragraph first — // insertNodes removes the seed again when block content replaces it. A // block-shaped value (virtual shadow root around a single block) needs // no seeding: it IS the block, so the block-finding walk below lands // on it directly. let firstChild = anchorNode.isShadowRoot() ? (anchorNode.getFirstChild() ?? anchorNode.append($createParagraphNode()).getFirstChild()) : anchorNode.getFirstChild(); // A shadow-root slot whose first child is a non-element (typically a // decorator like HorizontalRuleNode) would re-enter this same branch // forever: `firstChild.selectStart()` resolves back to the slot value's // own element-mode caret (no sibling, parent = the slot value root), // which matches the entry condition above. Seed a paragraph before the // non-element first child so the redirected selection lands in a block // and the recursion terminates. // // The seed paragraph is the redirect target only — if `nodes` carries // inline content the recursion fills the paragraph in place, and if it // carries block content the recursion's root/shadow-root branch // (`splice` after `$wrapInlineNodes`) inserts the new blocks before the // existing non-element first child while the seed sits at offset 0 as // the new shadow-root first child. In either case the seed ends up // hosting either the inserted content or an empty leading line, never // a stranded paragraph next to the original non-element child. if ( anchorNode.isShadowRoot() && firstChild !== null && !$isElementNode(firstChild) ) { const seed = $createParagraphNode(); firstChild.insertBefore(seed); firstChild = seed; } if (firstChild !== null) { firstChild.selectStart(); const redirected = $getSelection(); invariant( $isRangeSelection(redirected), 'Expected RangeSelection after redirecting into slot subtree', ); return redirected.insertNodes(nodes); } } // The anchor is an element point directly on a root or shadow root that is // not a named-slot host (handled above). This includes the document root // (e.g. an empty editor) and shadow roots that hold block-level children // directly — for instance the block cursor between or after the children of // a decorator-only container or the playground CollapsibleContentNode. // Roots and shadow roots hold blocks (and shadow roots) directly, so splice // the nodes in at the anchor offset: a block node (such as a pasted // DecoratorNode) goes in as-is, while inline runs are wrapped in a block // first since a root/shadow root cannot contain inline children. if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) { const blocksParent = $wrapInlineNodes(nodes); const nodeToSelect = blocksParent.getLastDescendant(); anchorNode.splice(this.anchor.offset, 0, blocksParent.getChildren()); if (nodeToSelect !== null) { nodeToSelect.selectEnd(); } return; } const firstPoint = this.isBackward() ? this.focus : this.anchor; let firstNode = firstPoint.getNode(); let firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock); const last = nodes[nodes.length - 1]!; // CASE 1: insert inside a code block if ($isElementNode(firstBlock) && '__language' in firstBlock) { if ('__language' in nodes[0]) { this.insertText(nodes[0].getTextContent()); } else { const index = $removeTextAndSplitBlock(this); firstBlock.splice(index, 0, nodes); last.selectEnd(); } return; } // CASE 2: All elements of the array are inline const notInline = (node: LexicalNode) => ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); if (!nodes.some(notInline)) { invariant( $isElementNode(firstBlock), 'Expected node %s of type %s to have a block ElementNode ancestor', firstNode.constructor.name, firstNode.getType(), ); const index = $removeTextAndSplitBlock(this); firstBlock.splice(index, 0, nodes); last.selectEnd(); return; } // CASE 3a: the target block IS a slot value. Its virtual shadow root // holds exactly one block, so block-level content cannot become its // sibling; mirror pasting into an <input> instead — block structure // flattens to its inline content on the single line (line breaks are // stripped like the input value sanitization strips newlines, and // block-only decorators are dropped, having no single-line form). if ($isElementNode(firstBlock) && $getSlotHostKey(firstBlock) !== null) { const index = $removeTextAndSplitBlock(this); const inlineNodes = $extractInlineFromBlocks(nodes); firstBlock.splice(index, 0, inlineNodes); const lastInserted = inlineNodes[inlineNodes.length - 1]; if (lastInserted !== undefined) { lastInserted.selectEnd(); } else { firstBlock.select(index, index); } return; } // CASE 3b: there is non-inline content but no block ancestor to insert it // relative to. The element point on a root/shadow root is handled above, so // this is a malformed document where an inline-only element directly holds // a block child (e.g. a HorizontalRuleNode inside a CollapsibleTitleNode, // see #8713) or a non-inline element that reports canBeEmpty() === false. // A non-inline node must never become the child of an inline-only element, // so enforce the document structure rules with // $insertNodeToNearestRootAtCaret, which splits the ancestor chain up to // the nearest node that may contain non-inline children (a root or shadow // root) and inserts the blocks there. Lists are unaffected: a list item is // always a block ancestor, so they fall through to CASE 3 and keep their // existing (ListItemNode-aware) paste behavior. if (firstBlock === null) { const blocksParent = $wrapInlineNodes(nodes); const nodeToSelect = blocksParent.getLastDescendant(); // Split the ancestor chain up to the nearest root or shadow root and // insert each block there. let caret: PointCaret<'next'> = $caretFromPoint(this.anchor, 'next'); for (const block of blocksParent.getChildren()) { caret = $insertNodeToNearestRootAtCaret(block, caret); } if (nodeToSelect !== null) { nodeToSelect.selectEnd(); } return; } // CASE 3c: the target block exists but its parent is not a root or shadow // root — the only elements that may contain non-inline children — and the // block does not relocate itself to a valid parent (it is not // parent-required, unlike a ListItemNode, whose insertAfter escapes the // list). Inserting the blocks as siblings here would nest them in an // inline-only element, e.g. a HorizontalRuleNode pasted into the // ParagraphNode of a CollapsibleTitleNode (see #8724). Mirror CASE 3a and // flatten the incoming nodes to their inline content, dropping the // block-level parts that have no inline form. if ( $isElementNode(firstBlock) && !firstBlock.isParentRequired() && !$isRootOrShadowRoot(firstBlock.getParentOrThrow()) ) { const index = $removeTextAndSplitBlock(this); const inlineNodes = $extractInlineFromBlocks(nodes); firstBlock.splice(index, 0, inlineNodes); const lastInserted = inlineNodes[inlineNodes.length - 1]; if (lastInserted !== undefined) { lastInserted.selectEnd(); } else { firstBlock.select(index, index); } return; } // CASE 3: At least 1 element of the array is not inline const blocksParent = $wrapInlineNodes(nodes); const nodeToSelect = blocksParent.getLastDescendant()!; const blocks = blocksParent.getChildren(); const isMergeable = (node: LexicalNode): node is ElementNode => $isElementNode(node) && INTERNAL_$isBlock(node) && !node.isEmpty() && $isElementNode(firstBlock) && (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty()); const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty(); const insertedParagraph = shouldInsert ? this.insertParagraph() : null; if (insertedParagraph && !firstBlock.isAttached()) { firstNode = this.anchor.getNode(); firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock); } const lastToInsert: LexicalNode | undefined = blocks[blocks.length - 1]; let fi