resources/js/wysiwyg/lexical/core/LexicalSelection.ts
/**
* 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/shared/invariant';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isRootNode,
$isTextNode,
$setSelection,
SELECTION_CHANGE_COMMAND,
TextNode,
} from '.';
import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
import {
markCollapsedSelectionFormat,
markSelectionChangeFromDOMUpdate,
} from './LexicalEvents';
import {getIsProcessingMutations} from './LexicalMutations';
import {insertRangeAfter, LexicalNode} from './LexicalNode';
import {
getActiveEditor,
getActiveEditorState,
isCurrentlyReadOnlyMode,
} from './LexicalUpdates';
import {
$getAdjacentNode,
$getAncestor,
$getCompositionKey,
$getNearestRootOrShadowRoot,
$getNodeByKey,
$getNodeFromDOM,
$getRoot,
$hasAncestor,
$isTokenOrSegmented,
$setCompositionKey,
doesContainGrapheme,
getDOMSelection,
getDOMTextNode,
getElementByKeyOrThrow,
getTextNodeOffset,
INTERNAL_$isBlock,
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
removeDOMBlockCursorElement,
scrollIntoViewIfNeeded,
toggleTextFormatType,
} from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
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') => 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') => 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') {
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 {
let aNode = this.getNode();
let bNode = b.getNode();
const aOffset = this.offset;
const bOffset = b.offset;
if ($isElementNode(aNode)) {
const aNodeDescendant = aNode.getDescendantByIndex<ElementNode>(aOffset);
aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
}
if ($isElementNode(bNode)) {
const bNodeDescendant = bNode.getDescendantByIndex<ElementNode>(bOffset);
bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
}
if (aNode === bNode) {
return aOffset < bOffset;
}
return aNode.isBefore(bNode);
}
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'): void {
const selection = this._selection;
const oldKey = this.key;
this.key = key;
this.offset = offset;
this.type = type;
if (!isCurrentlyReadOnlyMode()) {
if ($getCompositionKey() === oldKey) {
$setCompositionKey(key);
}
if (selection !== null) {
selection.setCachedNodes(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();
const target = $isRootNode(element)
? $createParagraphNode().append(textNode)
: textNode;
textNode.setFormat(format);
textNode.setStyle(style);
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');
}
function $setPointValues(
point: PointType,
key: NodeKey,
offset: number,
type: 'text' | 'element',
): void {
point.key = key;
point.offset = offset;
point.type = type;
}
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;
}
}
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;
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.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,
* inluding 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.
*
* @returns an Array containing all the nodes in the Selection
*/
getNodes(): Array<LexicalNode> {
const cachedNodes = this._cachedNodes;
if (cachedNodes !== null) {
return cachedNodes;
}
const anchor = this.anchor;
const focus = this.focus;
const isBefore = anchor.isBefore(focus);
const firstPoint = isBefore ? anchor : focus;
const lastPoint = isBefore ? focus : anchor;
let firstNode = firstPoint.getNode();
let lastNode = lastPoint.getNode();
const startOffset = firstPoint.offset;
const endOffset = lastPoint.offset;
if ($isElementNode(firstNode)) {
const firstNodeDescendant =
firstNode.getDescendantByIndex<ElementNode>(startOffset);
firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
}
if ($isElementNode(lastNode)) {
let lastNodeDescendant =
lastNode.getDescendantByIndex<ElementNode>(endOffset);
// We don't want to over-select, as node selection infers the child before
// the last descendant, not including that descendant.
if (
lastNodeDescendant !== null &&
lastNodeDescendant !== firstNode &&
lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
) {
lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
}
lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
}
let nodes: Array<LexicalNode>;
if (firstNode.is(lastNode)) {
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
nodes = [];
} else {
nodes = [firstNode];
}
} else {
nodes = firstNode.getNodesBetween(lastNode);
}
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,
): void {
$setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
$setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
this._cachedNodes = null;
this.dirty = true;
}
/**
* 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] = resolvedSelectionPoints;
$setPointValues(
this.anchor,
anchorPoint.key,
anchorPoint.offset,
anchorPoint.type,
);
$setPointValues(
this.focus,
focusPoint.key,
focusPoint.offset,
focusPoint.type,
);
this._cachedNodes = null;
}
/**
* 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 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 {
const parts = text.split(/(\r?\n|\t)/);
const nodes = [];
const length = parts.length;
for (let i = 0; i < length; i++) {
const part = parts[i];
if (part === '\n' || part === '\r\n') {
nodes.push($createLineBreakNode());
} else if (part === '\t') {
nodes.push($createTabNode());
} else {
nodes.push($createTextNode(part));
}
}
this.insertNodes(nodes);
}
/**
* Attempts to insert the provided text into the EditorState at the current Selection as a new
* Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
*
* @param text the text to insert into the Selection
*/
insertText(text: string): void {
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,
);
}
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 &&
(firstNode.isSegmented() ||
firstNode.isToken() ||
!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 &&
(firstNode.isSegmented() ||
firstNode.isToken() ||
!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 (firstNode.isToken()) {
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;
}
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') {
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;
} else {
this.format = firstNodeFormat;
this.style = firstNodeStyle;
}
}
} 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) &&
!lastNode.isToken() &&
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 (!firstNode.isToken()) {
firstNode = firstNode.spliceText(
startOffset,
firstNodeTextLength - startOffset,
text,
true,
);
if (firstNode.getTextContent() === '') {
firstNode.remove();
} else if (firstNode.isComposing() && this.anchor.type === 'text') {
// When composing, we need to adjust the anchor offset so that
// we correctly replace that right range.
this.anchor.offset -= text.length;
}
} 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 {
this.insertText('');
}
/**
* 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.
*/
formatText(formatType: TextFormatType): 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 selectedTextNodesLength = selectedTextNodes.length;
if (selectedTextNodesLength === 0) {
this.toggleFormat(formatType);
// When changing format, we should stop composition
$setCompositionKey(null);
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, null);
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 as TextNode] = 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 as TextNode] = 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 accomodate the incoming ones.
*
* @param nodes - the nodes to insert
*/
insertNodes(nodes: Array<LexicalNode>): void {
if (nodes.length === 0) {
return;
}
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 firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!;
const last = nodes[nodes.length - 1]!;
// CASE 1: insert inside a code block
if ('__language' in firstBlock && $isElementNode(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 'firstBlock' to be an ElementNode",
);
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 = blocks[blocks.length - 1];
let firstToInsert = blocks[0];
if (isMergeable(firstToInsert)) {
invariant(
$isElementNode(firstBlock),
"Expected 'firstBlock' to be an ElementNode",
);
firstBlock.append(...firstToInsert.getChildren());
firstToInsert = blocks[1];
}
if (firstToInsert) {
insertRangeAfter(firstBlock, firstToInsert);
}
const lastInsertedBlock = $getAncestor(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 = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!;
invariant($isElementNode(block), 'Expected ancestor to be an 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;
const lastIndex = selectedNodesLength - 1;
const anchor = this.anchor;
const focus = this.focus;
let firstNode = selectedNodes[0];
let lastNode = selectedNodes[lastIndex];
const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
if (selectedNodesLength === 0) {
return [];
} else if (selectedNodesLength === 1) {
if ($isTextNode(firstNode) && !this.isCollapsed()) {
const startOffset =
anchorOffset > focusOffset ? focusOffset : anchorOffset;
const endOffset =
anchorOffset > focusOffset ? anchorOffset : focusOffset;
const splitNodes = firstNode.splitText(startOffset, endOffset);
const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
return node != null ? [node] : [];
}
return [firstNode];
}
const isBefore = anchor.isBefore(focus);
if ($isTextNode(firstNode)) {
const startOffset = isBefore ? anchorOffset : focusOffset;
if (startOffset === firstNode.getTextContentSize()) {
selectedNodes.shift();
} else if (startOffset !== 0) {
[, firstNode] = firstNode.splitText(startOffset);
selectedNodes[0] = firstNode;
}
}
if ($isTextNode(lastNode)) {
const lastNodeText = lastNode.getTextContent();
const lastNodeTextLength = lastNodeText.length;
const endOffset = isBefore ? focusOffset : anchorOffset;
if (endOffset === 0) {
selectedNodes.pop();
} else if (endOffset !== lastNodeTextLength) {
[lastNode] = lastNode.splitText(endOffset);
selectedNodes[lastIndex] = lastNode;
}
}
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 {
const focus = this.focus;
const anchor = this.anchor;
const collapse = alter === 'move';
// Handle the selection movement around decorators.
const possibleNode = $getAdjacentNode(focus, isBackward);
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
// Make it possible to move selection from range selection to
// node selection on the node.
if (collapse && possibleNode.isKeyboardSelectable()) {
const nodeSelection = $createNodeSelection();
nodeSelection.add(possibleNode.__key);
$setSelection(nodeSelection);
return;
}
const sibling = isBackward
? possibleNode.getPreviousSibling()
: possibleNode.getNextSibling();
if (!$isTextNode(sibling)) {
const parent = possibleNode.getParentOrThrow();
let offset;
let elementKey;
if ($isElementNode(sibling)) {
elementKey = sibling.__key;
offset = isBackward ? sibling.getChildrenSize() : 0;
} else {
offset = possibleNode.getIndexWithinParent();
elementKey = parent.__key;
if (!isBackward) {
offset++;
}
}
focus.set(elementKey, offset, 'element');
if (collapse) {
anchor.set(elementKey, offset, 'element');
}
return;
} else {
const siblingKey = sibling.__key;
const offset = isBackward ? sibling.getTextContent().length : 0;
focus.set(siblingKey, offset, 'text');
if (collapse) {
anchor.set(siblingKey, offset, 'text');
}
return;
}
}
const editor = getActiveEditor();
const domSelection = getDOMSelection(editor._window);
if (!domSelection) {
return;
}
const blockCursorElement = editor._blockCursorElement;
const rootElement = editor._rootElement;
// 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(possibleNode) &&
!possibleNode.isInline() &&
!possibleNode.canBeEmpty()
) {
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
}
// 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) {
const range = 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) {
// Validate selection; make sure that the new extended selection respects shadow roots
const nodes = this.getNodes();
const validNodes = [];
let shrinkSelection = false;
for (let i = 0; i < nodes.length; i++) {
const nextNode = nodes[i];
if ($hasAncestor(nextNode, root)) {
validNodes.push(nextNode);
} else {
shrinkSelection = true;
}
}
if (shrinkSelection && validNodes.length > 0) {
// validNodes length check is a safeguard against an invalid selection; as getNodes()
// will return an empty array in this case
if (isBackward) {
const firstValidNode = validNodes[0];
if ($isElementNode(firstValidNode)) {
firstValidNode.selectStart();
} else {
firstValidNode.getParentOrThrow().selectStart();
}
} else {
const lastValidNode = validNodes[validNodes.length - 1];
if ($isElementNode(lastValidNode)) {
lastValidNode.selectEnd();
} else {
lastValidNode.getParentOrThrow().selectEnd();
}
}
}
// 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.
if (
domSelection.anchorNode !== range.startContainer ||
domSelection.anchorOffset !== range.startOffset
) {
$swapPoints(this);
}
}
}
}
/**
* 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;
}
// Handle the deletion around decorators.
const focus = this.focus;
const possibleNode = $getAdjacentNode(focus, isBackward);
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
// Make it possible to move selection from range selection to
// node selection on the node.
if (
possibleNode.isKeyboardSelectable() &&
$isElementNode(anchorNode) &&
anchorNode.getChildrenSize() === 0
) {
anchorNode.remove();
const nodeSelection = $createNodeSelection();
nodeSelection.add(possibleNode.__key);
$setSelection(nodeSelection);
} else {
possibleNode.remove();
const editor = getActiveEditor();
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}
return;
} else if (
!isBackward &&
$isElementNode(possibleNode) &&
$isElementNode(anchorNode) &&
anchorNode.isEmpty()
) {
anchorNode.remove();
possibleNode.selectStart();
return;
}
this.modify('extend', 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
const element =
anchor.type === 'element'
? anchor.getNode()
: anchor.getNode().getParentOrThrow();
if (element.collapseAtStart(this)) {
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.getIndexWithinParent() === 0
) {
anchorNode.collapseAtStart(this);
}
}
}
/**
* 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 {
if (this.isCollapsed()) {
// Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections
// but doesn't properly handle selections which end on elements, a space character is added
// for such selections transforming their anchor's type to 'text'
const anchorIsElement = this.anchor.type === 'element';
if (anchorIsElement) {
this.insertText(' ');
}
this.modify('extend', isBackward, 'lineboundary');
// If selection is extended to cover text edge then extend it one character more
// to delete its parent element. Otherwise text content will be deleted but empty
// parent node will remain
const endPoint = isBackward ? this.focus : this.anchor;
if (endPoint.offset === 0) {
this.modify('extend', isBackward, 'character');
}
// Adjusts selection to include an extra character added for element anchors to remove it
if (anchorIsElement) {
const startPoint = isBackward ? this.anchor : this.focus;
startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
}
}
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;
}
this.modify('extend', isBackward, 'word');
}
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 {
return this.focus.isBefore(this.anchor);
}
getStartEndPoints(): null | [PointType, PointType] {
return [this.anchor, this.focus];
}
}
export function $isNodeSelection(x: unknown): x is NodeSelection {
return x instanceof NodeSelection;
}
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 $swapPoints(selection: RangeSelection): void {
const focus = selection.focus;
const anchor = selection.anchor;
const anchorKey = anchor.key;
const anchorOffset = anchor.offset;
const anchorType = anchor.type;
$setPointValues(anchor, focus.key, focus.offset, focus.type);
$setPointValues(focus, anchorKey, anchorOffset, anchorType);
selection._cachedNodes = null;
}
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);
}
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 (!doesContainGrapheme(text)) {
if (isBackward) {
focus.offset = characterOffset;
} else {
anchor.offset = characterOffset;
}
}
}
} else {
// TODO Handling of multibyte characters
}
}
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 | PointType {
let resolvedOffset = offset;
let resolvedNode: TextNode | LexicalNode | null;
// If we have selection on an element, we will
// need to figure out (using the offset) what text
// node should be selected.
if (dom.nodeType === DOM_ELEMENT_TYPE) {
// 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) {
moveSelectionToEnd = true;
resolvedOffset = childNodesLength - 1;
}
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);
} else {
let resolvedElement = $getNodeFromDOM(dom);
// Ensure resolvedElement is actually a element.
if (resolvedElement === null) {
return null;
}
if ($isElementNode(resolvedElement)) {
resolvedOffset = Math.min(
resolvedElement.getChildrenSize(),
resolvedOffset,
);
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);
} else if (
child !== resolvedElement &&
moveSelectionToEnd &&
!hasBlockCursor
) {
resolvedOffset++;
}
} else {
const index = resolvedElement.getIndexWithinParent();
// When selecting decorators, there can be some selection issues when using resolvedOffset,
// and instead we should be checking if we're using the offset
if (
offset === 0 &&
$isDecoratorNode(resolvedElement) &&
$getNodeFromDOM(dom) === resolvedElement
) {
resolvedOffset = index;
} else {
resolvedOffset = index + 1;
}
resolvedElement = resolvedElement.getParentOrThrow();
}
if ($isElementNode(resolvedElement)) {
return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
}
}
} else {
// TextNode or null
resolvedNode = $getNodeFromDOM(dom);
}
if (!$isTextNode(resolvedNode)) {
return null;
}
return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
}
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.key = prevSibling.__key;
point.offset = prevSibling.getChildrenSize();
// @ts-expect-error: intentional
point.type = 'element';
} else if ($isTextNode(prevSibling)) {
point.key = prevSibling.__key;
point.offset = prevSibling.getTextContent().length;
}
} else if (
(isCollapsed || !isBackward) &&
prevSibling === null &&
$isElementNode(parent) &&
parent.isInline()
) {
const parentSibling = parent.getPreviousSibling();
if ($isTextNode(parentSibling)) {
point.key = parentSibling.__key;
point.offset = parentSibling.getTextContent().length;
}
}
} else if (offset === node.getTextContent().length) {
const nextSibling = node.getNextSibling();
const parent = node.getParent();
if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
point.key = nextSibling.__key;
point.offset = 0;
// @ts-expect-error: intentional
point.type = 'element';
} else if (
(isCollapsed || isBackward) &&
nextSibling === null &&
$isElementNode(parent) &&
parent.isInline() &&
!parent.canInsertTextAfter()
) {
const parentSibling = parent.getNextSibling();
if ($isTextNode(parentSibling)) {
point.key = parentSibling.__key;
point.offset = 0;
}
}
}
}
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.key = anchor.key;
focus.offset = anchor.offset;
focus.type = anchor.type;
}
const editor = getActiveEditor();
if (
editor.isComposing() &&
editor._compositionKey !== anchor.key &&
$isRangeSelection(lastSelection)
) {
const lastAnchor = lastSelection.anchor;
const lastFocus = lastSelection.focus;
$setPointValues(
anchor,
lastAnchor.key,
lastAnchor.offset,
lastAnchor.type,
);
$setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
}
}
}
function $internalResolveSelectionPoints(
anchorDOM: null | Node,
anchorOffset: number,
focusDOM: null | Node,
focusOffset: number,
editor: LexicalEditor,
lastSelection: null | BaseSelection,
): null | [PointType, PointType] {
if (
anchorDOM === null ||
focusDOM === null ||
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
) {
return null;
}
const resolvedAnchorPoint = $internalResolveSelectionPoint(
anchorDOM,
anchorOffset,
$isRangeSelection(lastSelection) ? lastSelection.anchor : null,
editor,
);
if (resolvedAnchorPoint === null) {
return null;
}
const resolvedFocusPoint = $internalResolveSelectionPoint(
focusDOM,
focusOffset,
$isRangeSelection(lastSelection) ? lastSelection.focus : null,
editor,
);
if (resolvedFocusPoint === null) {
return null;
}
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;
}
}
// Handle normalization of selection when it is at the boundaries.
$normalizeSelectionPointsForBoundaries(
resolvedAnchorPoint,
resolvedFocusPoint,
lastSelection,
);
return [resolvedAnchorPoint, resolvedFocusPoint];
}
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,
): null | BaseSelection {
const currentEditorState = editor.getEditorState();
const lastSelection = currentEditorState._selection;
const domSelection = getDOMSelection(editor._window);
if ($isRangeSelection(lastSelection) || lastSelection == null) {
return $internalCreateRangeSelection(
lastSelection,
domSelection,
editor,
null,
);
}
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;
}
anchorDOM = domSelection.anchorNode;
focusDOM = domSelection.focusNode;
anchorOffset = domSelection.anchorOffset;
focusOffset = domSelection.focusOffset;
if (
isSelectionChange &&
$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] = resolvedSelectionPoints;
return new RangeSelection(
resolvedAnchorPoint,
resolvedFocusPoint,
!$isRangeSelection(lastSelection) ? 0 : lastSelection.format,
!$isRangeSelection(lastSelection) ? '' : lastSelection.style,
);
}
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.key = key;
if (!isBefore) {
point.offset += textLength;
}
} else if (point.offset > target.getIndexWithinParent()) {
point.offset -= 1;
}
}
export function updateDOMSelection(
prevSelection: BaseSelection | null,
nextSelection: BaseSelection | null,
editor: LexicalEditor,
domSelection: Selection,
tags: Set<string>,
rootElement: HTMLElement,
nodeCount: number,
): void {
const anchorDOMNode = domSelection.anchorNode;
const focusDOMNode = domSelection.focusNode;
const anchorOffset = domSelection.anchorOffset;
const focusOffset = domSelection.focusOffset;
const activeElement = document.activeElement;
// TODO: make this not hard-coded, and add another config option
// that makes this configurable.
if (
(tags.has('collaboration') && activeElement !== rootElement) ||
(activeElement !== null &&
isSelectionCapturedInDecoratorInput(activeElement))
) {
return;
}
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, anchorDOMNode, focusDOMNode)
) {
domSelection.removeAllRanges();
}
return;
}
const anchor = nextSelection.anchor;
const focus = nextSelection.focus;
const anchorKey = anchor.key;
const focusKey = focus.key;
const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
const focusDOM = getElementByKeyOrThrow(editor, focusKey);
const nextAnchorOffset = anchor.offset;
const nextFocusOffset = 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 = getDOMTextNode(anchorDOM);
const anchorNode = anchor.getNode();
anchorFormatOrStyleChanged =
anchorNode.getFormat() !== nextFormat ||
anchorNode.getStyle() !== nextStyle;
} else if (
$isRangeSelection(prevSelection) &&
prevSelection.anchor.type === 'text'
) {
anchorFormatOrStyleChanged = true;
}
if (focus.type === 'text') {
nextFocusNode = getDOMTextNode(focusDOM);
}
// 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(
nextFormat,
nextStyle,
nextAnchorOffset,
anchorKey,
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 (
anchorOffset === nextAnchorOffset &&
focusOffset === nextFocusOffset &&
anchorDOMNode === nextAnchorNode &&
focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482
!(domSelection.type === 'Range' && isCollapsed)
) {
// If the root element does not have focus, ensure it has focus
if (activeElement === null || !rootElement.contains(activeElement)) {
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.
try {
domSelection.setBaseAndExtent(
nextAnchorNode,
nextAnchorOffset,
nextFocusNode,
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);
}
}
if (
!tags.has('skip-scroll-into-view') &&
nextSelection.isCollapsed() &&
rootElement !== null &&
rootElement === document.activeElement
) {
const selectionTarget: null | Range | HTMLElement | Text =
nextSelection instanceof RangeSelection &&
nextSelection.anchor.type === 'element'
? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
null
: domSelection.rangeCount > 0
? domSelection.getRangeAt(0)
: null;
if (selectionTarget !== null) {
let selectionRect: DOMRect;
if (selectionTarget instanceof Text) {
const range = document.createRange();
range.selectNode(selectionTarget);
selectionRect = range.getBoundingClientRect();
} else {
selectionRect = selectionTarget.getBoundingClientRect();
}
scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
}
}
markSelectionChangeFromDOMUpdate();
}
export function $insertNodes(nodes: Array<LexicalNode>) {
let selection = $getSelection() || $getPreviousSelection();
if (selection === null) {
selection = $getRoot().selectEnd();
}
selection.insertNodes(nodes);
}
export function $getTextContent(): string {
const selection = $getSelection();
if (selection === null) {
return '';
}
return selection.getTextContent();
}
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;
while (!INTERNAL_$isBlock(node)) {
[node, offset] = $splitNodeAtPoint(node, offset);
}
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 $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 = null;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const isLineBreakNode = $isLineBreakNode(node);
if (
isLineBreakNode ||
($isDecoratorNode(node) && node.isInline()) ||
($isElementNode(node) && node.isInline()) ||
$isTextNode(node) ||
node.isParentRequired()
) {
if (currentBlock === null) {
currentBlock = node.createParentElementNode();
virtualRoot.append(currentBlock);
// In the case of LineBreakNode, we just need to
// add an empty ParagraphNode to the topLevelBlocks.
if (isLineBreakNode) {
continue;
}
}
if (currentBlock !== null) {
currentBlock.append(node);
}
} else {
virtualRoot.append(node);
currentBlock = null;
}
}
return virtualRoot;
}