UNPKG

lexical

Version:

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

1,609 lines (1,495 loc) • 118 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 {NodeKey} from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import type {TextFormatType} from './nodes/LexicalTextNode'; import invariant from '@lexical/internal/invariant'; import { $caretFromPoint, $caretRangeFromSelection, $comparePointCaretNext, $createLineBreakNode, $createParagraphNode, $createTextNode, $extendCaretToRange, $getAdjacentChildCaret, $getCaretRange, $getCaretRangeInDirection, $getChildCaret, $getSiblingCaret, $getTextNodeOffset, $isChildCaret, $isDecoratorNode, $isElementNode, $isExtendableTextPointCaret, $isLineBreakNode, $isParagraphNode, $isRootNode, $isSiblingCaret, $isTextNode, $isTextPointCaret, $normalizeCaret, $removeTextFromCaretRange, $rewindSiblingCaret, $setPointFromCaret, $setSelection, $setSelectionFromCaretRange, $updateRangeSelectionFromCaretRange, CaretRange, ChildCaret, COLLABORATION_TAG, type LineBreakNode, NodeCaret, 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, LexicalNode} from './LexicalNode'; import {$normalizeSelection} from './LexicalNormalization'; import { getActiveEditor, getActiveEditorState, isCurrentlyReadOnlyMode, } from './LexicalUpdates'; import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags'; import { $findMatchingParent, $getCompositionKey, $getDOMSlot, $getDOMTextNode, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeFromDOM, $getRoot, $hasAncestor, $isRootOrShadowRoot, $isSelectionCapturedInDecorator, $isTokenOrSegmented, $isTokenOrTab, $setCompositionKey, doesContainSurrogatePair, getDOMSelection, getElementByKeyOrThrow, getNodeKeyFromDOMNode, getWindow, INTERNAL_$isBlock, isHTMLElement, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, removeDOMBlockCursorElement, scrollIntoViewIfNeeded, toggleTextFormatType, } from './LexicalUtils'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; 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 { const target = $isRootNode(element) ? $createParagraphNode().append(textNode) : textNode; if (placementNode === null) { element.append(target); } else { placementNode.insertBefore(target); } } // 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: Array<LexicalNode> | null; dirty: boolean; clone(): BaseSelection; extract(): Array<LexicalNode>; getNodes(): Array<LexicalNode>; getTextContent(): string; insertText(text: string): void; insertRawText(text: string): void; is(selection: null | BaseSelection): boolean; insertNodes(nodes: Array<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: Array<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(): Array<LexicalNode> { return this.getNodes(); } insertRawText(text: string): void { // Do nothing? } insertText(): void { // Do nothing? } insertNodes(nodes: Array<LexicalNode>) { const selectedNodes = this.getNodes(); const selectedNodesLength = selectedNodes.length; 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(): Array<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 { const nodes = this.getNodes(); if (($getSelection() || $getPreviousSelection()) === this && nodes[0]) { const firstCaret = $getSiblingCaret(nodes[0], 'next'); $setSelectionFromCaretRange($getCaretRange(firstCaret, firstCaret)); } for (const node of nodes) { node.remove(); } } } 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: Array<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(): Array<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'; } 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 any node in the Selection * has the specified format. * * @param type the TextFormatType to check for. * @returns true if the provided format is currently toggled on 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; if (!$isTextNode(firstNode)) { invariant(false, '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)) ) { let nextSibling = firstNode.getNextSibling<TextNode>(); if ( !$isTextNode(nextSibling) || !nextSibling.canInsertTextBefore() || $isTokenOrSegmented(nextSibling) ) { nextSibling = $createTextNode(); nextSibling.setFormat(format); nextSibling.setStyle(style); if (!firstNodeParent.canInsertTextAfter()) { firstNodeParent.insertAfter(nextSibling); } else { firstNode.insertAfter(nextSibling); } } 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)) ) { let prevSibling = firstNode.getPreviousSibling<TextNode>(); if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) { prevSibling = $createTextNode(); prevSibling.setFormat(format); if (!firstNodeParent.canInsertTextBefore()) { firstNodeParent.insertBefore(prevSibling); } else { firstNode.insertBefore(prevSibling); } } prevSibling.select(); firstNode = prevSibling; if (text !== '') { this.insertText(text); return; } } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) { 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') { lastNode = (lastNode as TextNode).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 { if (this.isCollapsed()) { this.toggleFormat(formatType); // When changing format, we should stop composition $setCompositionKey(null); return; } const selectedNodes = this.getNodes(); const selectedTextNodes: Array<TextNode> = []; for (const selectedNode of selectedNodes) { if ($isTextNode(selectedNode)) { selectedTextNodes.push(selectedNode); } } const applyFormatToElements = (alignWith: number | null) => { selectedNodes.forEach(node => { if ($isElementNode(node)) { const newFormat = node.getFormatFlags(formatType, alignWith); node.setTextFormat(newFormat); } }); }; const selectedTextNodesLength = selectedTextNodes.length; if (selectedTextNodesLength === 0) { this.toggleFormat(formatType); // When changing format, we should stop composition $setCompositionKey(null); applyFormatToElements(alignWithFormat); return; } const anchor = this.anchor; const focus = this.focus; const isBackward = this.isBackward(); const startPoint = isBackward ? focus : anchor; const endPoint = isBackward ? anchor : focus; let firstIndex = 0; let firstNode = selectedTextNodes[0]; let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset; // In case selection started at the end of text node use next text node if ( startPoint.type === 'text' && startOffset === firstNode.getTextContentSize() ) { firstIndex = 1; firstNode = selectedTextNodes[1]; startOffset = 0; } if (firstNode == null) { return; } const firstNextFormat = firstNode.getFormatFlags( formatType, alignWithFormat, ); applyFormatToElements(firstNextFormat); const lastIndex = selectedTextNodesLength - 1; let lastNode = selectedTextNodes[lastIndex]; const endOffset = endPoint.type === 'text' ? endPoint.offset : lastNode.getTextContentSize(); // Single node selected if (firstNode.is(lastNode)) { // No actual text is selected, so do nothing. if (startOffset === endOffset) { return; } // The entire node is selected or it is token, so just format it if ( $isTokenOrSegmented(firstNode) || (startOffset === 0 && endOffset === firstNode.getTextContentSize()) ) { firstNode.setFormat(firstNextFormat); } else { // Node is partially selected, so split it into two nodes // add style the selected one. const splitNodes = firstNode.splitText(startOffset, endOffset); const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; replacement.setFormat(firstNextFormat); // Update selection only if starts/ends on text node if (startPoint.type === 'text') { startPoint.set(replacement.__key, 0, 'text'); } if (endPoint.type === 'text') { endPoint.set(replacement.__key, endOffset - startOffset, 'text'); } } this.format = firstNextFormat; return; } // Multiple nodes selected // The entire first node isn't selected, so split it if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) { [, firstNode] = firstNode.splitText(startOffset); startOffset = 0; } firstNode.setFormat(firstNextFormat); const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat); // If the offset is 0, it means no actual characters are selected, // so we skip formatting the last node altogether. if (endOffset > 0) { if ( endOffset !== lastNode.getTextContentSize() && !$isTokenOrSegmented(lastNode) ) { [lastNode] = lastNode.splitText(endOffset); } lastNode.setFormat(lastNextFormat); } // Process all text nodes in between for (let i = firstIndex + 1; i < lastIndex; i++) { const textNode = selectedTextNodes[i]; const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat); textNode.setFormat(nextFormat); } // Update selection only if starts/ends on text node if (startPoint.type === 'text') { startPoint.set(firstNode.__key, startOffset, 'text'); } if (endPoint.type === 'text') { endPoint.set(lastNode.__key, endOffset, 'text'); } this.format = firstNextFormat | lastNextFormat; } /** * 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: Array<LexicalNode>): void { if (nodes.length === 0) { return; } if (!this.isCollapsed()) { this.removeText(); } if (this.anchor.key === 'root') { this.insertParagraph(); const selection = $getSelection(); invariant( $isRangeSelection(selection), 'Expected RangeSelection after insertParagraph', ); return selection.insertNodes(nodes); } const firstPoint = this.isBackward() ? this.focus : this.anchor; const firstNode = firstPoint.getNode(); const 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 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; const lastToInsert: LexicalNode | undefined = blocks[blocks.length - 1]; let firstToInsert: LexicalNode | undefined = blocks[0]; if (isMergeable(firstToInsert)) { invariant( $isElementNode(firstBlock), 'Expected node %s of type %s to have a block ElementNode ancestor', firstNode.constructor.name, firstNode.getType(), ); firstBlock.append(...firstToInsert.getChildren()); firstToInsert = blocks[1]; } if (firstToInsert) { invariant( firstBlock !== null, 'Expected node %s of type %s to have a block ancestor', firstNode.constructor.name, firstNode.getType(), ); insertRangeAfter(firstBlock, firstToInsert); } const lastInsertedBlock = $findMatchingParent( nodeToSelect, INTERNAL_$isBlock, ); if ( insertedParagraph && $isElementNode(lastInsertedBlock) && (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert)) ) { lastInsertedBlock.append(...insertedParagraph.getChildren()); insertedParagraph.remove(); } if ($isElementNode(firstBlock) && firstBlock.isEmpty()) { firstBlock.remove(); } nodeToSelect.selectEnd(); // To understand this take a look at the test "can wrap post-linebreak nodes into new element" const lastChild = $isElementNode(firstBlock) ? firstBlock.getLastChild() : null; if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) { lastChild.remove(); } } /** * Inserts a new ParagraphNode into the EditorState at the current Selection * * @returns the newly inserted node. */ insertParagraph(): ElementNode | null { if (this.anchor.key === 'root') { const paragraph = $createParagraphNode(); $getRoot().splice(this.anchor.offset, 0, [paragraph]); paragraph.select(); return paragraph; } const index = $removeTextAndSplitBlock(this); const block = $findMatchingParent(this.anchor.getNode(), INTERNAL_$isBlock); invariant( $isElementNode(block), 'Expected ancestor to be a block ElementNode', ); const firstToAppend = block.getChildAtIndex(index); const nodesToInsert = firstToAppend ? [firstToAppend, ...firstToAppend.getNextSiblings()] : []; const newBlock = block.insertNewAfter(this, false) as ElementNode | null; if (newBlock) { newBlock.append(...nodesToInsert); newBlock.selectStart(); return newBlock; } // if newBlock is null, it means that block is of type CodeNode. return null; } /** * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the * current Selection. */ insertLineBreak(selectStart?: boolean): void { const lineBreak = $createLineBreakNode(); this.insertNodes([lineBreak]); // this is used in MacOS with the command 'ctrl-O' (openLineBreak) if (selectStart) { const parent = lineBreak.getParentOrThrow(); const index = lineBreak.getIndexWithinParent(); parent.select(index, index); } } /** * Extracts the nodes in the Selection, splitting nodes where necessary * to get offset-level precision. * * @returns The nodes in the Selection */ extract(): Array<LexicalNode> { const selectedNodes = [...this.getNodes()]; const selectedNodesLength = selectedNodes.length; let firstNode = selectedNodes[0]; let lastNode = selectedNodes[selectedNodesLength - 1]; const [anchorOffset, focusOffset] = $getCharacterOffsets(this); const isBackward = this.isBackward(); const [startPoint, endPoint] = isBackward ? [this.focus, this.anchor] : [this.anchor, this.focus]; const [startOffset, endOffset] = isBackward ? [focusOffset, anchorOffset] : [anchorOffset, focusOffset]; if (selectedNodesLength === 0) { return []; } else if (selectedNodesLength === 1) { if ($isTextNode(firstNode) && !this.isCollapsed()) { const splitNodes = firstNode.splitText(startOffset, endOffset); const node = startOffset === 0 ? splitNodes[0] : splitNodes[1]; if (node) { startPoint.set(node.getKey(), 0, 'text'); endPoint.set(node.getKey(), node.getTextContentSize(), 'text'); return [node]; } return []; } return [firstNode]; } if ($isTextNode(firstNode)) { if (startOffset === firstNode.getTextContentSize()) { selectedNodes.shift(); } else if (startOffset !== 0) { [, firstNode] = firstNode.splitText(startOffset); selectedNodes[0] = firstNode; startPoint.set(firstNode.getKey(), 0, 'text'); } } if ($isTextNode(lastNode)) { const lastNodeText = lastNode.getTextContent(); const lastNodeTextLength = lastNodeText.length; if (endOffset === 0) { selectedNodes.pop(); } else if (endOffset !== lastNodeTextLength) { [lastNode] = lastNode.splitText(endOffset); selectedNodes[selectedNodes.length - 1] = lastNode; endPoint.set(lastNode.getKey(), lastNode.getTextContentSize(), 'text'); } } return selectedNodes; } /** * Modifies the Selection according to the parameters and a set of heuristics that account for * various node types. Can be used to safely move or extend selection by one logical "unit" without * dealing explicitly with all the possible node types. * * @param alter the type of modification to perform * @param isBackward whether or not selection is backwards * @param granularity the granularity at which to apply the modification */ modify( alter: 'move' | 'extend', isBackward: boolean, granularity: 'character' | 'word' | 'lineboundary', ): void { if ( $modifySelectionAroundDecoratorsAndBlocks( this, alter, isBackward, granularity, ) ) { return; } const collapse = alter === 'move'; const editor = getActiveEditor(); const domSelection = getDOMSelection(getWindow(editor)); if (!domSelection) { return; } const blockCursorElement = editor._blockCursorElement; const rootElement = editor._rootElement; const focusNode = this.focus.getNode(); // Remove the block cursor element if it exists. This will ensure selection // works as intended. If we leave it in the DOM all sorts of strange bugs // occur. :/ if ( rootElement !== null && blockCursorElement !== null && $isElementNode(focusNode) && !focusNode.isInline() && !focusNode.canBeEmpty() ) { removeDOMBl