lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
4,412 lines • 150 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,
$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} 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');
}
function $insertTextAtPoint(
selection: RangeSelection,
text: string,
format: number,
style: string,
): void {
const anchorNode = selection.anchor.getNode();
invariant($isTextNode(anchorNode), 'insertText: anchor is not a text node');
const offset = selection.anchor.offset;
const textNode = $createTextNode(text);
textNode.setFormat(format);
textNode.setStyle(style);
const parent = anchorNode.getParentOrThrow();
if (offset === 0) {
if (parent.isInline() && !anchorNode.__prev) {
parent.insertBefore(textNode);
} else {
anchorNode.insertBefore(textNode, false);
}
} else if (offset === anchorNode.getTextContentSize()) {
if (parent.isInline() && !anchorNode.__next) {
parent.insertAfter(textNode);
} else {
anchorNode.insertAfter(textNode, false);
}
} else {
const [before] = anchorNode.splitText(offset);
before.insertAfter(textNode, false);
}
if (anchorNode.getTextContent() === '' && anchorNode.isAttached()) {
anchorNode.remove();
}
textNode.selectEnd();
if (textNode.isComposing() && selection.anchor.type === 'text') {
selection.anchor.set(
selection.anchor.key,
selection.anchor.offset - text.length,
selection.anchor.type,
);
}
}
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 {
// For non-collapsed selections, inherit format/style from the first
// selected text node so the replacement preserves the original styling.
let format = this.format;
let style = this.style;
if (!this.isCollapsed()) {
const firstPoint = this.focus.isBefore(this.anchor)
? this.focus
: this.anchor;
const firstNode = firstPoint.getNode();
if ($isTextNode(firstNode)) {
format = firstNode.getFormat();
style = firstNode.getStyle();
}
this.removeText();
this.format = format;
this.style = style;
if (text === '') {
return;
}
if ($getCompositionKey() === null) {
if (this.anchor.type === 'element') {
$transferStartingElementPointToTextPoint(
this.anchor,
this.focus,
format,
style,
);
}
$insertTextAtPoint(this, text, format, style);
return;
}
// Composing: fall through to the collapsed code below.
// spliceText preserves DOM node identity so the browser's
// IME tracker stays attached.
}
if (this.anchor.type === 'element') {
$transferStartingElementPointToTextPoint(
this.anchor,
this.focus,
format,
style,
);
}
const anchorNode = this.anchor.getNode();
invariant($isTextNode(anchorNode), 'insertText: anchor is not a text node');
const offset = this.anchor.offset;
const anchorParent = anchorNode.getParentOrThrow();
const anchorSize = anchorNode.getTextContentSize();
const needsRedirect =
$isTokenOrSegmented(anchorNode) ||
(offset === 0 &&
(!anchorNode.canInsertTextBefore() ||
(!anchorParent.canInsertTextBefore() && !anchorNode.__prev))) ||
(offset === anchorSize &&
(!anchorNode.canInsertTextAfter() ||
(!anchorParent.canInsertTextAfter() && !anchorNode.__next)));
if (needsRedirect) {
// Token/segmented nodes and nodes whose parent forbids text insertion
// at the boundary: reposition the cursor to an adjacent insertable
// node, then recurse.
// Segmented node with cursor in the middle: convert to normal
// text node so that the recursive insertText can proceed.
if (anchorNode.isSegmented() && offset !== 0 && offset !== anchorSize) {
if ($getCompositionKey() !== null) {
anchorNode.setMode('normal').setFormat(format).setStyle(style);
} else {
const replacement = $createTextNode(anchorNode.getTextContent());
replacement.setFormat(format);
replacement.setStyle(style);
anchorNode.replace(replacement);
replacement.select(offset, offset);
}
if (text !== '') {
this.insertText(text);
}
return;
}
if (text === '') {
return;
}
if (offset === 0) {
const prev = anchorNode.getPreviousSibling();
if (
$isTextNode(prev) &&
prev.canInsertTextAfter() &&
!$isTokenOrSegmented(prev)
) {
prev.select();
} else {
const newNode = $createTextNode();
newNode.setFormat(format);
newNode.setStyle(style);
if (!anchorParent.canInsertTextBefore()) {
anchorParent.insertBefore(newNode);
} else {
anchorNode.insertBefore(newNode);
}
newNode.select();
}
this.insertText(text);
return;
} else if (offset === anchorSize) {
const next = anchorNode.getNextSibling();
if (
$isTextNode(next) &&
next.canInsertTextBefore() &&
!$isTokenOrSegmented(next)
) {
next.select(0, 0);
} else {
const newNode = $createTextNode();
newNode.setFormat(format);
newNode.setStyle(style);
if (!anchorParent.canInsertTextAfter()) {
anchorParent.insertAfter(newNode);
} else {
anchorNode.insertAfter(newNode);
}
newNode.select(0, 0);
}
this.insertText(text);
return;
}
const newNode = $createTextNode(text);
newNode.setFormat(format);
newNode.setStyle(style);
anchorNode.replace(newNode);
newNode.select();
return;
}
if (text === '') {
return;
}
const atStartOfInline =
anchorParent.isInline() && offset === 0 && !anchorNode.__prev;
const atEndOfInline =
anchorParent.isInline() && offset === anchorSize && !anchorNode.__next;
const formatDiffers =
anchorNode.getFormat() !== format || anchorNode.getStyle() !== style;
if (atStartOfInline || atEndOfInline || formatDiffers) {
if (
anchorNode.getTextContent() === '' &&
!atStartOfInline &&
!atEndOfInline
) {
anchorNode.setFormat(format);
anchorNode.setStyle(style);
} else {
$insertTextAtPoint(this, text, format, style);
return;
}
}
anchorNode.spliceText(offset, 0, text, true);
if (anchorNode.isComposing() && this.anchor.type === 'text') {
this.anchor.set(
this.anchor.key,
this.anchor.offset - text.length,
this.anchor.type,
);
}
}
/**
* 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 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 {
const anchorNode = this.anchor.getNode();
if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) {
const paragraph = $createParagraphNode();
anchorNode.splice(this.anchor.offset, 0, [paragraph]);
paragraph.select();
return paragraph;
}
const index = $removeTextAndSplitBlock(this);
const block = $findMatchingParent(this.anchor.getNode(), INTERNAL_$isBlock);
if (block !== null && $getSlotHostKey(block) !== null) {
// The block IS the slot value: its virtual shadow root holds exactly
// one block, so there is no position for a sibling paragraph. Mirrors
// Enter in a single-line input — a no-op (hosts may map it to focus
// movement).
return null;
}
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(): 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()
) {
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
}
const focusKeyedDOM = getElementByKeyOrThrow(editor, this.focus.key);
let nextFocusDOM: HTMLElement | Text | null = focusKeyedDOM;
if (this.focus.type === 'text') {
nextFocusDOM = $isTextNode(focusNode)
? $getDOMTextNode(focusNode, focusKeyedDOM, editor)
: null;
}
if (this.dirty) {
const anchorKeyedDOM = getElementByKeyOrThrow(editor, this.anchor.key);
let nextAnchorDOM: HTMLElement | Text | null = anchorKeyedDOM;
if (this.anchor.type === 'text') {
const node = this.anchor.getNode();
nextAnchorDOM = $isTextNode(node)
? $getDOMTextNode(node, anchorKeyedDOM, editor)
: null;
}
if (nextAnchorDOM && nextFocusDOM) {
setDOMSelectionBaseAndExtent(
domSelection,
nextAnchorDOM,
this.anchor.offset,
nextFocusDOM,
this.focus.offset,
);
}
}
// When focus sits at a TextNode boundary, pre-normalize the DOM
// selection into the adjacent sibling's Text node so that the
// native Selection.modify can cross inline-grid/flex spans (#7301).
if (
granularity === 'character' &&
$isTextNode(focusNode) &&
focusNode.isUnmergeable()
) {
const atBoundary = isBackward
? this.focus.offset === 0
: this.focus.offset === focusNode.getTextContentSize();
if (atBoundary) {
const sibling = $getSiblingCaret(
focusNode,
isBackward ? 'previous' : 'next',
).getNodeAtCaret();
if ($isTextNode(sibling)) {
if (collapse) {
const sibKeyedDOM = editor.getElementByKey(sibling.getKey());
const sibDOM = sibKeyedDOM
? $getDOMTextNode(sibling, sibKeyedDOM, editor)
: null;
if (sibDOM) {
const sibOffset = isBackward ? sibDOM.length : 0;
setDOMSelectionBaseAndExtent(
domSelection,
sibDOM,
sibOffset,
sibDOM,
sibOffset,
);
}
} else {
// For extend (used by deleteCharacter), native Selection.modify
// cannot cross inline-grid span boundaries even after
// pre-normalization. Set the Lexical selection directly and
// return early to skip the native moveNativeSelection call.
const sibLen = sibling.getTextContentSize();
if (isBackward) {
this.focus.set(sibling.__key, sibLen - 1, 'text');
} else {
this.focus.set(sibling.__key, 1, 'text');
}
this.dirty = true;
return;
}
}
}
}
// We use the DOM selection.modify API here to "tell" us what the selection
// will be. We then use it to update the Lexical selection accordingly. This
// is much more reliable than waiting for a beforeinput and using the ranges
// from getTargetRanges(), and is also better than trying to do it ourselves
// using Intl.Segmenter or other workarounds that struggle with word segments
// and line segments (especially with word wrapping and non-Roman languages).
moveNativeSelection(
domSelection,
alter,
isBackward ? 'backward' : 'forward',
granularity,
);
// Guard against no ranges
if (domSelection.rangeCount > 0) {
// Inside a DOM shadow root getRangeAt(0) is retargeted to the host;
// read the composed StaticRange (real nodes) where available.
const composedRange = getComposedStaticRange(
domSelection,
editor._rootElement,
);
const range = composedRange || domSelection.getRangeAt(0);
// Apply the DOM selection to our Lexical selection.
const anchorNode = this.anchor.getNode();
const root = $isRootNode(anchorNode)
? anchorNode
: $getNearestRootOrShadowRoot(anchorNode);
this.applyDOMRange(range);
this.dirty = true;
if (!collapse) {
$shrinkSelectionToRoot(this, isBackward, root);
// Because a range works on start and end, we might need to flip
// the anchor and focus points to match what the DOM has, not what
// the range has specifically. Inside a shadow root anchorNode is
// retargeted to the host, so use the standard Selection.direction.
// If a future engine ships getComposedRanges without direction
// this falls through to forward; backward fidelity is the known
// limitation documented on getDOMSelectionPoints.
const anchorIsAtRangeStart = composedRange
? domSelection.direction !== 'backward'
: domSelection.anchorNode === range.startContainer &&
domSelection.anchorOffset === range.startOffset;
if (!anchorIsAtRangeStart) {
$swapPoints(this);
}
}
}
if (granularity === 'lineboundary') {
$modifySelectionAroundDecoratorsAndBlocks(
this,
alter,
isBackward,
granularity,
'decorators',
);
}
}
/**
* Helper for handling forward character and word deletion that prevents element nodes
* like a table, columns layout being destroyed
*
* @param anchor the anchor
* @param anchorNode the anchor node in the selection
* @param isBackward whether or not selection is backwards
*/
forwardDeletion(
anchor: PointType,
anchorNode: TextNode | ElementNode,
isBackward: boolean,
): boolean {
if (
!isBackward &&
// Delete forward handle case
((anchor.type === 'element' &&
$isElementNode(anchorNode) &&
anchor.offset === anchorNode.getChildrenSize()) ||
(anchor.type === 'text' &&
anchor.offset === anchorNode.getTextContentSize()))
) {
const parent = anchorNode.getParent();
const nextSibling =
anchorNode.getNextSibling() ||
(parent === null ? null : parent.getNextSibling());
if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
return true;
}
}
return false;
}
/**
* Performs one logical character deletion operation on the EditorState based on the current Selection.
* Handles different node types.
*
* @param isBackward whether or not the selection is backwards.
*/
deleteCharacter(isBackward: boolean): void {
const wasCollapsed = this.isCollapsed();
if (this.isCollapsed()) {
const anchor = this.anchor;
let anchorNode: TextNode | ElementNode | null = anchor.getNode();
if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
return;
}
const direction = isBackward ? 'previous' : 'next';
const initialCaret = $caretFromPoint(anchor, direction);
const initialRange = $extendCaretToRange(initialCaret);
if (
initialRange
.getTextSlices()
.every(slice => slice === null || slice.distance === 0)
) {
// There's no text in the direction of the deletion so we can explore our options
let state:
| {type: 'initial'}
| {
type: 'merge-next-block';
block: ElementNode;
}
| {
type: 'merge-block';
caret: ChildCaret<ElementNode, typeof direction>;
block: ElementNode;
} = {type: 'initial'};
for (const caret of initialRange.iterNodeCarets('shadowRoot')) {
if ($isChildCaret(caret)) {
if (caret.origin.isInline()) {
// fall through when descending an inline
} else if (caret.origin.isShadowRoot()) {
if (state.type === 'merge-block') {
break;
}
// Don't merge with a shadow root block
if (
$isElementNode(initialRange.anchor.origin) &&
initialRange.anchor.origin.isEmpty()
) {
// delete an empty paragraph like the DecoratorNode case
const normCaret = $normalizeCaret(caret);
$updateRangeSelectionFromCaretRange(
this,
$getCaretRange(normCaret, normCaret),
);
initialRange.anchor.origin.remove();
}
return;
} else if (
state.type === 'merge-next-block' ||
state.type === 'merge-block'
) {
// Keep descending ChildCaret to find which block to merge with
state = {block: state.block, caret, type: 'merge-block'};
}
} else if (state.type === 'merge-block') {
break;
} else if ($isSiblingCaret(caret)) {
if ($isElementNode(caret.origin)) {
if (!caret.origin.isInline()) {
state = {block: caret.origin, type: 'merge-next-block'};
} else if (!caret.origin.isParentOf(initialRange.anchor.origin)) {
break;
}
continue;
} else if ($isDecoratorNode(caret.origin)) {
if (caret.origin.isIsolated()) {
// do nothing, shouldn't delete an isolated decorator
} else if ($getSlotNames(caret.origin).length > 0) {
// A slot-bearing decorator is removed only as a unit by an
// explicit host deletion, never silently via backspace —
// same policy as the merge-block branch below for
// ElementNode-as-host. When the anchor is an empty
// paragraph next to the host, drop the paragraph and
// select the host (matches the shadow-root ElementNode
// path at line 1951–1962 above); otherwise leave both in
// place.
if (
$isElementNode(initialRange.anchor.origin) &&
initialRange.anchor.origin.isEmpty()
) {
initialRange.anchor.origin.remove();
const nodeSelection = $createNodeSelection();
nodeSelection.add(caret.origin.getKey());
$setSelection(nodeSelection);
}
} else if (
state.type === 'merge-next-block' &&
(caret.origin.isKeyboardSelectable() ||
!caret.origin.isInline()) &&
$isElementNode(initialRange.anchor.origin) &&
initialRange.anchor.origin.isEmpty()
) {
// If the anchor is an empty element that is adjacent to a
// decorator then we remove the paragraph and select the
// decorator
initialRange.anchor.origin.remove();
const nodeSelection = $createNodeSelection();
nodeSelection.add(caret.origin.getKey());
$setSelection(nodeSelection);
} else {
// When the anchor is not an empty element then the
// adjacent decorator is removed
caret.origin.remove();
}
// always stop when a decorator is encountered
return;
}
break;
}
}
if (state.type === 'merge-block') {
// `block` is the anchor-side block; `caret.origin` is the
// adjacent (previous-direction) block we descended into.
const {caret, block} = state;
// The cross-block merge below removes `block` (it merges into the
// adjacent block). If `block` owns slots, that removal would discard
// them, since slots are not children and are not carried over. Leave
// the caret in place instead: a slot-bearing host is removed only as
// a unit by an explicit host deletion, never silently via backspace.
if ($getSlotNames(block).length > 0) {
return;
}
// Empty adjacent block at the same nesting level: remove it
// instead of merging, so the current block's type (e.g.
// heading) survives. Limiting to a shared parent leaves
// structural wrappers like a ListNode containing an empty
// ListItemNode to the default cross-block merge — the
// ListNode is not considered empty just because its only
// child is.
if (
caret.origin.isEmpty() &&
!block.isEmpty() &&
caret.origin.getParent() === block.getParent()
) {
caret.origin.remove(true);
return;
}
$updateRangeSelectionFromCaretRange(
this,
$getCaretRange(
!caret.origin.isEmpty() && block.isEmpty()
? $rewindSiblingCaret($getSiblingCaret(block, caret.direction))
: initialRange.anchor,
caret,
),
);
return this.removeText();
}
// No text lies in the deletion direction and nothing in scope was
// found to delete, so the caret sits at a slot edge. A slot value is
// nested within its host's DOM, so the boundary lives only in the
// model: the native caret measurement below would cross it and
// select into the host. Stop when that edge is a slot value — the
// slot link is a virtual shadow root, so this applies whether or not
// the value is itself a shadow root; an ordinary (non-slotted)
// shadow root keeps the native behavior.
for (let node: LexicalNode | null = anchor.getNode(); node !== null; ) {
if ($getSlotHostKey(node) !== null) {
return;
}
if ($isElementNode(node) && node.isShadowRoot()) {
break;
}
node = node.getParent();
}
}
// Handle the deletion around decorators.
const focus = this.focus;
// Extend the selection in the model rather than with the native
// modify('extend'), which would clobber the X11 PRIMARY selection.
// See https://github.com/facebook/lexical/issues/8766
$extendSelectionForDeletion(this, isBackward, 'character');
if (!this.isCollapsed()) {
const focusNode = focus.type === 'text' ? focus.getNode() : null;
anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
if (focusNode !== null && focusNode.isSegmented()) {
const offset = focus.offset;
const textContentSize = focusNode.getTextContentSize();
if (
focusNode.is(anchorNode) ||
(isBackward && offset !== textContentSize) ||
(!isBackward && offset !== 0)
) {
$removeSegment(focusNode, isBackward, offset);
return;
}
} else if (anchorNode !== null && anchorNode.isSegmented()) {
const offset = anchor.offset;
const textContentSize = anchorNode.getTextContentSize();
if (
anchorNode.is(focusNode) ||
(isBackward && offset !== 0) ||
(!isBackward && offset !== textContentSize)
) {
$removeSegment(anchorNode, isBackward, offset);
return;
}
}
$updateCaretSelectionForUnicodeCharacter(this, isBackward);
} else if (isBackward && anchor.offset === 0) {
// Special handling around rich text nodes
if ($collapseAtStart(this, anchor.getNode())) {
return;
}
}
}
this.removeText();
if (
isBackward &&
!wasCollapsed &&
this.isCollapsed() &&
this.anchor.type === 'element' &&
this.anchor.offset === 0
) {
const anchorNode = this.anchor.getNode();
if (
anchorNode.isEmpty() &&
$isRootNode(anchorNode.getParent()) &&
anchorNode.getPreviousSibling() === null
) {
$collapseAtStart(this, anchorNode);
}
$ensureRootHasParagraph();
}
}
/**
* Performs one logical line deletion operation on the EditorState based on the current Selection.
* Handles different node types.
*
* @param isBackward whether or not the selection is backwards.
*/
deleteLine(isBackward: boolean): void {
// A decorator-host slot's DOM is relocated out of document order (the
// host's React decorate() mounts the slot container wherever it wants),
// so a deletion that starts inside one cannot be expressed by the
// native range the browser hands us: forward delete at a slot edge
// extends backward over the whole line, and the lineboundary extend
// below resolves in the wrong direction too. Element-host slots
// (e.g. a Card title) keep document order so the native path is
// fine there. Narrow to the decorator-host case and defer to
// deleteCharacter, which clamps at the slot boundary while still
// handling in-slot character and paragraph deletion.
const anchorSlotFrame = $getPointSlotFrame(this.anchor);
if (
anchorSlotFrame !== null &&
$isDecoratorNode($getSlotHost(anchorSlotFrame))
) {
if (!this.isCollapsed()) {
this.focus.set(this.anchor.key, this.anchor.offset, this.anchor.type);
}
this.deleteCharacter(isBackward);
return;
}
if (this.isCollapsed()) {
$extendSelectionForDeletion(this, isBackward, 'lineboundary');
}
if (this.isCollapsed()) {
// If the selection was already collapsed at the lineboundary,
// use the deleteCharacter operation to handle all of the logic associated
// with navigating through the parent element
this.deleteCharacter(isBackward);
} else {
const anchorBlock = $findMatchingParent(
this.anchor.getNode(),
INTERNAL_$isBlock,
);
const focusBlock = $findMatchingParent(
this.focus.getNode(),
INTERNAL_$isBlock,
);
if (anchorBlock !== focusBlock) {
this.focus.set(this.anchor.key, this.anchor.offset, this.anchor.type);
this.deleteCharacter(isBackward);
} else {
this.removeText();
}
}
}
/**
* Performs one logical word deletion operation on the EditorState based on the current Selection.
* Handles different node types.
*
* @param isBackward whether or not the selection is backwards.
*/
deleteWord(isBackward: boolean): void {
if (this.isCollapsed()) {
const anchor = this.anchor;
const anchorNode: TextNode | ElementNode | null = anchor.getNode();
if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
return;
}
$extendSelectionForDeletion(this, isBackward, 'word');
}
if (this.isCollapsed()) {
// If the selection was already collapsed at the lineboundary,
// use the deleteCharacter operation to handle all of the logic associated
// with navigating through the parent element
this.deleteCharacter(isBackward);
} else {
this.removeText();
}
}
/**
* Returns whether the Selection is "backwards", meaning the focus
* logically precedes the anchor in the EditorState.
* @returns true if the Selection is backwards, false otherwise.
*/
isBackward(): boolean {
const cached = this._cachedIsBackward;
if (cached !== null) {
return cached;
}
const isBackward = this.focus.isBefore(this.anchor);
if (!isCurrentlyReadOnlyMode()) {
this._cachedIsBackward = isBackward;
}
return isBackward;
}
getStartEndPoints(): [PointType, PointType] {
return [this.anchor, this.focus];
}
}
export function $isNodeSelection(x: unknown): x is NodeSelection {
return x instanceof NodeSelection;
}
/**
* Applies a pure bitmask transform to every formattable node in the selection
* in a single traversal, splitting the first and last TextNodes as necessary
* so that only the selected text is affected. Each node receives exactly one
* `setFormat(applyFormat(getFormat()))` (ElementNodes use their textFormat).
*
* @param selection - the selection whose nodes should be formatted.
* @param applyFormat - maps a node's current 32-bit format to its new format.
*/
function $updateTextFormat(
selection: RangeSelection | NodeSelection,
applyFormat: (format: number) => number,
): void {
if ($isNodeSelection(selection)) {
for (const node of selection.getNodes()) {
if ($isInlineFormattable(node)) {
node.setFormat(applyFormat(node.getFormat()));
}
}
return;
}
if (selection.isCollapsed()) {
selection.setFormat(applyFormat(selection.format));
// When changing format, we should stop composition
$setCompositionKey(null);
return;
}
const selectedTextNodes: TextNode[] = [];
for (const node of selection.getNodes()) {
if ($isTextNode(node)) {
selectedTextNodes.push(node);
} else if ($isElementNode(node)) {
node.setTextFormat(applyFormat(node.getTextFormat()));
} else if ($isInlineFormattable(node)) {
node.setFormat(applyFormat(node.getFormat()));
}
}
const selectedTextNodesLength = selectedTextNodes.length;
if (selectedTextNodesLength === 0) {
selection.setFormat(applyFormat(selection.format));
// When changing format, we should stop composition
$setCompositionKey(null);
return;
}
const anchor = selection.anchor;
const focus = selection.focus;
const isBackward = selection.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 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;
}
const newFormat = applyFormat(firstNode.getFormat());
// The entire node is selected or it is token, so just format it
if (
$isTokenOrSegmented(firstNode) ||
(startOffset === 0 && endOffset === firstNode.getTextContentSize())
) {
firstNode.setFormat(newFormat);
} else {
// Node is partially selected, so split it into two nodes
// and style the selected one.
const splitNodes = firstNode.splitText(startOffset, endOffset);
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
replacement.setFormat(newFormat);
// 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');
}
}
selection.format = newFormat;
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;
}
const firstNextFormat = applyFormat(firstNode.getFormat());
firstNode.setFormat(firstNextFormat);
const lastNextFormat = applyFormat(lastNode.getFormat());
// 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];
textNode.setFormat(applyFormat(textNode.getFormat()));
}
// 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');
}
selection.format = firstNextFormat | lastNextFormat;
}
/**
* Explicitly sets or unsets text formats on the selection. Unlike $formatText
* which toggles based on the current selection state, this function sets each
* specified format to the exact boolean value provided. Mutually exclusive
* formats (subscript/superscript, lowercase/uppercase/capitalize) are
* reconciled by {@link toggleTextFormatType}, with later entries winning when
* the requested formats conflict.
*
* @param selection - the selection whose nodes should be formatted.
* @param formats - a partial record mapping TextFormatType to boolean.
*/
export function $setTextFormat(
selection: RangeSelection | NodeSelection,
formats: Partial<Record<TextFormatType, boolean>>,
): void {
const entries: [TextFormatType, boolean][] = [];
for (const [type, value] of Object.entries(formats) as [
TextFormatType,
boolean | undefined,
][]) {
if (typeof value === 'boolean') {
entries.push([type, value]);
}
}
if (entries.length === 0) {
return;
}
$updateTextFormat(selection, format => {
for (const [type, value] of entries) {
format = toggleTextFormatType(
format,
type,
value ? TEXT_TYPE_TO_FORMAT[type] : 0,
);
}
return format;
});
}
/**
* Applies the provided format to TextNodes and inline formattable nodes
* (e.g. DecoratorTextNode) in the selection, splitting or merging TextNodes
* as necessary and aligning all formattable nodes to the same target format.
*
* For RangeSelection the toggle direction is determined by the selection's
* computed format (intersection of all text nodes) when no explicit alignment
* is given. For NodeSelection each node is toggled independently when no
* explicit alignment is given, since there is no TextNode to use as an
* alignment reference.
*
* @param selection - the selection whose nodes should be formatted.
* @param formatType - the format type to apply.
* @param alignWithFormat - optional 32-bit bitmask to align with.
*/
export function $formatText(
selection: RangeSelection | NodeSelection,
formatType: TextFormatType,
alignWithFormat: number | null = null,
): void {
const effectiveAlign =
alignWithFormat === null && $isRangeSelection(selection)
? toggleTextFormatType(selection.format, formatType, null)
: alignWithFormat;
$updateTextFormat(selection, format =>
toggleTextFormatType(format, formatType, effectiveAlign),
);
}
function getCharacterOffset(point: PointType): number {
const offset = point.offset;
if (point.type === 'text') {
return offset;
}
const parent = point.getNode();
return offset === parent.getChildrenSize()
? parent.getTextContent().length
: 0;
}
export function $getCharacterOffsets(
selection: BaseSelection,
): [number, number] {
const anchorAndFocus = selection.getStartEndPoints();
if (anchorAndFocus === null) {
return [0, 0];
}
const [anchor, focus] = anchorAndFocus;
if (
anchor.type === 'element' &&
focus.type === 'element' &&
anchor.key === focus.key &&
anchor.offset === focus.offset
) {
return [0, 0];
}
return [getCharacterOffset(anchor), getCharacterOffset(focus)];
}
function $collapseAtStart(
selection: RangeSelection,
startNode: LexicalNode,
): boolean {
for (
let node: null | LexicalNode = startNode;
node;
node = node.getParent()
) {
if ($isElementNode(node)) {
if (node.collapseAtStart(selection)) {
return true;
}
if ($isRootOrShadowRoot(node)) {
break;
}
}
if (node.getPreviousSibling()) {
break;
}
}
return false;
}
function $swapPoints(selection: RangeSelection): void {
const focus = selection.focus;
const anchor = selection.anchor;
const anchorKey = anchor.key;
const anchorOffset = anchor.offset;
const anchorType = anchor.type;
anchor.set(focus.key, focus.offset, focus.type, true);
focus.set(anchorKey, anchorOffset, anchorType, true);
}
function moveNativeSelection(
domSelection: Selection,
alter: 'move' | 'extend',
direction: 'backward' | 'forward' | 'left' | 'right',
granularity: 'character' | 'word' | 'lineboundary',
): void {
// Selection.modify() method applies a change to the current selection or cursor position,
// but is still non-standard in some browsers.
domSelection.modify(alter, direction, granularity);
}
/**
* Validate that the selection respects `root` (the nearest root or shadow
* root): if any selected node lies outside of it, shrink the selection to the
* valid edge in the given direction. The valid node check is a safeguard
* against an invalid selection, for which getNodes() returns an empty array.
*
* @returns true if the selection was shrunk
*/
function $shrinkSelectionToRoot(
selection: RangeSelection,
isBackward: boolean,
root: LexicalNode,
): boolean {
const nodes = selection.getNodes();
const validNodes = nodes.filter(node => $hasAncestor(node, root));
if (validNodes.length === 0 || validNodes.length === nodes.length) {
return false;
}
const edgeNode = isBackward
? validNodes[0]
: validNodes[validNodes.length - 1];
const edgeElement = $isElementNode(edgeNode)
? edgeNode
: edgeNode.getParentOrThrow();
if (isBackward) {
edgeElement.selectStart();
} else {
edgeElement.selectEnd();
}
return true;
}
/**
* Extend a collapsed selection by one unit (`character`, `word` or
* `lineboundary`) in the deletion direction without ever creating a
* non-collapsed DOM selection.
*
* On Linux/X11, browsers propagate any non-collapsed DOM selection made
* during a user gesture to the PRIMARY selection (the middle-click paste
* buffer), so a deletion must never pass through a transient non-collapsed
* DOM selection or every Backspace/Delete overwrites the user's paste
* buffer (https://github.com/facebook/lexical/issues/8766). A collapsed
* caret never takes PRIMARY ownership, so the DOM caret is moved with the
* native `modify('move')` to measure where the engine places the unit
* boundary, and the `[original .. landed]` range is constructed in the
* model only. `applyDOMRange` reads just the range's boundary points (it
* never touches the DOM selection), giving the same point resolution,
* decorator pre/post handling, shadow-root shrink validation and
* anchor/focus orientation as a native selection extension would, while
* the DOM selection is only ever collapsed.
*
* When the measurement is not possible — no DOM selection or no
* `Selection.modify` (headless environments can polyfill it), or an
* unresolvable anchor — the selection is left collapsed, so the deletion
* becomes a no-op for that keystroke.
*/
function $extendSelectionForDeletion(
selection: RangeSelection,
isBackward: boolean,
granularity: 'character' | 'word' | 'lineboundary',
): void {
// Decorator/block handling, resolved in the model exactly as modify() does.
if (
$modifySelectionAroundDecoratorsAndBlocks(
selection,
'extend',
isBackward,
granularity,
)
) {
return;
}
const editor = getActiveEditor();
const domSelection = getDOMSelection(getWindow(editor));
if (!domSelection || typeof domSelection.modify !== 'function') {
return;
}
const blockCursorElement = editor._blockCursorElement;
const rootElement = editor._rootElement;
const anchor = selection.anchor;
const focusNode = selection.focus.getNode();
// A block cursor element left in the DOM would corrupt element offsets.
if (
rootElement !== null &&
blockCursorElement !== null &&
$isElementNode(focusNode) &&
!focusNode.isInline() &&
!focusNode.canBeEmpty()
) {
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
}
const $resolvePointDOM = (point: PointType): HTMLElement | Text | null => {
const pointNode = point.getNode();
const keyedDOM = editor.getElementByKey(point.key);
return keyedDOM !== null && point.type === 'text' && $isTextNode(pointNode)
? $getDOMTextNode(pointNode, keyedDOM, editor)
: keyedDOM;
};
// Resolve the model anchor to a DOM position (one end of the final range).
const anchorNode = anchor.getNode();
const anchorDOM = $resolvePointDOM(anchor);
if (anchorDOM === null) {
return;
}
const anchorOffset = anchor.offset;
// Measure from the FOCUS: the decorator/block pre-pass above may have
// hopped the model focus past an inline decorator and returned false — the
// shape where native caret movement is unreliable (it can refuse to move a
// caret adjacent to contenteditable=false content). modify() runs the
// native extend from the extent (the hopped focus); do the same here. For
// an untouched collapsed selection the focus is the anchor.
const wasCollapsed = selection.isCollapsed();
const focus = selection.focus;
const focusDOM = wasCollapsed ? anchorDOM : $resolvePointDOM(focus);
if (focusDOM === null) {
return;
}
const focusOffset = focus.offset;
// Sync a COLLAPSED DOM caret at the measurement origin (base === extent),
// then move it by one unit to measure the engine's boundary. Neither
// operation touches PRIMARY.
setDOMSelectionBaseAndExtent(
domSelection,
focusDOM,
focusOffset,
focusDOM,
focusOffset,
);
moveNativeSelection(
domSelection,
'move',
isBackward ? 'backward' : 'forward',
granularity,
);
if (domSelection.rangeCount === 0) {
return;
}
// Inside a DOM shadow root getRangeAt(0) is retargeted to the host; read the
// composed StaticRange (real nodes) where available. After a 'move' the DOM
// selection is collapsed, so start === end === the landed caret.
const landedRange =
getComposedStaticRange(domSelection, rootElement) ||
domSelection.getRangeAt(0);
const landedContainer = landedRange.startContainer;
const landedOffset = landedRange.startOffset;
// Native 'move' cannot cross inline-grid/flex span boundaries (#7301).
// When at the deletion-side edge of an unmergeable TextNode, extend into
// the adjacent sibling directly instead of relying on the native result.
if (
wasCollapsed &&
granularity === 'character' &&
anchor.type === 'text' &&
$isTextNode(anchorNode) &&
anchorNode.isUnmergeable()
) {
const boundaryOffset = isBackward ? 0 : anchorNode.getTextContentSize();
if (anchorOffset === boundaryOffset) {
const sibling = $getSiblingCaret(
anchorNode,
isBackward ? 'previous' : 'next',
).getNodeAtCaret();
if ($isTextNode(sibling)) {
const sibOffset = isBackward ? sibling.getTextContentSize() - 1 : 1;
selection.focus.set(sibling.__key, sibOffset, 'text');
selection.dirty = true;
return;
}
}
}
// In-node character deletion (the common case): keep the focus in the
// anchor's own text node. A native 'move' reports a caret that lands on a
// text-node boundary as a position in the *adjacent* node, and that
// representation changes how a later insertion (e.g. at a format or
// keyword boundary) resolves. When the move lands inside the anchor node
// its offset is used directly; landing on the node's edge is clamped to
// that edge. Word/line deletions legitimately span nodes, so they use the
// general path below.
if (wasCollapsed && granularity === 'character' && anchor.type === 'text') {
// The deletion-side edge of the anchor's text.
const edgeOffset = isBackward ? 0 : anchorNode.getTextContentSize();
const clampedOffset =
landedContainer === anchorDOM
? landedOffset
: anchorOffset !== edgeOffset
? edgeOffset
: -1;
if (clampedOffset >= 0) {
if (clampedOffset !== anchorOffset) {
selection.focus.set(anchor.key, clampedOffset, 'text');
selection.dirty = true;
}
return;
}
}
// General path: reconstruct, in document order, the [original .. landed]
// range a native extend would have produced. applyDOMRange only reads these
// four boundary properties into the model, so a plain StaticRange-shaped
// object is sufficient (and avoids a StaticRange constructor dependency).
const [startContainer, startOffset, endContainer, endOffset] = isBackward
? [landedContainer, landedOffset, anchorDOM, anchorOffset]
: [anchorDOM, anchorOffset, landedContainer, landedOffset];
const root = $isRootNode(anchorNode)
? anchorNode
: $getNearestRootOrShadowRoot(anchorNode);
selection.applyDOMRange({
collapsed: false,
endContainer,
endOffset,
startContainer,
startOffset,
} as unknown as StaticRange);
selection.dirty = true;
if (!$shrinkSelectionToRoot(selection, isBackward, root) && isBackward) {
// applyDOMRange set anchor = range start (the landed point); the deletion
// anchor must stay at the original caret, so restore that orientation.
$swapPoints(selection);
}
if (granularity === 'lineboundary') {
$modifySelectionAroundDecoratorsAndBlocks(
selection,
'extend',
isBackward,
granularity,
'decorators',
);
}
}
/**
* Called by `RangeSelection.deleteCharacter` to determine if
* `$extendSelectionForDeletion` extended the selection further
* than a user would expect for that operation.
*
* A short(?) JavaScript string vs. Unicode primer:
*
* Strings in JavaScript use an UTF-16 encoding, and the offsets into a
* string are based on those UTF-16 *code units*. This is basically a
* historical mistake (though logical at that time, decades ago), but
* can never really be fixed for compatibility reasons.
*
* In Unicode, a *code point* is the combination of one or more *code units*.
* and the range of a *code point* can fit into 21 bits.
*
* Every valid *code point* can be represented with one or two
* *UTF-16 code units*. One unit is used when the code point is in the
* Basic Multilingual Plane (BMP) and is `< 0xFFFF`. Anything outside
* of that plane is encoded with a *surrogate pair* of *code units* and
* `/[\uD800-\uDBFF][\uDC00-\uDFFF]/` is a regex that you could use to
* find any valid *surrogate pair*. As far as Unicode is concerned, these
* pairs represent a single *code point*, but in JavaScript, these pairs
* have a length of 2 (`pair.charCodeAt(n)` is really returning a
* UTF-16 *code unit*, not a unicode *code point*). It is possible to request
* a *code point* with `pair.codePointAt(0)` and enumerate code points
* in a string with `[...string]` but the offsets we work with, and
* the string length, are based in *code units* so that functionality
* is unfortunately not very useful here.
*
* This only gets us as far as *code points*. We now know that we must
* consider that each *code point* can have a length of 1 or 2 in JavaScript
* string distance. It gets even trickier because the visual representation
* of a character is a *grapheme* (approximately what the user thinks of
* as a character). A *grapheme* is one or more *code points*, and can
* essentially be arbitrarily long, as there are many ways to combine
* them.
*
* The native caret measurement has already extended our selection by one
* *grapheme* in the direction we want to delete. Sounds great, it's done
* a lot of awfully tricky work for us because this functionality has only
* recently become available in JavaScript via `Intl.Segmenter`. The
* problem is that in many cases the expected behavior of backspace or
* delete is *not always to delete a whole grapheme*. In some languages
* it's always expected that backspace ought to delete one code point, not the
* whole grapheme. In other situations such as emoji that use variation
* selectors you *do* want to delete the whole *grapheme*.
*
* In a few situations the behavior is even application dependent, such as
* with latin languages where you have multiple ways to represent the same
* character visually (e.g. a letter with an accent in one code point, or a
* letter followed by a combining mark in a second code point); some apps will
* delete the whole grapheme and others will delete only the combining mark,
* probably based on whether they perform some sort of *normalization* on their
* input to ensure that only one form is used when two sequences of code points
* can represent the same visual character. Lexical currently chooses not
* to perform any normalization so this type of combining marks will be
* deleted as a *code point* without deleting the whole *grapheme*.
*
* See also:
* https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-2/#G25564
* https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G30602
* https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G49537
* https://mathiasbynens.be/notes/javascript-unicode
*/
function $updateCaretSelectionForUnicodeCharacter(
selection: RangeSelection,
isBackward: boolean,
): void {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (
anchorNode === focusNode &&
anchor.type === 'text' &&
focus.type === 'text'
) {
// Handling of multibyte characters
const anchorOffset = anchor.offset;
const focusOffset = focus.offset;
const isBefore = anchorOffset < focusOffset;
const startOffset = isBefore ? anchorOffset : focusOffset;
const endOffset = isBefore ? focusOffset : anchorOffset;
const characterOffset = endOffset - 1;
if (startOffset !== characterOffset) {
const text = anchorNode.getTextContent().slice(startOffset, endOffset);
if (shouldDeleteExactlyOneCodeUnit(text)) {
if (isBackward) {
focus.set(focus.key, characterOffset, focus.type);
} else {
anchor.set(anchor.key, characterOffset, anchor.type);
}
}
}
}
}
function shouldDeleteExactlyOneCodeUnit(text: string) {
if (__DEV__) {
invariant(
text.length > 1,
'shouldDeleteExactlyOneCodeUnit: expecting to be called only with sequences of two or more code units',
);
}
return !(doesContainSurrogatePair(text) || doesContainEmoji(text));
}
/**
* Given the wall of text in $updateCaretSelectionForUnicodeCharacter, you'd
* think that the solution might be complex, but the only currently known
* cases given the above constraints where we want to delete a whole grapheme
* are when emoji is involved. Since ES6 we can use unicode character classes
* in regexp which makes this simple.
*
* It may make sense to add to this heuristic in the future if other
* edge cases are discovered, which is why detailed notes remain.
*
* This is implemented with runtime feature detection and will always
* return false on pre-2020 platforms that do not have unicode character
* class support.
*/
const doesContainEmoji: (text: string) => boolean = (() => {
try {
const re = new RegExp('\\p{Emoji}', 'u');
const test = re.test.bind(re);
// Sanity check a few emoji to make sure the regexp was parsed
// and works correctly. Any one of these should be sufficient,
// but they're cheap and it only runs once.
if (
// Emoji in the BMP (heart) with variation selector
test('\u2764\ufe0f') &&
// Emoji in the BMP (#) with variation selector
test('#\ufe0f\u20e3') &&
// Emoji outside the BMP (thumbs up) that is encoded with a surrogate pair
test('\ud83d\udc4d')
) {
return test;
}
} catch (_e) {
// SyntaxError
}
// fallback, surrogate pair already checked
return () => false;
})();
function $removeSegment(
node: TextNode,
isBackward: boolean,
offset: number,
): void {
const textNode = node;
const textContent = textNode.getTextContent();
const split = textContent.split(/(?=\s)/g);
const splitLength = split.length;
let segmentOffset = 0;
let restoreOffset: number | undefined = 0;
for (let i = 0; i < splitLength; i++) {
const text = split[i];
const isLast = i === splitLength - 1;
restoreOffset = segmentOffset;
segmentOffset += text.length;
if (
(isBackward && segmentOffset === offset) ||
segmentOffset > offset ||
isLast
) {
split.splice(i, 1);
if (isLast) {
restoreOffset = undefined;
}
break;
}
}
const nextTextContent = split.join('').trim();
if (nextTextContent === '') {
textNode.remove();
} else {
textNode.setTextContent(nextTextContent);
textNode.select(restoreOffset, restoreOffset);
}
}
function shouldResolveAncestor(
resolvedElement: ElementNode,
resolvedOffset: number,
lastPoint: null | PointType,
): boolean {
const parent = resolvedElement.getParent();
return (
lastPoint === null ||
parent === null ||
!parent.canBeEmpty() ||
parent !== lastPoint.getNode()
);
}
function $internalResolveSelectionPoint(
dom: Node,
offset: number,
lastPoint: null | PointType,
editor: LexicalEditor,
): null | [point: PointType, dirty: boolean] {
let resolvedOffset = offset;
let resolvedNode: TextNode | LexicalNode | null;
// True when the DOM position is not directly representable in the
// Lexical tree (e.g. the caret landed inside a void/empty element
// such as <col> or in another unmanaged subtree) and the resolution
// had to walk up to a Lexical ancestor. The caller marks the
// resulting selection dirty so the reconciler writes a valid DOM
// caret back instead of leaving the user's cursor "stuck" inside
// unmanaged DOM.
let dirty = false;
// If we have selection on an element, we will
// need to figure out (using the offset) what text
// node should be selected.
if (isHTMLElement(dom)) {
// Resolve element to a ElementNode, or TextNode, or null
let moveSelectionToEnd = false;
// Given we're moving selection to another node, selection is
// definitely dirty.
// We use the anchor to find which child node to select
const childNodes = dom.childNodes;
const childNodesLength = childNodes.length;
const blockCursorElement = editor._blockCursorElement;
// If the anchor is the same as length, then this means we
// need to select the very last text node.
if (resolvedOffset === childNodesLength && childNodesLength > 0) {
moveSelectionToEnd = true;
resolvedOffset = childNodesLength - 1;
}
if (
getNodeKeyFromDOMNode(dom, editor) === undefined &&
!isDOMCapturingSelection(dom, editor)
) {
// The DOM caret is sitting on a node that has no Lexical key
// (e.g. <col> inside an unmanaged <colgroup>, or any unmanaged
// scaffolding around a DOMSlot — wrap elements, contenteditable=false
// labels, badges, etc.). Resolution will walk up to find a Lexical
// ancestor below, so the resulting Lexical position will not
// correspond to where the DOM caret currently is. Mark the
// selection dirty so the reconciler writes a valid DOM caret back
// at the resolved Lexical position.
//
// Exclusions split across the two guard clauses:
// - The first clause (`key !== undefined`) covers any DOM node
// with a `__lexicalKey_*` attribute — Lexical-managed elements
// and the editor root (stashed in `resetEditor`).
// - `isDOMCapturingSelection` covers DecoratorNode subtrees (which
// own their own DOM) and subtrees marked via
// `setDOMUnmanaged(dom, {captureSelection: true})` —
// extension-owned widgets that keep a native caret.
//
// Void elements that ARE Lexical nodes (LineBreakNode <br>,
// empty decorator containers, etc.) have keys, so this check
// leaves their existing resolution-to-parent behavior alone.
dirty = true;
}
let childDOM = childNodes[resolvedOffset];
let hasBlockCursor = false;
if (childDOM === blockCursorElement) {
childDOM = childNodes[resolvedOffset + 1];
hasBlockCursor = true;
} else if (blockCursorElement !== null) {
const blockCursorElementParent = blockCursorElement.parentNode;
if (dom === blockCursorElementParent) {
const blockCursorOffset = Array.prototype.indexOf.call(
blockCursorElementParent.children,
blockCursorElement,
);
if (offset > blockCursorOffset) {
resolvedOffset--;
}
}
}
resolvedNode = $getNodeFromDOM(childDOM);
if ($isTextNode(resolvedNode)) {
resolvedOffset = $getTextNodeOffset(
resolvedNode,
moveSelectionToEnd ? 'next' : 'previous',
);
} else {
let resolvedElement = $getNodeFromDOM(dom);
// Ensure resolvedElement is actually a element.
if (resolvedElement === null) {
return null;
}
if ($isElementNode(resolvedElement)) {
const elementDOM = editor.getElementByKey(resolvedElement.getKey());
invariant(
elementDOM !== null,
'$internalResolveSelectionPoint: node in DOM but not keyToDOMMap',
);
const slot = $getDOMSlot(resolvedElement, elementDOM, editor);
[resolvedElement, resolvedOffset] = slot.resolveChildIndex(
resolvedElement,
elementDOM,
dom,
offset,
);
// This is just a typescript workaround, it is true but lost due to mutability
invariant(
$isElementNode(resolvedElement),
'$internalResolveSelectionPoint: resolvedElement is not an ElementNode',
);
if (
moveSelectionToEnd &&
resolvedOffset >= resolvedElement.getChildrenSize()
) {
resolvedOffset = Math.max(0, resolvedElement.getChildrenSize() - 1);
}
let child = resolvedElement.getChildAtIndex(resolvedOffset);
if (
$isElementNode(child) &&
shouldResolveAncestor(child, resolvedOffset, lastPoint)
) {
const descendant = moveSelectionToEnd
? child.getLastDescendant()
: child.getFirstDescendant();
if (descendant === null) {
resolvedElement = child;
} else {
child = descendant;
resolvedElement = $isElementNode(child)
? child
: child.getParentOrThrow();
}
resolvedOffset = 0;
}
if ($isTextNode(child)) {
resolvedNode = child;
resolvedElement = null;
resolvedOffset = $getTextNodeOffset(
child,
moveSelectionToEnd ? 'next' : 'previous',
);
} else if (
child !== resolvedElement &&
moveSelectionToEnd &&
!hasBlockCursor
) {
invariant($isElementNode(resolvedElement), 'invariant');
resolvedOffset = Math.min(
resolvedElement.getChildrenSize(),
resolvedOffset + 1,
);
}
} else {
// A slot value is parentless — it links up to its host via
// `__slotHost` and behaves like a shadow root. Anchor the caret
// adjacent to the host (a normal child of its parent), since the slot
// value itself has no parent to anchor in. Non-slotted leaves anchor
// in their own parent as before.
const slotHost = $getSlotHost(resolvedElement);
const anchorNode = slotHost !== null ? slotHost : resolvedElement;
const index = anchorNode.getIndexWithinParent();
// For wrap patterns (slot exposes an inner content element via
// `withElement`) defer to `slot.resolveLeafPosition` so the
// wrap's structure determines "before vs after". For bare leaf
// DOM we preserve the historical rule: only a DecoratorNode at
// DOM offset 0 resolves to "before"; everything else (including
// bare LineBreakNode) resolves to "after".
const elementDOM = editor.getElementByKey(resolvedElement.getKey());
let position: 'before' | 'after' = 'after';
if (elementDOM !== null && $getNodeFromDOM(dom) === resolvedElement) {
const slot = $getDOMSlot(resolvedElement, elementDOM, editor);
if (slot.element !== elementDOM) {
position = slot.resolveLeafPosition(elementDOM, dom, offset);
} else if (offset === 0 && $isDecoratorNode(resolvedElement)) {
position = 'before';
}
}
resolvedOffset = position === 'before' ? index : index + 1;
resolvedElement = anchorNode.getParentOrThrow();
}
if ($isElementNode(resolvedElement)) {
return [
$createPoint(resolvedElement.__key, resolvedOffset, 'element'),
dirty,
];
}
}
} else {
// TextNode or null
resolvedNode = $getNodeFromDOM(dom);
}
if (!$isTextNode(resolvedNode)) {
return null;
}
return [
$createPoint(
resolvedNode.__key,
$getTextNodeOffset(resolvedNode, resolvedOffset, 'clamp'),
'text',
),
dirty,
];
}
function resolveSelectionPointOnBoundary(
point: TextPointType,
isBackward: boolean,
isCollapsed: boolean,
): void {
const offset = point.offset;
const node = point.getNode();
if (offset === 0) {
const prevSibling = node.getPreviousSibling();
const parent = node.getParent();
if (!isBackward) {
if (
$isElementNode(prevSibling) &&
!isCollapsed &&
prevSibling.isInline()
) {
point.set(prevSibling.__key, prevSibling.getChildrenSize(), 'element');
} else if ($isTextNode(prevSibling) && !node.isUnmergeable()) {
point.set(
prevSibling.__key,
prevSibling.getTextContent().length,
'text',
);
}
} else if (
(isCollapsed || !isBackward) &&
prevSibling === null &&
$isElementNode(parent) &&
parent.isInline()
) {
const parentSibling = parent.getPreviousSibling();
if ($isTextNode(parentSibling)) {
point.set(
parentSibling.__key,
parentSibling.getTextContent().length,
'text',
);
}
}
} else if (offset === node.getTextContent().length) {
const nextSibling = node.getNextSibling();
const parent = node.getParent();
if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
point.set(nextSibling.__key, 0, 'element');
} else if (
(isCollapsed || isBackward) &&
nextSibling === null &&
$isElementNode(parent) &&
parent.isInline() &&
!parent.canInsertTextAfter() &&
parent.getTextContentSize() > 1
) {
const parentSibling = parent.getNextSibling();
if ($isTextNode(parentSibling)) {
point.set(parentSibling.__key, 0, 'text');
}
}
}
}
function $normalizeSelectionPointsForBoundaries(
anchor: PointType,
focus: PointType,
lastSelection: null | BaseSelection,
): void {
if (anchor.type === 'text' && focus.type === 'text') {
const isBackward = anchor.isBefore(focus);
const isCollapsed = anchor.is(focus);
// Attempt to normalize the offset to the previous sibling if we're at the
// start of a text node and the sibling is a text node or inline element.
resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
if (isCollapsed) {
focus.set(anchor.key, anchor.offset, anchor.type);
}
}
}
// @experimental named-slots. The innermost slot-root ancestor of a point
// (a node whose up-pointer is __slotHost, not __parent), or null when the
// point is not inside any slot. Walking via getParent() naturally stops at a
// slot root because a slotted node's __parent is null. Non-slot trees have
// __slotHost === null everywhere, so this always returns null there.
function $getPointSlotFrame(point: PointType): LexicalNode | null {
const node = $getNodeByKey(point.key);
return node === null ? null : $getSlotFrame(node);
}
// @experimental named-slots. Content order (slots-first) of a slot-straddling
// pair, computed from the model alone. The caret comparison ($comparePoint…)
// throws across a slot boundary (a slotted node has no common ancestor through
// __parent), so each side that sits in a slot is reduced to its host — a
// main-tree node — and the hosts are compared with the linked-list isBefore.
// A slotted point sorts at its host's leading edge (slots-first). Only called
// for a confirmed straddle (the frames differ).
function $slotStraddleFocusAfterAnchor(
anchorPoint: PointType,
focusPoint: PointType,
anchorFrame: LexicalNode | null,
focusFrame: LexicalNode | null,
): boolean {
if (anchorFrame !== null && focusFrame !== null) {
const anchorHost = $getSlotHost(anchorFrame);
const focusHost = $getSlotHost(focusFrame);
if (anchorHost !== null && anchorHost.is(focusHost)) {
// Two slots of the same host: slot-map iteration is insertion order,
// which is the order the reconciler renders them (content order).
for (const slotKey of $getSlotMap(anchorHost).values()) {
if (slotKey === anchorFrame.getKey()) {
return true;
}
if (slotKey === focusFrame.getKey()) {
return false;
}
}
return true;
}
return anchorHost !== null && focusHost !== null
? anchorHost.isBefore(focusHost)
: true;
}
if (anchorFrame !== null) {
const anchorHost = $getSlotHost(anchorFrame);
const focusNode = $getNodeByKey(focusPoint.key);
if (anchorHost === null || focusNode === null) {
return true;
}
// Focus within the host's regular children sits after the slot content.
if (anchorHost.is(focusNode) || anchorHost.isParentOf(focusNode)) {
return true;
}
return anchorHost.isBefore(focusNode);
}
const focusHost = $getSlotHost(focusFrame as LexicalNode);
const anchorNode = $getNodeByKey(anchorPoint.key);
if (focusHost === null || anchorNode === null) {
return false;
}
// Anchor within the host's regular children sits after the slot (focus).
if (focusHost.is(anchorNode) || focusHost.isParentOf(anchorNode)) {
return false;
}
return anchorNode.isBefore(focusHost);
}
// @experimental named-slots. Slots are shadow-root-isolated: a RangeSelection
// must not straddle a slot boundary. When the anchor and focus are in
// different frames, clamp the focus into the anchor's frame (anchor-frame
// rule), keeping keyboard/mouse/programmatic results consistent. The direction
// is resolved lazily (resolveFocusAfterAnchor) only on an actual straddle,
// because the DOM-read and programmatic callers determine it differently (DOM
// order vs the model comparator) and the model comparator is invalid until a
// straddle is confirmed. Returns true when it mutated the focus point. No-op
// (returns false) when both points share a frame — including the all-null case
// in non-slot trees, so behavior there is unchanged.
function $clampSelectionPointsToSlotFrame(
anchorPoint: PointType,
focusPoint: PointType,
resolveFocusAfterAnchor: (
anchorFrame: LexicalNode | null,
focusFrame: LexicalNode | null,
) => boolean,
): boolean {
const anchorFrame = $getPointSlotFrame(anchorPoint);
const focusFrame = $getPointSlotFrame(focusPoint);
if (
anchorFrame === focusFrame ||
(anchorFrame !== null && focusFrame !== null && anchorFrame.is(focusFrame))
) {
return false;
}
const focusAfterAnchor = resolveFocusAfterAnchor(anchorFrame, focusFrame);
if (anchorFrame !== null) {
// Anchor sits inside a slot: pull the focus to that slot's edge (the far
// edge in the drag direction), leaving a contained partial selection.
// Slot→slot drags hit this same branch — no host escalation.
if ($isElementNode(anchorFrame)) {
focusPoint.set(
anchorFrame.getKey(),
focusAfterAnchor ? anchorFrame.getChildrenSize() : 0,
'element',
);
} else {
focusPoint.set(
anchorFrame.getKey(),
focusAfterAnchor ? anchorFrame.getTextContentSize() : 0,
'text',
);
}
return true;
}
// Anchor sits outside, focus inside a slot: push the focus past the host
// that owns the slot so the host is wholly contained.
const host = $getSlotHost(focusFrame as LexicalNode);
if (host === null) {
return false;
}
const hostParent = host.getParent();
if (hostParent === null) {
return false;
}
const hostIndex = host.getIndexWithinParent();
focusPoint.set(
hostParent.getKey(),
focusAfterAnchor ? hostIndex + 1 : hostIndex,
'element',
);
return true;
}
/**
* Programmatic counterpart of the DOM-read clamp: applied when a
* RangeSelection is committed via $setSelection so an API-built selection
* cannot straddle a slot boundary either. Direction comes from the model
* comparator (slots-first content order), not the caret system — a
* straddling pair has no common ancestor through __parent, so the caret
* comparison would throw (that integration is the deferred caret-slot work),
* and not from the DOM either, since $setSelection also runs in headless
* mode where there is no DOM. Marks the selection dirty when it mutates a
* point. No-op for non-slot trees (both frames null), evaluated before any
* direction work, so non-slot and headless callers are unaffected.
*
* @experimental named-slots
* @internal
*/
export function $clampRangeSelectionToSlotFrame(
selection: RangeSelection,
): boolean {
const clamped = $clampSelectionPointsToSlotFrame(
selection.anchor,
selection.focus,
(anchorFrame, focusFrame) =>
$slotStraddleFocusAfterAnchor(
selection.anchor,
selection.focus,
anchorFrame,
focusFrame,
),
);
if (clamped) {
selection.dirty = true;
}
return clamped;
}
function $internalResolveSelectionPoints(
anchorDOM: null | Node,
anchorOffset: number,
focusDOM: null | Node,
focusOffset: number,
editor: LexicalEditor,
lastSelection: null | BaseSelection,
): null | [anchor: PointType, focus: PointType, dirty: boolean] {
if (
anchorDOM === null ||
focusDOM === null ||
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
) {
return null;
}
const resolvedAnchor = $internalResolveSelectionPoint(
anchorDOM,
anchorOffset,
$isRangeSelection(lastSelection) ? lastSelection.anchor : null,
editor,
);
if (resolvedAnchor === null) {
return null;
}
const resolvedFocus = $internalResolveSelectionPoint(
focusDOM,
focusOffset,
$isRangeSelection(lastSelection) ? lastSelection.focus : null,
editor,
);
if (resolvedFocus === null) {
return null;
}
const [resolvedAnchorPoint, anchorDirty] = resolvedAnchor;
const [resolvedFocusPoint, focusDirty] = resolvedFocus;
if (__DEV__) {
$validatePoint('anchor', resolvedAnchorPoint);
$validatePoint('focus', resolvedFocusPoint);
}
if (
resolvedAnchorPoint.type === 'element' &&
resolvedFocusPoint.type === 'element'
) {
const anchorNode = $getNodeFromDOM(anchorDOM);
const focusNode = $getNodeFromDOM(focusDOM);
// Ensure if we're selecting the content of a decorator that we
// return null for this point, as it's not in the controlled scope
// of Lexical.
if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
return null;
}
}
// @experimental named-slots. Clamp a slot-straddling drag into the
// anchor's frame before normalization cleans up the resulting edge points.
// The DOM order of the resolved nodes gives the drag direction (slot DOM is
// slots-first, so DOM order matches content order). Gated on `_slotsUsed`
// so editors that never slot anything skip the walk, mirroring the
// commit-time and `$setSelection` clamps.
const slotClamped =
editor._slotsUsed &&
$clampSelectionPointsToSlotFrame(
resolvedAnchorPoint,
resolvedFocusPoint,
() =>
(anchorDOM.compareDocumentPosition(focusDOM) &
Node.DOCUMENT_POSITION_FOLLOWING) !==
0,
);
// Handle normalization of selection when it is at the boundaries.
$normalizeSelectionPointsForBoundaries(
resolvedAnchorPoint,
resolvedFocusPoint,
lastSelection,
);
return [
resolvedAnchorPoint,
resolvedFocusPoint,
anchorDirty || focusDirty || slotClamped,
];
}
export function $isBlockElementNode(
node: LexicalNode | null | undefined,
): node is ElementNode {
return $isElementNode(node) && !node.isInline();
}
// This is used to make a selection when the existing
// selection is null, i.e. forcing selection on the editor
// when it current exists outside the editor.
export function $internalMakeRangeSelection(
anchorKey: NodeKey,
anchorOffset: number,
focusKey: NodeKey,
focusOffset: number,
anchorType: 'text' | 'element',
focusType: 'text' | 'element',
): RangeSelection {
const editorState = getActiveEditorState();
const selection = new RangeSelection(
$createPoint(anchorKey, anchorOffset, anchorType),
$createPoint(focusKey, focusOffset, focusType),
0,
'',
);
selection.dirty = true;
editorState._selection = selection;
return selection;
}
export function $createRangeSelection(): RangeSelection {
const anchor = $createPoint('root', 0, 'element');
const focus = $createPoint('root', 0, 'element');
return new RangeSelection(anchor, focus, 0, '');
}
export function $createNodeSelection(): NodeSelection {
return new NodeSelection(new Set());
}
export function $internalCreateSelection(
editor: LexicalEditor,
event: UIEvent | Event | null,
): null | BaseSelection {
const currentEditorState = editor.getEditorState();
const lastSelection = currentEditorState._selection;
const domSelection = getDOMSelection(getWindow(editor));
if ($isRangeSelection(lastSelection) || lastSelection == null) {
return $internalCreateRangeSelection(
lastSelection,
domSelection,
editor,
event,
);
}
return lastSelection.clone();
}
export function $createRangeSelectionFromDom(
domSelection: Selection | null,
editor: LexicalEditor,
): null | RangeSelection {
return $internalCreateRangeSelection(null, domSelection, editor, null);
}
export function $internalCreateRangeSelection(
lastSelection: null | BaseSelection,
domSelection: Selection | null,
editor: LexicalEditor,
event: UIEvent | Event | null,
): null | RangeSelection {
const windowObj = editor._window;
if (windowObj === null) {
return null;
}
// When we create a selection, we try to use the previous
// selection where possible, unless an actual user selection
// change has occurred. When we do need to create a new selection
// we validate we can have text nodes for both anchor and focus
// nodes. If that holds true, we then return that selection
// as a mutable object that we use for the editor state for this
// update cycle. If a selection gets changed, and requires a
// update to native DOM selection, it gets marked as "dirty".
// If the selection changes, but matches with the existing
// DOM selection, then we only need to sync it. Otherwise,
// we generally bail out of doing an update to selection during
// reconciliation unless there are dirty nodes that need
// reconciling.
const windowEvent = event || windowObj.event;
const eventType = windowEvent ? windowEvent.type : undefined;
const isSelectionChange = eventType === 'selectionchange';
const useDOMSelection =
!getIsProcessingMutations() &&
(isSelectionChange ||
eventType === 'beforeinput' ||
eventType === 'compositionstart' ||
eventType === 'compositionend' ||
(eventType === 'click' &&
windowEvent &&
(windowEvent as InputEvent).detail === 3) ||
eventType === 'drop' ||
eventType === undefined);
let anchorDOM, focusDOM, anchorOffset, focusOffset;
if (!$isRangeSelection(lastSelection) || useDOMSelection) {
if (domSelection === null) {
return null;
}
const points = getDOMSelectionPoints(domSelection, editor._rootElement);
anchorDOM = points.anchorNode;
focusDOM = points.focusNode;
anchorOffset = points.anchorOffset;
focusOffset = points.focusOffset;
if (
(isSelectionChange || eventType === undefined) &&
$isRangeSelection(lastSelection) &&
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
) {
return lastSelection.clone();
}
} else {
return lastSelection.clone();
}
// Let's resolve the text nodes from the offsets and DOM nodes we have from
// native selection.
const resolvedSelectionPoints = $internalResolveSelectionPoints(
anchorDOM,
anchorOffset,
focusDOM,
focusOffset,
editor,
lastSelection,
);
if (resolvedSelectionPoints === null) {
return null;
}
const [resolvedAnchorPoint, resolvedFocusPoint, dirty] =
resolvedSelectionPoints;
let format = 0;
let style = '';
if ($isRangeSelection(lastSelection)) {
const lastAnchor = lastSelection.anchor;
if (resolvedAnchorPoint.key === lastAnchor.key) {
format = lastSelection.format;
style = lastSelection.style;
} else {
const anchorNode = resolvedAnchorPoint.getNode();
if ($isTextNode(anchorNode)) {
format = anchorNode.getFormat();
style = anchorNode.getStyle();
} else if ($isElementNode(anchorNode)) {
format = anchorNode.getTextFormat();
style = anchorNode.getTextStyle();
}
}
}
const newSelection = new RangeSelection(
resolvedAnchorPoint,
resolvedFocusPoint,
format,
style,
);
if (dirty) {
newSelection.dirty = true;
}
return newSelection;
}
function $validatePoint(name: 'anchor' | 'focus', point: PointType): void {
const node = $getNodeByKey(point.key);
invariant(
node !== undefined,
'$validatePoint: %s key %s not found in current editorState',
name,
point.key,
);
if (point.type === 'text') {
invariant(
$isTextNode(node),
'$validatePoint: %s key %s is not a TextNode',
name,
point.key,
);
const size = node.getTextContentSize();
invariant(
point.offset <= size,
'$validatePoint: %s point.offset > node.getTextContentSize() (%s > %s)',
name,
String(point.offset),
String(size),
);
} else {
invariant(
$isElementNode(node),
'$validatePoint: %s key %s is not an ElementNode',
name,
point.key,
);
const size = node.getChildrenSize();
invariant(
point.offset <= size,
'$validatePoint: %s point.offset > node.getChildrenSize() (%s > %s)',
name,
String(point.offset),
String(size),
);
}
}
export function $getSelection(): null | BaseSelection {
const editorState = getActiveEditorState();
return editorState._selection;
}
export function $getPreviousSelection(): null | BaseSelection {
const editor = getActiveEditor();
return editor._editorState._selection;
}
export function $updateElementSelectionOnCreateDeleteNode(
selection: RangeSelection,
parentNode: LexicalNode,
nodeOffset: number,
times = 1,
): void {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
return;
}
const parentKey = parentNode.__key;
// Single node. We shift selection but never redimension it
if (selection.isCollapsed()) {
const selectionOffset = anchor.offset;
if (
(nodeOffset <= selectionOffset && times > 0) ||
(nodeOffset < selectionOffset && times < 0)
) {
const newSelectionOffset = Math.max(0, selectionOffset + times);
anchor.set(parentKey, newSelectionOffset, 'element');
focus.set(parentKey, newSelectionOffset, 'element');
// The new selection might point to text nodes, try to resolve them
$updateSelectionResolveTextNodes(selection);
}
} else {
// Multiple nodes selected. We shift or redimension selection
const isBackward = selection.isBackward();
const firstPoint = isBackward ? focus : anchor;
const firstPointNode = firstPoint.getNode();
const lastPoint = isBackward ? anchor : focus;
const lastPointNode = lastPoint.getNode();
if (parentNode.is(firstPointNode)) {
const firstPointOffset = firstPoint.offset;
if (
(nodeOffset <= firstPointOffset && times > 0) ||
(nodeOffset < firstPointOffset && times < 0)
) {
firstPoint.set(
parentKey,
Math.max(0, firstPointOffset + times),
'element',
);
}
}
if (parentNode.is(lastPointNode)) {
const lastPointOffset = lastPoint.offset;
if (
(nodeOffset <= lastPointOffset && times > 0) ||
(nodeOffset < lastPointOffset && times < 0)
) {
lastPoint.set(
parentKey,
Math.max(0, lastPointOffset + times),
'element',
);
}
}
}
// The new selection might point to text nodes, try to resolve them
$updateSelectionResolveTextNodes(selection);
}
function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
const anchor = selection.anchor;
const anchorOffset = anchor.offset;
const focus = selection.focus;
const focusOffset = focus.offset;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (selection.isCollapsed()) {
if (!$isElementNode(anchorNode)) {
return;
}
const childSize = anchorNode.getChildrenSize();
const anchorOffsetAtEnd = anchorOffset >= childSize;
const child = anchorOffsetAtEnd
? anchorNode.getChildAtIndex(childSize - 1)
: anchorNode.getChildAtIndex(anchorOffset);
if ($isTextNode(child)) {
let newOffset = 0;
if (anchorOffsetAtEnd) {
newOffset = child.getTextContentSize();
}
anchor.set(child.__key, newOffset, 'text');
focus.set(child.__key, newOffset, 'text');
}
return;
}
if ($isElementNode(anchorNode)) {
const childSize = anchorNode.getChildrenSize();
const anchorOffsetAtEnd = anchorOffset >= childSize;
const child = anchorOffsetAtEnd
? anchorNode.getChildAtIndex(childSize - 1)
: anchorNode.getChildAtIndex(anchorOffset);
if ($isTextNode(child)) {
let newOffset = 0;
if (anchorOffsetAtEnd) {
newOffset = child.getTextContentSize();
}
anchor.set(child.__key, newOffset, 'text');
}
}
if ($isElementNode(focusNode)) {
const childSize = focusNode.getChildrenSize();
const focusOffsetAtEnd = focusOffset >= childSize;
const child = focusOffsetAtEnd
? focusNode.getChildAtIndex(childSize - 1)
: focusNode.getChildAtIndex(focusOffset);
if ($isTextNode(child)) {
let newOffset = 0;
if (focusOffsetAtEnd) {
newOffset = child.getTextContentSize();
}
focus.set(child.__key, newOffset, 'text');
}
}
}
export function applySelectionTransforms(
nextEditorState: EditorState,
editor: LexicalEditor,
): void {
const prevEditorState = editor.getEditorState();
const prevSelection = prevEditorState._selection;
const nextSelection = nextEditorState._selection;
if ($isRangeSelection(nextSelection)) {
const anchor = nextSelection.anchor;
const focus = nextSelection.focus;
let anchorNode;
if (anchor.type === 'text') {
anchorNode = anchor.getNode();
anchorNode.selectionTransform(prevSelection, nextSelection);
}
if (focus.type === 'text') {
const focusNode = focus.getNode();
if (anchorNode !== focusNode) {
focusNode.selectionTransform(prevSelection, nextSelection);
}
}
}
}
export function moveSelectionPointToSibling(
point: PointType,
node: LexicalNode,
parent: ElementNode,
prevSibling: LexicalNode | null,
nextSibling: LexicalNode | null,
): void {
let siblingKey = null;
let offset = 0;
let type: 'text' | 'element' | null = null;
if (prevSibling !== null) {
siblingKey = prevSibling.__key;
if ($isTextNode(prevSibling)) {
offset = prevSibling.getTextContentSize();
type = 'text';
} else if ($isElementNode(prevSibling)) {
offset = prevSibling.getChildrenSize();
type = 'element';
}
} else {
if (nextSibling !== null) {
siblingKey = nextSibling.__key;
if ($isTextNode(nextSibling)) {
type = 'text';
} else if ($isElementNode(nextSibling)) {
type = 'element';
}
}
}
if (siblingKey !== null && type !== null) {
point.set(siblingKey, offset, type);
} else {
offset = node.getIndexWithinParent();
if (offset === -1) {
// Move selection to end of parent
offset = parent.getChildrenSize();
}
point.set(parent.__key, offset, 'element');
}
}
export function adjustPointOffsetForMergedSibling(
point: PointType,
isBefore: boolean,
key: NodeKey,
target: TextNode,
textLength: number,
): void {
if (point.type === 'text') {
point.set(key, point.offset + (isBefore ? 0 : textLength), 'text');
} else if (point.offset > target.getIndexWithinParent()) {
point.set(point.key, point.offset - 1, 'element');
}
}
function setDOMSelectionBaseAndExtent(
domSelection: Selection,
nextAnchorDOM: HTMLElement | Text,
nextAnchorOffset: number,
nextFocusDOM: HTMLElement | Text,
nextFocusOffset: number,
): void {
// Apply the updated selection to the DOM. Note: this will trigger
// a "selectionchange" event, although it will be asynchronous.
try {
domSelection.setBaseAndExtent(
nextAnchorDOM,
nextAnchorOffset,
nextFocusDOM,
nextFocusOffset,
);
} catch (error) {
// If we encounter an error, continue. This can sometimes
// occur with FF and there's no good reason as to why it
// should happen.
if (__DEV__) {
console.warn(error);
}
}
}
function $getElementAndOffsetForPoint(
editor: LexicalEditor,
node: LexicalNode,
offset: number,
): [HTMLElement, number] {
const element = getElementByKeyOrThrow(editor, node.getKey());
if ($isElementNode(node)) {
const slot = $getDOMSlot(node, element, editor);
return [slot.element, offset + slot.getFirstChildOffset()];
}
return [element, offset];
}
/** @internal */
export function $updateDOMSelection(
prevSelection: BaseSelection | null,
nextSelection: BaseSelection | null,
editor: LexicalEditor,
domSelection: Selection,
tags: Set<string>,
rootElement: HTMLElement,
): void {
const rootForActive = rootElement.getRootNode();
const activeElement =
isDOMDocumentNode(rootForActive) || isDOMShadowRoot(rootForActive)
? getActiveElementDeep(rootForActive)
: null;
// TODO: make this not hard-coded, and add another config option
// that makes this configurable.
if (
(tags.has(COLLABORATION_TAG) && activeElement !== rootElement) ||
(activeElement !== null &&
$isSelectionCapturedInDecoratorInput(activeElement, activeElement))
) {
return;
}
// Resolve the live DOM selection's boundary points through any enclosing
// DOM shadow roots; Selection.anchorNode/focusNode are retargeted to the
// shadow host, so the comparisons below read composed points instead. In
// the light DOM getDOMSelectionPoints returns `domSelection` itself (no
// Selection property reads happen here), so `currentPoints` aliases it
// and preserves the deferred reads described below. The matching live
// Range is computed lazily in `getCurrentRange()` so the scroll-into-view
// fallback below is the only path that pays `getRangeAt(0)`'s layout
// flush — `getDOMSelectionRangeAndPoints()` (the public helper) still
// returns both eagerly for external callers.
const currentPoints = getDOMSelectionPoints(domSelection, rootElement);
let currentRangeCache: Range | null | undefined;
const getCurrentRange = (): Range | null => {
if (currentRangeCache === undefined) {
// Resolve through any enclosing shadow roots: getRangeAt(0) alone is
// retargeted to the shadow host inside a shadow tree, so the
// scroll-into-view rect below would measure the host instead of the
// caret. getDOMSelectionRange falls back to getRangeAt(0) in the light
// DOM.
currentRangeCache = getDOMSelectionRange(domSelection, rootElement);
}
return currentRangeCache;
};
if (!$isRangeSelection(nextSelection)) {
// We don't remove selection if the prevSelection is null because
// of editor.setRootElement(). If this occurs on init when the
// editor is already focused, then this can cause the editor to
// lose focus.
if (
prevSelection !== null &&
isSelectionWithinEditor(
editor,
currentPoints.anchorNode,
currentPoints.focusNode,
)
) {
domSelection.removeAllRanges();
}
return;
}
// DOM Selection property reads (anchorNode, focusNode, anchorOffset,
// focusOffset) are deferred to their single point of use in the diff
// check below, and guarded by a cheap domSelection.type check first.
// These reads force the browser to resolve the selection against the
// current layout, triggering synchronous style/layout recalculation.
const anchor = nextSelection.anchor;
const focus = nextSelection.focus;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
const [anchorDOM, nextAnchorOffset] = $getElementAndOffsetForPoint(
editor,
anchorNode,
anchor.offset,
);
const [focusDOM, nextFocusOffset] = $getElementAndOffsetForPoint(
editor,
focusNode,
focus.offset,
);
const nextFormat = nextSelection.format;
const nextStyle = nextSelection.style;
const isCollapsed = nextSelection.isCollapsed();
let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
let nextFocusNode: HTMLElement | Text | null = focusDOM;
let anchorFormatOrStyleChanged = false;
if (anchor.type === 'text') {
nextAnchorNode = $isTextNode(anchorNode)
? $getDOMTextNode(anchorNode, anchorDOM, editor)
: null;
anchorFormatOrStyleChanged =
anchorNode.getFormat() !== nextFormat ||
anchorNode.getStyle() !== nextStyle;
} else if (
$isRangeSelection(prevSelection) &&
prevSelection.anchor.type === 'text'
) {
anchorFormatOrStyleChanged = true;
}
if (focus.type === 'text') {
nextFocusNode = $isTextNode(focusNode)
? $getDOMTextNode(focusNode, focusDOM, editor)
: null;
}
// If we can't get an underlying text node for selection, then
// we should avoid setting selection to something incorrect.
if (nextAnchorNode === null || nextFocusNode === null) {
return;
}
if (
isCollapsed &&
(prevSelection === null ||
anchorFormatOrStyleChanged ||
($isRangeSelection(prevSelection) &&
(prevSelection.format !== nextFormat ||
prevSelection.style !== nextStyle)))
) {
markCollapsedSelectionFormat(
editor,
nextFormat,
nextStyle,
nextAnchorOffset,
anchor.key,
performance.now(),
);
}
// Diff against the native DOM selection to ensure we don't do
// an unnecessary selection update. We also skip this check if
// we're moving selection to within an element, as this can
// sometimes be problematic around scrolling.
if (
!(domSelection.type === 'Range' && isCollapsed) && // Badly interpreted range selection when collapsed - #1482
currentPoints.anchorOffset === nextAnchorOffset &&
currentPoints.focusOffset === nextFocusOffset &&
currentPoints.anchorNode === nextAnchorNode &&
currentPoints.focusNode === nextFocusNode
) {
// If the root element does not have focus, ensure it has focus — but
// not when the deep-focused element belongs to a different editor
// (e.g. the inner editor of a coexisting outer-editor / shadow-editor
// pair). Stealing focus there breaks the user's typing flow.
if (activeElement === null || !rootElement.contains(activeElement)) {
const focusEditor =
activeElement !== null
? getNearestEditorFromDOMNode(activeElement)
: null;
if (
(focusEditor === null || focusEditor === editor) &&
!tags.has(SKIP_SELECTION_FOCUS_TAG)
) {
rootElement.focus({
preventScroll: true,
});
}
}
if (anchor.type !== 'element') {
return;
}
}
// Apply the updated selection to the DOM. Note: this will trigger
// a "selectionchange" event, although it will be asynchronous.
setDOMSelectionBaseAndExtent(
domSelection,
nextAnchorNode,
nextAnchorOffset,
nextFocusNode,
nextFocusOffset,
);
// Firefox-specific fix: After setting DOM selection, ensure root element has focus
// to maintain cursor visibility. Firefox requires focus to be on the root element
// for the cursor to be visible, especially after operations like drag that may
// cause focus loss. This is critical for collapsed selections (cursor).
if (
IS_FIREFOX &&
nextSelection.isCollapsed() &&
rootElement !== null &&
!tags.has(SKIP_SELECTION_FOCUS_TAG)
) {
// Shallow active element for the containment check: rootElement.contains()
// does not cross shadow boundaries, so a host-retargeted result gives the
// correct containment outcome (and avoids a false "outside" when focus is
// in a nested decorator shadow inside this editor).
const focusedElement = getActiveElement(rootElement);
if (focusedElement === null || !rootElement.contains(focusedElement)) {
// Don't steal focus when the active element belongs to a *different*
// editor (e.g. the inner editor of a coexisting outer-editor /
// shadow-editor pair). Resolve the *deep* focused element for this
// attribution: a shallow read returns the other editor's shadow host,
// which getNearestEditorFromDOMNode can't map back to that editor, so
// the guard would otherwise wrongly steal focus from a shadow-mounted
// sibling.
const deepFocusedElement = getActiveElementDeep(
rootElement.ownerDocument,
);
const focusEditor =
deepFocusedElement !== null
? getNearestEditorFromDOMNode(deepFocusedElement)
: null;
if (focusEditor === null || focusEditor === editor) {
// Restore focus immediately to ensure cursor visibility.
// Note: We rely on the normal selection update mechanism to ensure the
// cursor is visible. Using requestAnimationFrame here could cause race
// conditions where another update changes the selection before the rAF
// callback executes.
rootElement.focus({preventScroll: true});
}
}
}
if (
!tags.has(SKIP_SCROLL_INTO_VIEW_TAG) &&
nextSelection.isCollapsed() &&
rootElement !== null &&
// Re-read the active element rather than a value cached before the focus
// restore / selection mutation above, which can become stale (e.g. when
// setting the DOM selection focuses the contentEditable as a side effect).
// Shallow is sufficient here for the same reason as the Firefox branch
// above: the equality check doesn't cross the shadow boundary.
rootElement === getActiveElement(rootElement)
) {
const selectionTarget: null | Range | HTMLElement | Text =
$isRangeSelection(nextSelection) &&
nextSelection.anchor.type === 'element'
? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
null
: getCurrentRange();
if (selectionTarget !== null) {
let selectionRect: DOMRect;
if (isDOMTextNode(selectionTarget)) {
const range = selectionTarget.ownerDocument.createRange();
range.selectNode(selectionTarget);
selectionRect = range.getBoundingClientRect();
} else {
selectionRect = selectionTarget.getBoundingClientRect();
}
scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
}
}
markSelectionChangeFromDOMUpdate(editor);
}
export function $insertNodes(nodes: LexicalNode[]) {
let selection = $getSelection() || $getPreviousSelection();
if (selection === null) {
selection = $getRoot().selectEnd();
}
selection.insertNodes(nodes);
}
/**
* Push-lexer visitor passed to {@link tokenizeRawText}. The tokenizer
* invokes one callback per token it emits; empty text runs are
* suppressed, so `text` is only invoked with a non-empty string.
*/
export interface RawTextVisitor {
readonly linebreak: () => void;
readonly tab: () => void;
readonly text: (text: string) => void;
}
/**
* Push-lex a raw text string into `linebreak` (`\n` / `\r\n`), `tab`
* (`\t`), and `text` (everything else) tokens, dispatching each to the
* matching callback on `visitor` in source order.
*
* Shared by {@link $generateNodesFromRawText} (which builds
* `LineBreakNode` / `TabNode` / `TextNode` siblings) and by
* `@lexical/clipboard`'s default `text/plain` clipboard importer
* (which maps `linebreak` to a real paragraph break via
* `insertParagraph` so multi-line plain text becomes multi-paragraph
* rich text). Empty text runs are dropped so callers don't need to
* special-case them.
*/
export function tokenizeRawText(text: string, visitor: RawTextVisitor): void {
for (const part of text.split(/(\r?\n|\t)/)) {
if (part === '\n' || part === '\r\n') {
visitor.linebreak();
} else if (part === '\t') {
visitor.tab();
} else if (part !== '') {
visitor.text(part);
}
}
}
/**
* Convert a raw text string into a flat array of `TextNode`,
* `LineBreakNode`, and `TabNode` siblings, splitting on `\n`, `\r\n`,
* and `\t`. Use this when you need the same `\n` / `\t` → real-node
* conversion that {@link RangeSelection.insertRawText} performs but
* without a selection — e.g. when building a `CodeNode`'s children
* inside a DOM-import rule.
*/
export function $generateNodesFromRawText(
text: string,
): (TextNode | LineBreakNode)[] {
const nodes: (TextNode | LineBreakNode)[] = [];
tokenizeRawText(text, {
linebreak: () => nodes.push($createLineBreakNode()),
tab: () => nodes.push($createTabNode()),
text: part => nodes.push($createTextNode(part)),
});
return nodes;
}
export function $getTextContent(): string {
const selection = $getSelection();
if (selection === null) {
return '';
}
return selection.getTextContent();
}
// @experimental named-slots. Inline projection of a pasted node list for a
// block-shaped slot value (insertNodes CASE 3a): inline nodes pass through,
// non-inline elements contribute their inline content recursively, line
// breaks are stripped (the <input> value-sanitization analogy for newlines),
// and non-inline decorators are dropped.
function $extractInlineFromBlocks(nodes: LexicalNode[]): LexicalNode[] {
const inlineNodes: LexicalNode[] = [];
for (const node of nodes) {
if ($isLineBreakNode(node)) {
continue;
}
if (($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline()) {
if ($isElementNode(node)) {
inlineNodes.push(...$extractInlineFromBlocks(node.getChildren()));
}
continue;
}
inlineNodes.push(node);
}
return inlineNodes;
}
function $removeTextAndSplitBlock(selection: RangeSelection): number {
let selection_ = selection;
if (!selection.isCollapsed()) {
selection_.removeText();
}
// A new selection can originate as a result of node replacement, in which case is registered via
// $setSelection
const newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
selection_ = newSelection;
}
invariant(
$isRangeSelection(selection_),
'Unexpected dirty selection to be null',
);
const anchor = selection_.anchor;
let node = anchor.getNode();
let offset = anchor.offset;
// A slotted node is the virtual scope root (its parent is null), so the
// split walk must stop there even when it is not INTERNAL_$isBlock itself
// (e.g. a container-shaped slot value with element children) — otherwise
// $splitNodeAtPoint's parentless fallback would append a stray paragraph
// to the document root.
while (!INTERNAL_$isBlock(node) && $getSlotHostKey(node) === null) {
const prevNode = node;
[node, offset] = $splitNodeAtPoint(node, offset);
if (prevNode.is(node)) {
break;
}
}
return offset;
}
function $splitNodeAtPoint(
node: LexicalNode,
offset: number,
): [parent: ElementNode, offset: number] {
const parent = node.getParent();
if (!parent) {
const paragraph = $createParagraphNode();
$getRoot().append(paragraph);
paragraph.select();
return [$getRoot(), 0];
}
if ($isTextNode(node)) {
const split = node.splitText(offset);
if (split.length === 0) {
return [parent, node.getIndexWithinParent()];
}
const x = offset === 0 ? 0 : 1;
const index = split[0].getIndexWithinParent() + x;
return [parent, index];
}
if (!$isElementNode(node) || offset === 0) {
return [parent, node.getIndexWithinParent()];
}
const firstToAppend = node.getChildAtIndex(offset);
if (firstToAppend) {
const insertPoint = new RangeSelection(
$createPoint(node.__key, offset, 'element'),
$createPoint(node.__key, offset, 'element'),
0,
'',
);
const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
if (newElement) {
newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
}
}
return [parent, node.getIndexWithinParent() + 1];
}
function $isInlineRunNode(node: LexicalNode): boolean {
return (
$isLineBreakNode(node) ||
$isInlineElementOrDecoratorNode(node) ||
$isTextNode(node) ||
node.isParentRequired()
);
}
function $wrapInlineNodes(nodes: LexicalNode[]) {
// We temporarily insert the topLevelNodes into an arbitrary ElementNode,
// since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
const virtualRoot = $createParagraphNode();
let currentBlock: ElementNode | null = null;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isInlineRunNode(node)) {
if (currentBlock === null) {
currentBlock = node.createParentElementNode();
virtualRoot.append(currentBlock);
// A LineBreakNode that is an entire run by itself collapses to an
// empty paragraph, since the block boundary already provides the
// visual newline (the form that clipboard pastes ending in a
// trailing <br> rely on, and the same policy as
// $paragraphPackageRun in @lexical/html). A linebreak followed by
// more inline content in the same run is preserved.
const nextNode: LexicalNode | undefined = nodes[i + 1];
if (
$isLineBreakNode(node) &&
(nextNode === undefined || !$isInlineRunNode(nextNode))
) {
continue;
}
}
currentBlock.append(node);
} else {
virtualRoot.append(node);
currentBlock = null;
}
}
return virtualRoot;
}
/**
* Get all nodes in a CaretRange in a way that complies with all of the
* quirks of the original RangeSelection.getNodes().
*
* @param range The CaretRange
*/
function $getNodesFromCaretRangeCompat(
// getNodes returned nodes in document order
range: CaretRange<'next'>,
): LexicalNode[] {
const nodes: LexicalNode[] = [];
const [beforeSlice, afterSlice] = range.getTextSlices();
if (beforeSlice) {
nodes.push(beforeSlice.caret.origin);
}
const seenAncestors = new Set<ElementNode>();
const seenElements = new Set<ElementNode>();
for (const caret of range) {
if ($isChildCaret(caret)) {
// Emulate the leading under-selection behavior of getNodes by
// ignoring the 'enter' of any ElementNode until we've seen a
// SiblingCaret
const {origin} = caret;
if (nodes.length === 0) {
seenAncestors.add(origin);
} else {
seenElements.add(origin);
nodes.push(origin);
}
} else {
const {origin} = caret;
if (!$isElementNode(origin) || !seenElements.has(origin)) {
nodes.push(origin);
}
}
}
if (afterSlice) {
nodes.push(afterSlice.caret.origin);
}
// Emulate the trailing underselection behavior when the last offset of
// an element is selected
if (
$isSiblingCaret(range.focus) &&
$isElementNode(range.focus.origin) &&
range.focus.getNodeAtCaret() === null
) {
for (
let reverseCaret: null | NodeCaret<'previous'> = $getChildCaret(
range.focus.origin,
'previous',
);
$isChildCaret(reverseCaret) &&
seenAncestors.has(reverseCaret.origin) &&
!reverseCaret.origin.isEmpty() &&
reverseCaret.origin.is(nodes[nodes.length - 1]);
reverseCaret = $getAdjacentChildCaret(reverseCaret)
) {
seenAncestors.delete(reverseCaret.origin);
nodes.pop();
}
}
while (nodes.length > 1) {
const lastIncludedNode = nodes[nodes.length - 1];
if ($isElementNode(lastIncludedNode)) {
if (
seenElements.has(lastIncludedNode) ||
lastIncludedNode.isEmpty() ||
seenAncestors.has(lastIncludedNode)
) {
// fall through to break
} else {
nodes.pop();
continue;
}
}
break;
}
if (nodes.length === 0 && range.isCollapsed()) {
// Emulate the collapsed behavior of getNodes by returning the descendant
const normCaret = $normalizeCaret(range.anchor);
const flippedNormCaret = $normalizeCaret(range.anchor.getFlipped());
const $getCandidate = (caret: PointCaret): LexicalNode | null =>
$isTextPointCaret(caret) ? caret.origin : caret.getNodeAtCaret();
const node =
$getCandidate(normCaret) ||
$getCandidate(flippedNormCaret) ||
(range.anchor.getNodeAtCaret()
? normCaret.origin
: flippedNormCaret.origin);
nodes.push(node);
}
return nodes;
}
/**
* @internal
*
* Modify the focus of the focus around possible decorators and blocks and return true
* if the movement is done.
*/
function $modifySelectionAroundDecoratorsAndBlocks(
selection: RangeSelection,
alter: 'move' | 'extend',
isBackward: boolean,
granularity: 'character' | 'word' | 'lineboundary',
mode: 'decorators-and-blocks' | 'decorators' = 'decorators-and-blocks',
): boolean {
if (
alter === 'move' &&
granularity === 'character' &&
!selection.isCollapsed()
) {
// moving left or right when the selection isn't collapsed will
// just set the anchor to the focus or vice versa depending on
// direction
const [src, dst] =
isBackward === selection.isBackward()
? [selection.focus, selection.anchor]
: [selection.anchor, selection.focus];
dst.set(src.key, src.offset, src.type);
return true;
}
const initialFocus = $caretFromPoint(
selection.focus,
isBackward ? 'previous' : 'next',
);
const isLineBoundary = granularity === 'lineboundary';
const collapse = alter === 'move';
let focus = initialFocus;
let checkForBlock = mode === 'decorators-and-blocks';
if (!$isExtendableTextPointCaret(focus)) {
for (const siblingCaret of focus) {
checkForBlock = false;
const {origin} = siblingCaret;
if ($isDecoratorNode(origin) && !origin.isIsolated()) {
focus = siblingCaret;
if (isLineBoundary && origin.isInline()) {
continue;
}
}
break;
}
if (checkForBlock) {
for (const nextCaret of $extendCaretToRange(initialFocus).iterNodeCarets(
alter === 'extend' ? 'shadowRoot' : 'root',
)) {
if ($isChildCaret(nextCaret)) {
if (!nextCaret.origin.isInline()) {
focus = nextCaret;
}
} else if ($isElementNode(nextCaret.origin)) {
continue;
} else if (
$isDecoratorNode(nextCaret.origin) &&
!nextCaret.origin.isInline()
) {
focus = nextCaret;
}
break;
}
}
}
if (focus === initialFocus) {
return false;
}
// After this point checkForBlock is true if and only if we moved to a
// different block, so we should stop regardless of the granularity
if (
collapse &&
!isLineBoundary &&
$isDecoratorNode(focus.origin) &&
focus.origin.isKeyboardSelectable()
) {
// Make it possible to move selection from range selection to
// node selection on the node.
const nodeSelection = $createNodeSelection();
nodeSelection.add(focus.origin.getKey());
$setSelection(nodeSelection);
return true;
}
focus = $normalizeCaret(focus);
if (collapse) {
$setPointFromCaret(selection.anchor, focus);
}
$setPointFromCaret(selection.focus, focus);
return checkForBlock || !isLineBoundary;
}