lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
1,519 lines (1,427 loc) • 159 kB
text/typescript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {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