lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
1,609 lines (1,495 loc) • 118 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 {NodeKey} from './LexicalNode';
import type {ElementNode} from './nodes/LexicalElementNode';
import type {TextFormatType} from './nodes/LexicalTextNode';
import invariant from '@lexical/internal/invariant';
import {
$caretFromPoint,
$caretRangeFromSelection,
$comparePointCaretNext,
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$extendCaretToRange,
$getAdjacentChildCaret,
$getCaretRange,
$getCaretRangeInDirection,
$getChildCaret,
$getSiblingCaret,
$getTextNodeOffset,
$isChildCaret,
$isDecoratorNode,
$isElementNode,
$isExtendableTextPointCaret,
$isLineBreakNode,
$isParagraphNode,
$isRootNode,
$isSiblingCaret,
$isTextNode,
$isTextPointCaret,
$normalizeCaret,
$removeTextFromCaretRange,
$rewindSiblingCaret,
$setPointFromCaret,
$setSelection,
$setSelectionFromCaretRange,
$updateRangeSelectionFromCaretRange,
CaretRange,
ChildCaret,
COLLABORATION_TAG,
type LineBreakNode,
NodeCaret,
PointCaret,
SKIP_SCROLL_INTO_VIEW_TAG,
type TextNode,
} from '.';
import {IS_FIREFOX} from './environment';
import {TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
import {
markCollapsedSelectionFormat,
markSelectionChangeFromDOMUpdate,
} from './LexicalEvents';
import {getIsProcessingMutations} from './LexicalMutations';
import {insertRangeAfter, LexicalNode} from './LexicalNode';
import {$normalizeSelection} from './LexicalNormalization';
import {
getActiveEditor,
getActiveEditorState,
isCurrentlyReadOnlyMode,
} from './LexicalUpdates';
import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags';
import {
$findMatchingParent,
$getCompositionKey,
$getDOMSlot,
$getDOMTextNode,
$getNearestRootOrShadowRoot,
$getNodeByKey,
$getNodeFromDOM,
$getRoot,
$hasAncestor,
$isRootOrShadowRoot,
$isSelectionCapturedInDecorator,
$isTokenOrSegmented,
$isTokenOrTab,
$setCompositionKey,
doesContainSurrogatePair,
getDOMSelection,
getElementByKeyOrThrow,
getNodeKeyFromDOMNode,
getWindow,
INTERNAL_$isBlock,
isHTMLElement,
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
removeDOMBlockCursorElement,
scrollIntoViewIfNeeded,
toggleTextFormatType,
} from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
const __DEV__ = process.env.NODE_ENV !== 'production';
export type TextPointType = {
_selection: BaseSelection;
getNode: () => TextNode;
is: (point: PointType) => boolean;
isBefore: (point: PointType) => boolean;
key: NodeKey;
offset: number;
set: (
key: NodeKey,
offset: number,
type: 'text' | 'element',
onlyIfChanged?: boolean,
) => void;
type: 'text';
};
export type ElementPointType = {
_selection: BaseSelection;
getNode: () => ElementNode;
is: (point: PointType) => boolean;
isBefore: (point: PointType) => boolean;
key: NodeKey;
offset: number;
set: (
key: NodeKey,
offset: number,
type: 'text' | 'element',
onlyIfChanged?: boolean,
) => void;
type: 'element';
};
export type PointType = TextPointType | ElementPointType;
export class Point {
key: NodeKey;
offset: number;
type: 'text' | 'element';
_selection: BaseSelection | null;
constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
if (__DEV__) {
// This prevents a circular reference error when serialized as JSON,
// which happens on unit test failures
Object.defineProperty(this, '_selection', {
enumerable: false,
writable: true,
});
}
this._selection = null;
this.key = key;
this.offset = offset;
this.type = type;
}
is(point: PointType): boolean {
return (
this.key === point.key &&
this.offset === point.offset &&
this.type === point.type
);
}
isBefore(b: PointType): boolean {
if (this.key === b.key) {
return this.offset < b.offset;
}
const aCaret = $normalizeCaret($caretFromPoint(this, 'next'));
const bCaret = $normalizeCaret($caretFromPoint(b, 'next'));
return $comparePointCaretNext(aCaret, bCaret) < 0;
}
getNode(): LexicalNode {
const key = this.key;
const node = $getNodeByKey(key);
if (node === null) {
invariant(false, 'Point.getNode: node not found');
}
return node;
}
set(
key: NodeKey,
offset: number,
type: 'text' | 'element',
onlyIfChanged?: boolean,
): void {
const selection = this._selection;
const oldKey = this.key;
if (
onlyIfChanged &&
this.key === key &&
this.offset === offset &&
this.type === type
) {
return;
}
this.key = key;
this.offset = offset;
this.type = type;
if (__DEV__) {
const node = $getNodeByKey(key);
invariant(
type === 'text' ? $isTextNode(node) : $isElementNode(node),
'PointType.set: node with key %s is %s and can not be used for a %s point',
key,
node ? node.__type : '[not found]',
type,
);
}
if (!isCurrentlyReadOnlyMode()) {
if ($getCompositionKey() === oldKey) {
$setCompositionKey(key);
}
if (selection !== null) {
selection.setCachedNodes(null);
if ($isRangeSelection(selection)) {
selection._cachedIsBackward = null;
}
selection.dirty = true;
}
}
}
}
export function $createPoint(
key: NodeKey,
offset: number,
type: 'text' | 'element',
): PointType {
// @ts-expect-error: intentionally cast as we use a class for perf reasons
return new Point(key, offset, type);
}
function selectPointOnNode(point: PointType, node: LexicalNode): void {
let key = node.__key;
let offset = point.offset;
let type: 'element' | 'text' = 'element';
if ($isTextNode(node)) {
type = 'text';
const textContentLength = node.getTextContentSize();
if (offset > textContentLength) {
offset = textContentLength;
}
} else if (!$isElementNode(node)) {
const nextSibling = node.getNextSibling();
if ($isTextNode(nextSibling)) {
key = nextSibling.__key;
offset = 0;
type = 'text';
} else {
const parentNode = node.getParent();
if (parentNode) {
key = parentNode.__key;
offset = node.getIndexWithinParent() + 1;
}
}
}
point.set(key, offset, type);
}
export function $moveSelectionPointToEnd(
point: PointType,
node: LexicalNode,
): void {
if ($isElementNode(node)) {
const lastNode = node.getLastDescendant();
if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
selectPointOnNode(point, lastNode);
} else {
selectPointOnNode(point, node);
}
} else {
selectPointOnNode(point, node);
}
}
function $transferStartingElementPointToTextPoint(
start: ElementPointType,
end: PointType,
format: number,
style: string,
): void {
const element = start.getNode();
const placementNode = element.getChildAtIndex(start.offset);
const textNode = $createTextNode();
textNode.setFormat(format);
textNode.setStyle(style);
if ($isParagraphNode(placementNode)) {
placementNode.splice(0, 0, [textNode]);
} else {
const target = $isRootNode(element)
? $createParagraphNode().append(textNode)
: textNode;
if (placementNode === null) {
element.append(target);
} else {
placementNode.insertBefore(target);
}
}
// Transfer the element point to a text point.
if (start.is(end)) {
end.set(textNode.__key, 0, 'text');
}
start.set(textNode.__key, 0, 'text');
}
export interface BaseSelection {
_cachedNodes: Array<LexicalNode> | null;
dirty: boolean;
clone(): BaseSelection;
extract(): Array<LexicalNode>;
getNodes(): Array<LexicalNode>;
getTextContent(): string;
insertText(text: string): void;
insertRawText(text: string): void;
is(selection: null | BaseSelection): boolean;
insertNodes(nodes: Array<LexicalNode>): void;
getStartEndPoints(): null | [PointType, PointType];
isCollapsed(): boolean;
isBackward(): boolean;
getCachedNodes(): LexicalNode[] | null;
setCachedNodes(nodes: LexicalNode[] | null): void;
}
export class NodeSelection implements BaseSelection {
_nodes: Set<NodeKey>;
_cachedNodes: Array<LexicalNode> | null;
dirty: boolean;
constructor(objects: Set<NodeKey>) {
this._cachedNodes = null;
this._nodes = objects;
this.dirty = false;
}
getCachedNodes(): LexicalNode[] | null {
return this._cachedNodes;
}
setCachedNodes(nodes: LexicalNode[] | null): void {
this._cachedNodes = nodes;
}
is(selection: null | BaseSelection): boolean {
if (!$isNodeSelection(selection)) {
return false;
}
const a: Set<NodeKey> = this._nodes;
const b: Set<NodeKey> = selection._nodes;
return a.size === b.size && Array.from(a).every(key => b.has(key));
}
isCollapsed(): boolean {
return false;
}
isBackward(): boolean {
return false;
}
getStartEndPoints(): null {
return null;
}
add(key: NodeKey): void {
this.dirty = true;
this._nodes.add(key);
this._cachedNodes = null;
}
delete(key: NodeKey): void {
this.dirty = true;
this._nodes.delete(key);
this._cachedNodes = null;
}
clear(): void {
this.dirty = true;
this._nodes.clear();
this._cachedNodes = null;
}
has(key: NodeKey): boolean {
return this._nodes.has(key);
}
clone(): NodeSelection {
return new NodeSelection(new Set(this._nodes));
}
extract(): Array<LexicalNode> {
return this.getNodes();
}
insertRawText(text: string): void {
// Do nothing?
}
insertText(): void {
// Do nothing?
}
insertNodes(nodes: Array<LexicalNode>) {
const selectedNodes = this.getNodes();
const selectedNodesLength = selectedNodes.length;
const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
let selectionAtEnd: RangeSelection;
// Insert nodes
if ($isTextNode(lastSelectedNode)) {
selectionAtEnd = lastSelectedNode.select();
} else {
const index = lastSelectedNode.getIndexWithinParent() + 1;
selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
}
selectionAtEnd.insertNodes(nodes);
// Remove selected nodes
for (let i = 0; i < selectedNodesLength; i++) {
selectedNodes[i].remove();
}
}
getNodes(): Array<LexicalNode> {
const cachedNodes = this._cachedNodes;
if (cachedNodes !== null) {
return cachedNodes;
}
const objects = this._nodes;
const nodes = [];
for (const object of objects) {
const node = $getNodeByKey(object);
if (node !== null) {
nodes.push(node);
}
}
if (!isCurrentlyReadOnlyMode()) {
this._cachedNodes = nodes;
}
return nodes;
}
getTextContent(): string {
const nodes = this.getNodes();
let textContent = '';
for (let i = 0; i < nodes.length; i++) {
textContent += nodes[i].getTextContent();
}
return textContent;
}
/**
* Remove all nodes in the NodeSelection. If there were any nodes,
* replace the selection with a new RangeSelection at the previous
* location of the first node.
*/
deleteNodes(): void {
const nodes = this.getNodes();
if (($getSelection() || $getPreviousSelection()) === this && nodes[0]) {
const firstCaret = $getSiblingCaret(nodes[0], 'next');
$setSelectionFromCaretRange($getCaretRange(firstCaret, firstCaret));
}
for (const node of nodes) {
node.remove();
}
}
}
export function $isRangeSelection(x: unknown): x is RangeSelection {
return x instanceof RangeSelection;
}
export class RangeSelection implements BaseSelection {
format: number;
style: string;
anchor: PointType;
focus: PointType;
_cachedNodes: Array<LexicalNode> | null;
/** @internal */
_cachedIsBackward: boolean | null;
dirty: boolean;
constructor(
anchor: PointType,
focus: PointType,
format: number,
style: string,
) {
this.anchor = anchor;
this.focus = focus;
anchor._selection = this;
focus._selection = this;
this._cachedNodes = null;
this._cachedIsBackward = null;
this.format = format;
this.style = style;
this.dirty = false;
}
getCachedNodes(): LexicalNode[] | null {
return this._cachedNodes;
}
setCachedNodes(nodes: LexicalNode[] | null): void {
this._cachedNodes = nodes;
}
/**
* Used to check if the provided selections is equal to this one by value,
* including anchor, focus, format, and style properties.
* @param selection - the Selection to compare this one to.
* @returns true if the Selections are equal, false otherwise.
*/
is(selection: null | BaseSelection): boolean {
if (!$isRangeSelection(selection)) {
return false;
}
return (
this.anchor.is(selection.anchor) &&
this.focus.is(selection.focus) &&
this.format === selection.format &&
this.style === selection.style
);
}
/**
* Returns whether the Selection is "collapsed", meaning the anchor and focus are
* the same node and have the same offset.
*
* @returns true if the Selection is collapsed, false otherwise.
*/
isCollapsed(): boolean {
return this.anchor.is(this.focus);
}
/**
* Gets all the nodes in the Selection. Uses caching to make it generally suitable
* for use in hot paths.
*
* See also the {@link CaretRange} APIs (starting with
* {@link $caretRangeFromSelection}), which are likely to provide a better
* foundation for any operation where partial selection is relevant
* (e.g. the anchor or focus are inside an ElementNode and TextNode)
*
* @returns an Array containing all the nodes in the Selection
*/
getNodes(): Array<LexicalNode> {
const cachedNodes = this._cachedNodes;
if (cachedNodes !== null) {
return cachedNodes;
}
const range = $getCaretRangeInDirection(
$caretRangeFromSelection(this),
'next',
);
const nodes = $getNodesFromCaretRangeCompat(range);
if (__DEV__) {
if (this.isCollapsed() && nodes.length > 1) {
invariant(
false,
'RangeSelection.getNodes() returned %s > 1 nodes in a collapsed selection',
String(nodes.length),
);
}
}
if (!isCurrentlyReadOnlyMode()) {
this._cachedNodes = nodes;
}
return nodes;
}
/**
* Sets this Selection to be of type "text" at the provided anchor and focus values.
*
* @param anchorNode - the anchor node to set on the Selection
* @param anchorOffset - the offset to set on the Selection
* @param focusNode - the focus node to set on the Selection
* @param focusOffset - the focus offset to set on the Selection
*/
setTextNodeRange(
anchorNode: TextNode,
anchorOffset: number,
focusNode: TextNode,
focusOffset: number,
): this {
this.anchor.set(anchorNode.__key, anchorOffset, 'text');
this.focus.set(focusNode.__key, focusOffset, 'text');
return this;
}
/**
* Gets the (plain) text content of all the nodes in the selection.
*
* @returns a string representing the text content of all the nodes in the Selection
*/
getTextContent(): string {
const nodes = this.getNodes();
if (nodes.length === 0) {
return '';
}
const firstNode = nodes[0];
const lastNode = nodes[nodes.length - 1];
const anchor = this.anchor;
const focus = this.focus;
const isBefore = anchor.isBefore(focus);
const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
let textContent = '';
let prevWasElement = true;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isElementNode(node) && !node.isInline()) {
if (!prevWasElement) {
textContent += '\n';
}
if (node.isEmpty()) {
prevWasElement = false;
} else {
prevWasElement = true;
}
} else {
prevWasElement = false;
if ($isTextNode(node)) {
let text = node.getTextContent();
if (node === firstNode) {
if (node === lastNode) {
if (
anchor.type !== 'element' ||
focus.type !== 'element' ||
focus.offset === anchor.offset
) {
text =
anchorOffset < focusOffset
? text.slice(anchorOffset, focusOffset)
: text.slice(focusOffset, anchorOffset);
}
} else {
text = isBefore
? text.slice(anchorOffset)
: text.slice(focusOffset);
}
} else if (node === lastNode) {
text = isBefore
? text.slice(0, focusOffset)
: text.slice(0, anchorOffset);
}
textContent += text;
} else if (
($isDecoratorNode(node) || $isLineBreakNode(node)) &&
(node !== lastNode || !this.isCollapsed())
) {
textContent += node.getTextContent();
}
}
}
return textContent;
}
/**
* Attempts to map a DOM selection range onto this Lexical Selection,
* setting the anchor, focus, and type accordingly
*
* @param range a DOM Selection range conforming to the StaticRange interface.
*/
applyDOMRange(range: StaticRange): void {
const editor = getActiveEditor();
const currentEditorState = editor.getEditorState();
const lastSelection = currentEditorState._selection;
const resolvedSelectionPoints = $internalResolveSelectionPoints(
range.startContainer,
range.startOffset,
range.endContainer,
range.endOffset,
editor,
lastSelection,
);
if (resolvedSelectionPoints === null) {
return;
}
const [anchorPoint, focusPoint, dirty] = resolvedSelectionPoints;
this.anchor.set(
anchorPoint.key,
anchorPoint.offset,
anchorPoint.type,
true,
);
this.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type, true);
if (dirty) {
this.dirty = true;
}
// Firefox will use an element point rather than a text point in some cases,
// so we normalize for that
$normalizeSelection(this);
}
/**
* Creates a new RangeSelection, copying over all the property values from this one.
*
* @returns a new RangeSelection with the same property values as this one.
*/
clone(): RangeSelection {
const anchor = this.anchor;
const focus = this.focus;
const selection = new RangeSelection(
$createPoint(anchor.key, anchor.offset, anchor.type),
$createPoint(focus.key, focus.offset, focus.type),
this.format,
this.style,
);
return selection;
}
/**
* Toggles the provided format on all the TextNodes in the Selection.
*
* @param format a string TextFormatType to toggle on the TextNodes in the selection
*/
toggleFormat(format: TextFormatType): void {
this.format = toggleTextFormatType(this.format, format, null);
this.dirty = true;
}
/**
* Sets the value of the format property on the Selection
*
* @param format - the format to set at the value of the format property.
*/
setFormat(format: number): void {
this.format = format;
this.dirty = true;
}
/**
* Sets the value of the style property on the Selection
*
* @param style - the style to set at the value of the style property.
*/
setStyle(style: string): void {
this.style = style;
this.dirty = true;
}
/**
* Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
* has the specified format.
*
* @param type the TextFormatType to check for.
* @returns true if the provided format is currently toggled on on the Selection, false otherwise.
*/
hasFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.format & formatFlag) !== 0;
}
/**
* Attempts to insert the provided text into the EditorState at the current Selection.
* converts tabs, newlines, and carriage returns into LexicalNodes.
*
* @param text the text to insert into the Selection
*/
insertRawText(text: string): void {
this.insertNodes($generateNodesFromRawText(text));
}
/**
* Insert the provided text into the EditorState at the current Selection.
*
* @param text the text to insert into the Selection
*/
insertText(text: string): void {
// Now that "removeText" has been improved and does not depend on
// insertText, insertText can be greatly simplified. The next
// commented version is a WIP (about 5 tests fail).
//
// this.removeText();
// if (text === '') {
// return;
// }
// const anchorNode = this.anchor.getNode();
// const textNode = $createTextNode(text);
// textNode.setFormat(this.format);
// textNode.setStyle(this.style);
// if ($isTextNode(anchorNode)) {
// const parent = anchorNode.getParentOrThrow();
// if (this.anchor.offset === 0) {
// if (parent.isInline() && !anchorNode.__prev) {
// parent.insertBefore(textNode);
// } else {
// anchorNode.insertBefore(textNode);
// }
// } else if (this.anchor.offset === anchorNode.getTextContentSize()) {
// if (parent.isInline() && !anchorNode.__next) {
// parent.insertAfter(textNode);
// } else {
// anchorNode.insertAfter(textNode);
// }
// } else {
// const [before] = anchorNode.splitText(this.anchor.offset);
// before.insertAfter(textNode);
// }
// } else {
// anchorNode.splice(this.anchor.offset, 0, [textNode]);
// }
// const nodeToSelect = textNode.isAttached() ? textNode : anchorNode;
// nodeToSelect.selectEnd();
// // When composing, we need to adjust the anchor offset so that
// // we correctly replace that right range.
// if (
// textNode.isComposing() &&
// this.anchor.type === 'text' &&
// anchorNode.getTextContent() !== ''
// ) {
// this.anchor.offset -= text.length;
// }
const anchor = this.anchor;
const focus = this.focus;
const format = this.format;
const style = this.style;
let firstPoint = anchor;
let endPoint = focus;
if (!this.isCollapsed() && focus.isBefore(anchor)) {
firstPoint = focus;
endPoint = anchor;
}
if (firstPoint.type === 'element') {
$transferStartingElementPointToTextPoint(
firstPoint,
endPoint,
format,
style,
);
}
if (endPoint.type === 'element') {
$setPointFromCaret(
endPoint,
$normalizeCaret($caretFromPoint(endPoint, 'next')),
);
}
const startOffset = firstPoint.offset;
let endOffset = endPoint.offset;
const selectedNodes = this.getNodes();
const selectedNodesLength = selectedNodes.length;
let firstNode: TextNode = selectedNodes[0] as TextNode;
if (!$isTextNode(firstNode)) {
invariant(false, 'insertText: first node is not a text node');
}
const firstNodeText = firstNode.getTextContent();
const firstNodeTextLength = firstNodeText.length;
const firstNodeParent = firstNode.getParentOrThrow();
const lastIndex = selectedNodesLength - 1;
let lastNode = selectedNodes[lastIndex];
if (selectedNodesLength === 1 && endPoint.type === 'element') {
endOffset = firstNodeTextLength;
endPoint.set(firstPoint.key, endOffset, 'text');
}
if (
this.isCollapsed() &&
startOffset === firstNodeTextLength &&
($isTokenOrSegmented(firstNode) ||
!firstNode.canInsertTextAfter() ||
(!firstNodeParent.canInsertTextAfter() &&
firstNode.getNextSibling() === null))
) {
let nextSibling = firstNode.getNextSibling<TextNode>();
if (
!$isTextNode(nextSibling) ||
!nextSibling.canInsertTextBefore() ||
$isTokenOrSegmented(nextSibling)
) {
nextSibling = $createTextNode();
nextSibling.setFormat(format);
nextSibling.setStyle(style);
if (!firstNodeParent.canInsertTextAfter()) {
firstNodeParent.insertAfter(nextSibling);
} else {
firstNode.insertAfter(nextSibling);
}
}
nextSibling.select(0, 0);
firstNode = nextSibling;
if (text !== '') {
this.insertText(text);
return;
}
} else if (
this.isCollapsed() &&
startOffset === 0 &&
($isTokenOrSegmented(firstNode) ||
!firstNode.canInsertTextBefore() ||
(!firstNodeParent.canInsertTextBefore() &&
firstNode.getPreviousSibling() === null))
) {
let prevSibling = firstNode.getPreviousSibling<TextNode>();
if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
prevSibling = $createTextNode();
prevSibling.setFormat(format);
if (!firstNodeParent.canInsertTextBefore()) {
firstNodeParent.insertBefore(prevSibling);
} else {
firstNode.insertBefore(prevSibling);
}
}
prevSibling.select();
firstNode = prevSibling;
if (text !== '') {
this.insertText(text);
return;
}
} else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
const textNode = $createTextNode(firstNode.getTextContent());
textNode.setFormat(format);
firstNode.replace(textNode);
firstNode = textNode;
} else if (!this.isCollapsed() && text !== '') {
// When the firstNode or lastNode parents are elements that
// do not allow text to be inserted before or after, we first
// clear the content. Then we normalize selection, then insert
// the new content.
const lastNodeParent = lastNode.getParent();
if (
!firstNodeParent.canInsertTextBefore() ||
!firstNodeParent.canInsertTextAfter() ||
($isElementNode(lastNodeParent) &&
(!lastNodeParent.canInsertTextBefore() ||
!lastNodeParent.canInsertTextAfter()))
) {
this.insertText('');
$normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
this.insertText(text);
return;
}
}
if (selectedNodesLength === 1) {
if ($isTokenOrTab(firstNode)) {
const textNode = $createTextNode(text);
textNode.select();
firstNode.replace(textNode);
return;
}
const firstNodeFormat = firstNode.getFormat();
const firstNodeStyle = firstNode.getStyle();
if (
startOffset === endOffset &&
(firstNodeFormat !== format || firstNodeStyle !== style)
) {
if (firstNode.getTextContent() === '') {
firstNode.setFormat(format);
firstNode.setStyle(style);
} else {
const textNode = $createTextNode(text);
textNode.setFormat(format);
textNode.setStyle(style);
textNode.select();
if (startOffset === 0) {
firstNode.insertBefore(textNode, false);
} else {
const [targetNode] = firstNode.splitText(startOffset);
targetNode.insertAfter(textNode, false);
}
// When composing, we need to adjust the anchor offset so that
// we correctly replace that right range.
if (textNode.isComposing() && this.anchor.type === 'text') {
this.anchor.offset -= text.length;
this._cachedNodes = null;
this._cachedIsBackward = null;
}
return;
}
} else if ($isTabNode(firstNode)) {
// We don't need to check for delCount because there is only the entire selected node case
// that can hit here for content size 1 and with canInsertTextBeforeAfter false
const textNode = $createTextNode(text);
textNode.setFormat(format);
textNode.setStyle(style);
textNode.select();
firstNode.replace(textNode);
return;
}
const delCount = endOffset - startOffset;
firstNode = firstNode.spliceText(startOffset, delCount, text, true);
if (firstNode.getTextContent() === '') {
firstNode.remove();
} else if (this.anchor.type === 'text') {
this.format = firstNodeFormat;
this.style = firstNodeStyle;
if (firstNode.isComposing()) {
// When composing, we need to adjust the anchor offset so that
// we correctly replace that right range.
this.anchor.offset -= text.length;
this._cachedNodes = null;
this._cachedIsBackward = null;
}
}
} else {
const markedNodeKeysForKeep = new Set([
...firstNode.getParentKeys(),
...lastNode.getParentKeys(),
]);
// We have to get the parent elements before the next section,
// as in that section we might mutate the lastNode.
const firstElement = $isElementNode(firstNode)
? firstNode
: firstNode.getParentOrThrow();
let lastElement = $isElementNode(lastNode)
? lastNode
: lastNode.getParentOrThrow();
let lastElementChild = lastNode;
// If the last element is inline, we should instead look at getting
// the nodes of its parent, rather than itself. This behavior will
// then better match how text node insertions work. We will need to
// also update the last element's child accordingly as we do this.
if (!firstElement.is(lastElement) && lastElement.isInline()) {
// Keep traversing till we have a non-inline element parent.
do {
lastElementChild = lastElement;
lastElement = lastElement.getParentOrThrow();
} while (lastElement.isInline());
}
// Handle mutations to the last node.
if (
(endPoint.type === 'text' &&
(endOffset !== 0 || lastNode.getTextContent() === '')) ||
(endPoint.type === 'element' &&
lastNode.getIndexWithinParent() < endOffset)
) {
if (
$isTextNode(lastNode) &&
!$isTokenOrTab(lastNode) &&
endOffset !== lastNode.getTextContentSize()
) {
if (lastNode.isSegmented()) {
const textNode = $createTextNode(lastNode.getTextContent());
lastNode.replace(textNode);
lastNode = textNode;
}
// root node selections only select whole nodes, so no text splice is necessary
if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
lastNode = (lastNode as TextNode).spliceText(0, endOffset, '');
}
markedNodeKeysForKeep.add(lastNode.__key);
} else {
const lastNodeParent = lastNode.getParentOrThrow();
if (
!lastNodeParent.canBeEmpty() &&
lastNodeParent.getChildrenSize() === 1
) {
lastNodeParent.remove();
} else {
lastNode.remove();
}
}
} else {
markedNodeKeysForKeep.add(lastNode.__key);
}
// Either move the remaining nodes of the last parent to after
// the first child, or remove them entirely. If the last parent
// is the same as the first parent, this logic also works.
const lastNodeChildren = lastElement.getChildren();
const selectedNodesSet = new Set(selectedNodes);
const firstAndLastElementsAreEqual = firstElement.is(lastElement);
// We choose a target to insert all nodes after. In the case of having
// and inline starting parent element with a starting node that has no
// siblings, we should insert after the starting parent element, otherwise
// we will incorrectly merge into the starting parent element.
// TODO: should we keep on traversing parents if we're inside another
// nested inline element?
const insertionTarget =
firstElement.isInline() && firstNode.getNextSibling() === null
? firstElement
: firstNode;
for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
const lastNodeChild = lastNodeChildren[i];
if (
lastNodeChild.is(firstNode) ||
($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
) {
break;
}
if (lastNodeChild.isAttached()) {
if (
!selectedNodesSet.has(lastNodeChild) ||
lastNodeChild.is(lastElementChild)
) {
if (!firstAndLastElementsAreEqual) {
insertionTarget.insertAfter(lastNodeChild, false);
}
} else {
lastNodeChild.remove();
}
}
}
if (!firstAndLastElementsAreEqual) {
// Check if we have already moved out all the nodes of the
// last parent, and if so, traverse the parent tree and mark
// them all as being able to deleted too.
let parent: ElementNode | null = lastElement;
let lastRemovedParent = null;
while (parent !== null) {
const children = parent.getChildren();
const childrenLength = children.length;
if (
childrenLength === 0 ||
children[childrenLength - 1].is(lastRemovedParent)
) {
markedNodeKeysForKeep.delete(parent.__key);
lastRemovedParent = parent;
}
parent = parent.getParent();
}
}
// Ensure we do splicing after moving of nodes, as splicing
// can have side-effects (in the case of hashtags).
if (!$isTokenOrTab(firstNode)) {
firstNode = firstNode.spliceText(
startOffset,
firstNodeTextLength - startOffset,
text,
true,
);
if (firstNode.getTextContent() === '') {
firstNode.remove();
} else if (this.anchor.type === 'text') {
this.format = firstNode.getFormat();
this.style = firstNode.getStyle();
if (firstNode.isComposing()) {
// When composing, we need to adjust the anchor offset so that
// we correctly replace that right range.
this.anchor.offset -= text.length;
this._cachedNodes = null;
this._cachedIsBackward = null;
}
}
} else if (startOffset === firstNodeTextLength) {
firstNode.select();
} else {
const textNode = $createTextNode(text);
textNode.select();
firstNode.replace(textNode);
}
// Remove all selected nodes that haven't already been removed.
for (let i = 1; i < selectedNodesLength; i++) {
const selectedNode = selectedNodes[i];
const key = selectedNode.__key;
if (!markedNodeKeysForKeep.has(key)) {
selectedNode.remove();
}
}
}
}
/**
* Removes the text in the Selection, adjusting the EditorState accordingly.
*/
removeText(): void {
const isCurrentSelection = $getSelection() === this;
const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this));
$updateRangeSelectionFromCaretRange(this, newRange);
if (isCurrentSelection && $getSelection() !== this) {
$setSelection(this);
}
}
// TO-DO: Migrate this method to the new utility function $forEachSelectedTextNode (share similar logic)
/**
* Applies the provided format to the TextNodes in the Selection, splitting or
* merging nodes as necessary.
*
* @param formatType the format type to apply to the nodes in the Selection.
* @param alignWithFormat a 32-bit integer representing formatting flags to align with.
*/
formatText(
formatType: TextFormatType,
alignWithFormat: number | null = null,
): void {
if (this.isCollapsed()) {
this.toggleFormat(formatType);
// When changing format, we should stop composition
$setCompositionKey(null);
return;
}
const selectedNodes = this.getNodes();
const selectedTextNodes: Array<TextNode> = [];
for (const selectedNode of selectedNodes) {
if ($isTextNode(selectedNode)) {
selectedTextNodes.push(selectedNode);
}
}
const applyFormatToElements = (alignWith: number | null) => {
selectedNodes.forEach(node => {
if ($isElementNode(node)) {
const newFormat = node.getFormatFlags(formatType, alignWith);
node.setTextFormat(newFormat);
}
});
};
const selectedTextNodesLength = selectedTextNodes.length;
if (selectedTextNodesLength === 0) {
this.toggleFormat(formatType);
// When changing format, we should stop composition
$setCompositionKey(null);
applyFormatToElements(alignWithFormat);
return;
}
const anchor = this.anchor;
const focus = this.focus;
const isBackward = this.isBackward();
const startPoint = isBackward ? focus : anchor;
const endPoint = isBackward ? anchor : focus;
let firstIndex = 0;
let firstNode = selectedTextNodes[0];
let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
// In case selection started at the end of text node use next text node
if (
startPoint.type === 'text' &&
startOffset === firstNode.getTextContentSize()
) {
firstIndex = 1;
firstNode = selectedTextNodes[1];
startOffset = 0;
}
if (firstNode == null) {
return;
}
const firstNextFormat = firstNode.getFormatFlags(
formatType,
alignWithFormat,
);
applyFormatToElements(firstNextFormat);
const lastIndex = selectedTextNodesLength - 1;
let lastNode = selectedTextNodes[lastIndex];
const endOffset =
endPoint.type === 'text'
? endPoint.offset
: lastNode.getTextContentSize();
// Single node selected
if (firstNode.is(lastNode)) {
// No actual text is selected, so do nothing.
if (startOffset === endOffset) {
return;
}
// The entire node is selected or it is token, so just format it
if (
$isTokenOrSegmented(firstNode) ||
(startOffset === 0 && endOffset === firstNode.getTextContentSize())
) {
firstNode.setFormat(firstNextFormat);
} else {
// Node is partially selected, so split it into two nodes
// add style the selected one.
const splitNodes = firstNode.splitText(startOffset, endOffset);
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
replacement.setFormat(firstNextFormat);
// Update selection only if starts/ends on text node
if (startPoint.type === 'text') {
startPoint.set(replacement.__key, 0, 'text');
}
if (endPoint.type === 'text') {
endPoint.set(replacement.__key, endOffset - startOffset, 'text');
}
}
this.format = firstNextFormat;
return;
}
// Multiple nodes selected
// The entire first node isn't selected, so split it
if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
[, firstNode] = firstNode.splitText(startOffset);
startOffset = 0;
}
firstNode.setFormat(firstNextFormat);
const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
// If the offset is 0, it means no actual characters are selected,
// so we skip formatting the last node altogether.
if (endOffset > 0) {
if (
endOffset !== lastNode.getTextContentSize() &&
!$isTokenOrSegmented(lastNode)
) {
[lastNode] = lastNode.splitText(endOffset);
}
lastNode.setFormat(lastNextFormat);
}
// Process all text nodes in between
for (let i = firstIndex + 1; i < lastIndex; i++) {
const textNode = selectedTextNodes[i];
const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
textNode.setFormat(nextFormat);
}
// Update selection only if starts/ends on text node
if (startPoint.type === 'text') {
startPoint.set(firstNode.__key, startOffset, 'text');
}
if (endPoint.type === 'text') {
endPoint.set(lastNode.__key, endOffset, 'text');
}
this.format = firstNextFormat | lastNextFormat;
}
/**
* Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
* current Selection according to a set of heuristics that determine how surrounding nodes
* should be changed, replaced, or moved to accommodate the incoming ones.
*
* @param nodes - the nodes to insert
*/
insertNodes(nodes: Array<LexicalNode>): void {
if (nodes.length === 0) {
return;
}
if (!this.isCollapsed()) {
this.removeText();
}
if (this.anchor.key === 'root') {
this.insertParagraph();
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'Expected RangeSelection after insertParagraph',
);
return selection.insertNodes(nodes);
}
const firstPoint = this.isBackward() ? this.focus : this.anchor;
const firstNode = firstPoint.getNode();
const firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock);
const last = nodes[nodes.length - 1]!;
// CASE 1: insert inside a code block
if ($isElementNode(firstBlock) && '__language' in firstBlock) {
if ('__language' in nodes[0]) {
this.insertText(nodes[0].getTextContent());
} else {
const index = $removeTextAndSplitBlock(this);
firstBlock.splice(index, 0, nodes);
last.selectEnd();
}
return;
}
// CASE 2: All elements of the array are inline
const notInline = (node: LexicalNode) =>
($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
if (!nodes.some(notInline)) {
invariant(
$isElementNode(firstBlock),
'Expected node %s of type %s to have a block ElementNode ancestor',
firstNode.constructor.name,
firstNode.getType(),
);
const index = $removeTextAndSplitBlock(this);
firstBlock.splice(index, 0, nodes);
last.selectEnd();
return;
}
// CASE 3: At least 1 element of the array is not inline
const blocksParent = $wrapInlineNodes(nodes);
const nodeToSelect = blocksParent.getLastDescendant()!;
const blocks = blocksParent.getChildren();
const isMergeable = (node: LexicalNode): node is ElementNode =>
$isElementNode(node) &&
INTERNAL_$isBlock(node) &&
!node.isEmpty() &&
$isElementNode(firstBlock) &&
(!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());
const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
const lastToInsert: LexicalNode | undefined = blocks[blocks.length - 1];
let firstToInsert: LexicalNode | undefined = blocks[0];
if (isMergeable(firstToInsert)) {
invariant(
$isElementNode(firstBlock),
'Expected node %s of type %s to have a block ElementNode ancestor',
firstNode.constructor.name,
firstNode.getType(),
);
firstBlock.append(...firstToInsert.getChildren());
firstToInsert = blocks[1];
}
if (firstToInsert) {
invariant(
firstBlock !== null,
'Expected node %s of type %s to have a block ancestor',
firstNode.constructor.name,
firstNode.getType(),
);
insertRangeAfter(firstBlock, firstToInsert);
}
const lastInsertedBlock = $findMatchingParent(
nodeToSelect,
INTERNAL_$isBlock,
);
if (
insertedParagraph &&
$isElementNode(lastInsertedBlock) &&
(insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
) {
lastInsertedBlock.append(...insertedParagraph.getChildren());
insertedParagraph.remove();
}
if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
firstBlock.remove();
}
nodeToSelect.selectEnd();
// To understand this take a look at the test "can wrap post-linebreak nodes into new element"
const lastChild = $isElementNode(firstBlock)
? firstBlock.getLastChild()
: null;
if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
lastChild.remove();
}
}
/**
* Inserts a new ParagraphNode into the EditorState at the current Selection
*
* @returns the newly inserted node.
*/
insertParagraph(): ElementNode | null {
if (this.anchor.key === 'root') {
const paragraph = $createParagraphNode();
$getRoot().splice(this.anchor.offset, 0, [paragraph]);
paragraph.select();
return paragraph;
}
const index = $removeTextAndSplitBlock(this);
const block = $findMatchingParent(this.anchor.getNode(), INTERNAL_$isBlock);
invariant(
$isElementNode(block),
'Expected ancestor to be a block ElementNode',
);
const firstToAppend = block.getChildAtIndex(index);
const nodesToInsert = firstToAppend
? [firstToAppend, ...firstToAppend.getNextSiblings()]
: [];
const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
if (newBlock) {
newBlock.append(...nodesToInsert);
newBlock.selectStart();
return newBlock;
}
// if newBlock is null, it means that block is of type CodeNode.
return null;
}
/**
* Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
* current Selection.
*/
insertLineBreak(selectStart?: boolean): void {
const lineBreak = $createLineBreakNode();
this.insertNodes([lineBreak]);
// this is used in MacOS with the command 'ctrl-O' (openLineBreak)
if (selectStart) {
const parent = lineBreak.getParentOrThrow();
const index = lineBreak.getIndexWithinParent();
parent.select(index, index);
}
}
/**
* Extracts the nodes in the Selection, splitting nodes where necessary
* to get offset-level precision.
*
* @returns The nodes in the Selection
*/
extract(): Array<LexicalNode> {
const selectedNodes = [...this.getNodes()];
const selectedNodesLength = selectedNodes.length;
let firstNode = selectedNodes[0];
let lastNode = selectedNodes[selectedNodesLength - 1];
const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
const isBackward = this.isBackward();
const [startPoint, endPoint] = isBackward
? [this.focus, this.anchor]
: [this.anchor, this.focus];
const [startOffset, endOffset] = isBackward
? [focusOffset, anchorOffset]
: [anchorOffset, focusOffset];
if (selectedNodesLength === 0) {
return [];
} else if (selectedNodesLength === 1) {
if ($isTextNode(firstNode) && !this.isCollapsed()) {
const splitNodes = firstNode.splitText(startOffset, endOffset);
const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
if (node) {
startPoint.set(node.getKey(), 0, 'text');
endPoint.set(node.getKey(), node.getTextContentSize(), 'text');
return [node];
}
return [];
}
return [firstNode];
}
if ($isTextNode(firstNode)) {
if (startOffset === firstNode.getTextContentSize()) {
selectedNodes.shift();
} else if (startOffset !== 0) {
[, firstNode] = firstNode.splitText(startOffset);
selectedNodes[0] = firstNode;
startPoint.set(firstNode.getKey(), 0, 'text');
}
}
if ($isTextNode(lastNode)) {
const lastNodeText = lastNode.getTextContent();
const lastNodeTextLength = lastNodeText.length;
if (endOffset === 0) {
selectedNodes.pop();
} else if (endOffset !== lastNodeTextLength) {
[lastNode] = lastNode.splitText(endOffset);
selectedNodes[selectedNodes.length - 1] = lastNode;
endPoint.set(lastNode.getKey(), lastNode.getTextContentSize(), 'text');
}
}
return selectedNodes;
}
/**
* Modifies the Selection according to the parameters and a set of heuristics that account for
* various node types. Can be used to safely move or extend selection by one logical "unit" without
* dealing explicitly with all the possible node types.
*
* @param alter the type of modification to perform
* @param isBackward whether or not selection is backwards
* @param granularity the granularity at which to apply the modification
*/
modify(
alter: 'move' | 'extend',
isBackward: boolean,
granularity: 'character' | 'word' | 'lineboundary',
): void {
if (
$modifySelectionAroundDecoratorsAndBlocks(
this,
alter,
isBackward,
granularity,
)
) {
return;
}
const collapse = alter === 'move';
const editor = getActiveEditor();
const domSelection = getDOMSelection(getWindow(editor));
if (!domSelection) {
return;
}
const blockCursorElement = editor._blockCursorElement;
const rootElement = editor._rootElement;
const focusNode = this.focus.getNode();
// Remove the block cursor element if it exists. This will ensure selection
// works as intended. If we leave it in the DOM all sorts of strange bugs
// occur. :/
if (
rootElement !== null &&
blockCursorElement !== null &&
$isElementNode(focusNode) &&
!focusNode.isInline() &&
!focusNode.canBeEmpty()
) {
removeDOMBl