2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {LexicalEditor} from './LexicalEditor';
10 import type {EditorState} from './LexicalEditorState';
11 import type {NodeKey} from './LexicalNode';
12 import type {ElementNode} from './nodes/LexicalElementNode';
13 import type {TextFormatType} from './nodes/LexicalTextNode';
15 import invariant from 'lexical/shared/invariant';
20 $createTextNode, $getNearestNodeFromDOMNode,
27 SELECTION_CHANGE_COMMAND,
30 import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
32 markCollapsedSelectionFormat,
33 markSelectionChangeFromDOMUpdate,
34 } from './LexicalEvents';
35 import {getIsProcessingMutations} from './LexicalMutations';
36 import {insertRangeAfter, LexicalNode} from './LexicalNode';
40 isCurrentlyReadOnlyMode,
41 } from './LexicalUpdates';
46 $getNearestRootOrShadowRoot,
56 getElementByKeyOrThrow,
59 isSelectionCapturedInDecoratorInput,
60 isSelectionWithinEditor,
61 removeDOMBlockCursorElement,
62 scrollIntoViewIfNeeded,
64 } from './LexicalUtils';
65 import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
66 import {$selectSingleNode} from "../../utils/selection";
68 export type TextPointType = {
69 _selection: BaseSelection;
70 getNode: () => TextNode;
71 is: (point: PointType) => boolean;
72 isBefore: (point: PointType) => boolean;
75 set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
79 export type ElementPointType = {
80 _selection: BaseSelection;
81 getNode: () => ElementNode;
82 is: (point: PointType) => boolean;
83 isBefore: (point: PointType) => boolean;
86 set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
90 export type PointType = TextPointType | ElementPointType;
95 type: 'text' | 'element';
96 _selection: BaseSelection | null;
98 constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
99 this._selection = null;
101 this.offset = offset;
105 is(point: PointType): boolean {
107 this.key === point.key &&
108 this.offset === point.offset &&
109 this.type === point.type
113 isBefore(b: PointType): boolean {
114 let aNode = this.getNode();
115 let bNode = b.getNode();
116 const aOffset = this.offset;
117 const bOffset = b.offset;
119 if ($isElementNode(aNode)) {
120 const aNodeDescendant = aNode.getDescendantByIndex<ElementNode>(aOffset);
121 aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
123 if ($isElementNode(bNode)) {
124 const bNodeDescendant = bNode.getDescendantByIndex<ElementNode>(bOffset);
125 bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
127 if (aNode === bNode) {
128 return aOffset < bOffset;
130 return aNode.isBefore(bNode);
133 getNode(): LexicalNode {
134 const key = this.key;
135 const node = $getNodeByKey(key);
137 invariant(false, 'Point.getNode: node not found');
142 set(key: NodeKey, offset: number, type: 'text' | 'element'): void {
143 const selection = this._selection;
144 const oldKey = this.key;
146 this.offset = offset;
148 if (!isCurrentlyReadOnlyMode()) {
149 if ($getCompositionKey() === oldKey) {
150 $setCompositionKey(key);
152 if (selection !== null) {
153 selection.setCachedNodes(null);
154 selection.dirty = true;
160 export function $createPoint(
163 type: 'text' | 'element',
165 // @ts-expect-error: intentionally cast as we use a class for perf reasons
166 return new Point(key, offset, type);
169 function selectPointOnNode(point: PointType, node: LexicalNode): void {
170 let key = node.__key;
171 let offset = point.offset;
172 let type: 'element' | 'text' = 'element';
173 if ($isTextNode(node)) {
175 const textContentLength = node.getTextContentSize();
176 if (offset > textContentLength) {
177 offset = textContentLength;
179 } else if (!$isElementNode(node)) {
180 const nextSibling = node.getNextSibling();
181 if ($isTextNode(nextSibling)) {
182 key = nextSibling.__key;
186 const parentNode = node.getParent();
188 key = parentNode.__key;
189 offset = node.getIndexWithinParent() + 1;
193 point.set(key, offset, type);
196 export function $moveSelectionPointToEnd(
200 if ($isElementNode(node)) {
201 const lastNode = node.getLastDescendant();
202 if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
203 selectPointOnNode(point, lastNode);
205 selectPointOnNode(point, node);
208 selectPointOnNode(point, node);
212 function $transferStartingElementPointToTextPoint(
213 start: ElementPointType,
218 const element = start.getNode();
219 const placementNode = element.getChildAtIndex(start.offset);
220 const textNode = $createTextNode();
221 const target = $isRootNode(element)
222 ? $createParagraphNode().append(textNode)
224 textNode.setFormat(format);
225 textNode.setStyle(style);
226 if (placementNode === null) {
227 element.append(target);
229 placementNode.insertBefore(target);
231 // Transfer the element point to a text point.
233 end.set(textNode.__key, 0, 'text');
235 start.set(textNode.__key, 0, 'text');
238 function $setPointValues(
242 type: 'text' | 'element',
245 point.offset = offset;
249 export interface BaseSelection {
250 _cachedNodes: Array<LexicalNode> | null;
253 clone(): BaseSelection;
254 extract(): Array<LexicalNode>;
255 getNodes(): Array<LexicalNode>;
256 getTextContent(): string;
257 insertText(text: string): void;
258 insertRawText(text: string): void;
259 is(selection: null | BaseSelection): boolean;
260 insertNodes(nodes: Array<LexicalNode>): void;
261 getStartEndPoints(): null | [PointType, PointType];
262 isCollapsed(): boolean;
263 isBackward(): boolean;
264 getCachedNodes(): LexicalNode[] | null;
265 setCachedNodes(nodes: LexicalNode[] | null): void;
268 export class NodeSelection implements BaseSelection {
269 _nodes: Set<NodeKey>;
270 _cachedNodes: Array<LexicalNode> | null;
273 constructor(objects: Set<NodeKey>) {
274 this._cachedNodes = null;
275 this._nodes = objects;
279 getCachedNodes(): LexicalNode[] | null {
280 return this._cachedNodes;
283 setCachedNodes(nodes: LexicalNode[] | null): void {
284 this._cachedNodes = nodes;
287 is(selection: null | BaseSelection): boolean {
288 if (!$isNodeSelection(selection)) {
291 const a: Set<NodeKey> = this._nodes;
292 const b: Set<NodeKey> = selection._nodes;
293 return a.size === b.size && Array.from(a).every((key) => b.has(key));
296 isCollapsed(): boolean {
300 isBackward(): boolean {
304 getStartEndPoints(): null {
308 add(key: NodeKey): void {
310 this._nodes.add(key);
311 this._cachedNodes = null;
314 delete(key: NodeKey): void {
316 this._nodes.delete(key);
317 this._cachedNodes = null;
323 this._cachedNodes = null;
326 has(key: NodeKey): boolean {
327 return this._nodes.has(key);
330 clone(): NodeSelection {
331 return new NodeSelection(new Set(this._nodes));
334 extract(): Array<LexicalNode> {
335 return this.getNodes();
338 insertRawText(text: string): void {
346 insertNodes(nodes: Array<LexicalNode>) {
347 const selectedNodes = this.getNodes();
348 const selectedNodesLength = selectedNodes.length;
349 const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
350 let selectionAtEnd: RangeSelection;
352 if ($isTextNode(lastSelectedNode)) {
353 selectionAtEnd = lastSelectedNode.select();
355 const index = lastSelectedNode.getIndexWithinParent() + 1;
356 selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
358 selectionAtEnd.insertNodes(nodes);
359 // Remove selected nodes
360 for (let i = 0; i < selectedNodesLength; i++) {
361 selectedNodes[i].remove();
365 getNodes(): Array<LexicalNode> {
366 const cachedNodes = this._cachedNodes;
367 if (cachedNodes !== null) {
370 const objects = this._nodes;
372 for (const object of objects) {
373 const node = $getNodeByKey(object);
378 if (!isCurrentlyReadOnlyMode()) {
379 this._cachedNodes = nodes;
384 getTextContent(): string {
385 const nodes = this.getNodes();
386 let textContent = '';
387 for (let i = 0; i < nodes.length; i++) {
388 textContent += nodes[i].getTextContent();
394 export function $isRangeSelection(x: unknown): x is RangeSelection {
395 return x instanceof RangeSelection;
398 export class RangeSelection implements BaseSelection {
403 _cachedNodes: Array<LexicalNode> | null;
412 this.anchor = anchor;
414 anchor._selection = this;
415 focus._selection = this;
416 this._cachedNodes = null;
417 this.format = format;
422 getCachedNodes(): LexicalNode[] | null {
423 return this._cachedNodes;
426 setCachedNodes(nodes: LexicalNode[] | null): void {
427 this._cachedNodes = nodes;
431 * Used to check if the provided selections is equal to this one by value,
432 * inluding anchor, focus, format, and style properties.
433 * @param selection - the Selection to compare this one to.
434 * @returns true if the Selections are equal, false otherwise.
436 is(selection: null | BaseSelection): boolean {
437 if (!$isRangeSelection(selection)) {
441 this.anchor.is(selection.anchor) &&
442 this.focus.is(selection.focus) &&
443 this.format === selection.format &&
444 this.style === selection.style
449 * Returns whether the Selection is "collapsed", meaning the anchor and focus are
450 * the same node and have the same offset.
452 * @returns true if the Selection is collapsed, false otherwise.
454 isCollapsed(): boolean {
455 return this.anchor.is(this.focus);
459 * Gets all the nodes in the Selection. Uses caching to make it generally suitable
460 * for use in hot paths.
462 * @returns an Array containing all the nodes in the Selection
464 getNodes(): Array<LexicalNode> {
465 const cachedNodes = this._cachedNodes;
466 if (cachedNodes !== null) {
469 const anchor = this.anchor;
470 const focus = this.focus;
471 const isBefore = anchor.isBefore(focus);
472 const firstPoint = isBefore ? anchor : focus;
473 const lastPoint = isBefore ? focus : anchor;
474 let firstNode = firstPoint.getNode();
475 let lastNode = lastPoint.getNode();
476 const startOffset = firstPoint.offset;
477 const endOffset = lastPoint.offset;
479 if ($isElementNode(firstNode)) {
480 const firstNodeDescendant =
481 firstNode.getDescendantByIndex<ElementNode>(startOffset);
482 firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
484 if ($isElementNode(lastNode)) {
485 let lastNodeDescendant =
486 lastNode.getDescendantByIndex<ElementNode>(endOffset);
487 // We don't want to over-select, as node selection infers the child before
488 // the last descendant, not including that descendant.
490 lastNodeDescendant !== null &&
491 lastNodeDescendant !== firstNode &&
492 lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
494 lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
496 lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
499 let nodes: Array<LexicalNode>;
501 if (firstNode.is(lastNode)) {
502 if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
508 nodes = firstNode.getNodesBetween(lastNode);
510 if (!isCurrentlyReadOnlyMode()) {
511 this._cachedNodes = nodes;
517 * Sets this Selection to be of type "text" at the provided anchor and focus values.
519 * @param anchorNode - the anchor node to set on the Selection
520 * @param anchorOffset - the offset to set on the Selection
521 * @param focusNode - the focus node to set on the Selection
522 * @param focusOffset - the focus offset to set on the Selection
525 anchorNode: TextNode,
526 anchorOffset: number,
530 $setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
531 $setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
532 this._cachedNodes = null;
537 * Gets the (plain) text content of all the nodes in the selection.
539 * @returns a string representing the text content of all the nodes in the Selection
541 getTextContent(): string {
542 const nodes = this.getNodes();
543 if (nodes.length === 0) {
546 const firstNode = nodes[0];
547 const lastNode = nodes[nodes.length - 1];
548 const anchor = this.anchor;
549 const focus = this.focus;
550 const isBefore = anchor.isBefore(focus);
551 const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
552 let textContent = '';
553 let prevWasElement = true;
554 for (let i = 0; i < nodes.length; i++) {
555 const node = nodes[i];
556 if ($isElementNode(node) && !node.isInline()) {
557 if (!prevWasElement) {
560 if (node.isEmpty()) {
561 prevWasElement = false;
563 prevWasElement = true;
566 prevWasElement = false;
567 if ($isTextNode(node)) {
568 let text = node.getTextContent();
569 if (node === firstNode) {
570 if (node === lastNode) {
572 anchor.type !== 'element' ||
573 focus.type !== 'element' ||
574 focus.offset === anchor.offset
577 anchorOffset < focusOffset
578 ? text.slice(anchorOffset, focusOffset)
579 : text.slice(focusOffset, anchorOffset);
583 ? text.slice(anchorOffset)
584 : text.slice(focusOffset);
586 } else if (node === lastNode) {
588 ? text.slice(0, focusOffset)
589 : text.slice(0, anchorOffset);
593 ($isDecoratorNode(node) || $isLineBreakNode(node)) &&
594 (node !== lastNode || !this.isCollapsed())
596 textContent += node.getTextContent();
604 * Attempts to map a DOM selection range onto this Lexical Selection,
605 * setting the anchor, focus, and type accordingly
607 * @param range a DOM Selection range conforming to the StaticRange interface.
609 applyDOMRange(range: StaticRange): void {
610 const editor = getActiveEditor();
611 const currentEditorState = editor.getEditorState();
612 const lastSelection = currentEditorState._selection;
613 const resolvedSelectionPoints = $internalResolveSelectionPoints(
614 range.startContainer,
621 if (resolvedSelectionPoints === null) {
624 const [anchorPoint, focusPoint] = resolvedSelectionPoints;
637 this._cachedNodes = null;
641 * Creates a new RangeSelection, copying over all the property values from this one.
643 * @returns a new RangeSelection with the same property values as this one.
645 clone(): RangeSelection {
646 const anchor = this.anchor;
647 const focus = this.focus;
648 const selection = new RangeSelection(
649 $createPoint(anchor.key, anchor.offset, anchor.type),
650 $createPoint(focus.key, focus.offset, focus.type),
658 * Toggles the provided format on all the TextNodes in the Selection.
660 * @param format a string TextFormatType to toggle on the TextNodes in the selection
662 toggleFormat(format: TextFormatType): void {
663 this.format = toggleTextFormatType(this.format, format, null);
668 * Sets the value of the style property on the Selection
670 * @param style - the style to set at the value of the style property.
672 setStyle(style: string): void {
678 * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
679 * has the specified format.
681 * @param type the TextFormatType to check for.
682 * @returns true if the provided format is currently toggled on on the Selection, false otherwise.
684 hasFormat(type: TextFormatType): boolean {
685 const formatFlag = TEXT_TYPE_TO_FORMAT[type];
686 return (this.format & formatFlag) !== 0;
690 * Attempts to insert the provided text into the EditorState at the current Selection.
691 * converts tabs, newlines, and carriage returns into LexicalNodes.
693 * @param text the text to insert into the Selection
695 insertRawText(text: string): void {
696 const parts = text.split(/(\r?\n|\t)/);
698 const length = parts.length;
699 for (let i = 0; i < length; i++) {
700 const part = parts[i];
701 if (part === '\n' || part === '\r\n') {
702 nodes.push($createLineBreakNode());
703 } else if (part === '\t') {
704 nodes.push($createTabNode());
706 nodes.push($createTextNode(part));
709 this.insertNodes(nodes);
713 * Attempts to insert the provided text into the EditorState at the current Selection as a new
714 * Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
716 * @param text the text to insert into the Selection
718 insertText(text: string): void {
719 const anchor = this.anchor;
720 const focus = this.focus;
721 const format = this.format;
722 const style = this.style;
723 let firstPoint = anchor;
724 let endPoint = focus;
725 if (!this.isCollapsed() && focus.isBefore(anchor)) {
729 if (firstPoint.type === 'element') {
730 $transferStartingElementPointToTextPoint(
737 const startOffset = firstPoint.offset;
738 let endOffset = endPoint.offset;
739 const selectedNodes = this.getNodes();
740 const selectedNodesLength = selectedNodes.length;
741 let firstNode: TextNode = selectedNodes[0] as TextNode;
743 if (!$isTextNode(firstNode)) {
744 invariant(false, 'insertText: first node is not a text node');
746 const firstNodeText = firstNode.getTextContent();
747 const firstNodeTextLength = firstNodeText.length;
748 const firstNodeParent = firstNode.getParentOrThrow();
749 const lastIndex = selectedNodesLength - 1;
750 let lastNode = selectedNodes[lastIndex];
752 if (selectedNodesLength === 1 && endPoint.type === 'element') {
753 endOffset = firstNodeTextLength;
754 endPoint.set(firstPoint.key, endOffset, 'text');
758 this.isCollapsed() &&
759 startOffset === firstNodeTextLength &&
760 (firstNode.isSegmented() ||
761 firstNode.isToken() ||
762 !firstNode.canInsertTextAfter() ||
763 (!firstNodeParent.canInsertTextAfter() &&
764 firstNode.getNextSibling() === null))
766 let nextSibling = firstNode.getNextSibling<TextNode>();
768 !$isTextNode(nextSibling) ||
769 !nextSibling.canInsertTextBefore() ||
770 $isTokenOrSegmented(nextSibling)
772 nextSibling = $createTextNode();
773 nextSibling.setFormat(format);
774 nextSibling.setStyle(style);
775 if (!firstNodeParent.canInsertTextAfter()) {
776 firstNodeParent.insertAfter(nextSibling);
778 firstNode.insertAfter(nextSibling);
781 nextSibling.select(0, 0);
782 firstNode = nextSibling;
784 this.insertText(text);
788 this.isCollapsed() &&
790 (firstNode.isSegmented() ||
791 firstNode.isToken() ||
792 !firstNode.canInsertTextBefore() ||
793 (!firstNodeParent.canInsertTextBefore() &&
794 firstNode.getPreviousSibling() === null))
796 let prevSibling = firstNode.getPreviousSibling<TextNode>();
797 if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
798 prevSibling = $createTextNode();
799 prevSibling.setFormat(format);
800 if (!firstNodeParent.canInsertTextBefore()) {
801 firstNodeParent.insertBefore(prevSibling);
803 firstNode.insertBefore(prevSibling);
806 prevSibling.select();
807 firstNode = prevSibling;
809 this.insertText(text);
812 } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
813 const textNode = $createTextNode(firstNode.getTextContent());
814 textNode.setFormat(format);
815 firstNode.replace(textNode);
816 firstNode = textNode;
817 } else if (!this.isCollapsed() && text !== '') {
818 // When the firstNode or lastNode parents are elements that
819 // do not allow text to be inserted before or after, we first
820 // clear the content. Then we normalize selection, then insert
822 const lastNodeParent = lastNode.getParent();
825 !firstNodeParent.canInsertTextBefore() ||
826 !firstNodeParent.canInsertTextAfter() ||
827 ($isElementNode(lastNodeParent) &&
828 (!lastNodeParent.canInsertTextBefore() ||
829 !lastNodeParent.canInsertTextAfter()))
832 $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
833 this.insertText(text);
838 if (selectedNodesLength === 1) {
839 if (firstNode.isToken()) {
840 const textNode = $createTextNode(text);
842 firstNode.replace(textNode);
845 const firstNodeFormat = firstNode.getFormat();
846 const firstNodeStyle = firstNode.getStyle();
849 startOffset === endOffset &&
850 (firstNodeFormat !== format || firstNodeStyle !== style)
852 if (firstNode.getTextContent() === '') {
853 firstNode.setFormat(format);
854 firstNode.setStyle(style);
856 const textNode = $createTextNode(text);
857 textNode.setFormat(format);
858 textNode.setStyle(style);
860 if (startOffset === 0) {
861 firstNode.insertBefore(textNode, false);
863 const [targetNode] = firstNode.splitText(startOffset);
864 targetNode.insertAfter(textNode, false);
866 // When composing, we need to adjust the anchor offset so that
867 // we correctly replace that right range.
868 if (textNode.isComposing() && this.anchor.type === 'text') {
869 this.anchor.offset -= text.length;
873 } else if ($isTabNode(firstNode)) {
874 // We don't need to check for delCount because there is only the entire selected node case
875 // that can hit here for content size 1 and with canInsertTextBeforeAfter false
876 const textNode = $createTextNode(text);
877 textNode.setFormat(format);
878 textNode.setStyle(style);
880 firstNode.replace(textNode);
883 const delCount = endOffset - startOffset;
885 firstNode = firstNode.spliceText(startOffset, delCount, text, true);
886 if (firstNode.getTextContent() === '') {
888 } else if (this.anchor.type === 'text') {
889 if (firstNode.isComposing()) {
890 // When composing, we need to adjust the anchor offset so that
891 // we correctly replace that right range.
892 this.anchor.offset -= text.length;
894 this.format = firstNodeFormat;
895 this.style = firstNodeStyle;
899 const markedNodeKeysForKeep = new Set([
900 ...firstNode.getParentKeys(),
901 ...lastNode.getParentKeys(),
904 // We have to get the parent elements before the next section,
905 // as in that section we might mutate the lastNode.
906 const firstElement = $isElementNode(firstNode)
908 : firstNode.getParentOrThrow();
909 let lastElement = $isElementNode(lastNode)
911 : lastNode.getParentOrThrow();
912 let lastElementChild = lastNode;
914 // If the last element is inline, we should instead look at getting
915 // the nodes of its parent, rather than itself. This behavior will
916 // then better match how text node insertions work. We will need to
917 // also update the last element's child accordingly as we do this.
918 if (!firstElement.is(lastElement) && lastElement.isInline()) {
919 // Keep traversing till we have a non-inline element parent.
921 lastElementChild = lastElement;
922 lastElement = lastElement.getParentOrThrow();
923 } while (lastElement.isInline());
926 // Handle mutations to the last node.
928 (endPoint.type === 'text' &&
929 (endOffset !== 0 || lastNode.getTextContent() === '')) ||
930 (endPoint.type === 'element' &&
931 lastNode.getIndexWithinParent() < endOffset)
934 $isTextNode(lastNode) &&
935 !lastNode.isToken() &&
936 endOffset !== lastNode.getTextContentSize()
938 if (lastNode.isSegmented()) {
939 const textNode = $createTextNode(lastNode.getTextContent());
940 lastNode.replace(textNode);
943 // root node selections only select whole nodes, so no text splice is necessary
944 if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
945 lastNode = (lastNode as TextNode).spliceText(0, endOffset, '');
947 markedNodeKeysForKeep.add(lastNode.__key);
949 const lastNodeParent = lastNode.getParentOrThrow();
951 !lastNodeParent.canBeEmpty() &&
952 lastNodeParent.getChildrenSize() === 1
954 lastNodeParent.remove();
960 markedNodeKeysForKeep.add(lastNode.__key);
963 // Either move the remaining nodes of the last parent to after
964 // the first child, or remove them entirely. If the last parent
965 // is the same as the first parent, this logic also works.
966 const lastNodeChildren = lastElement.getChildren();
967 const selectedNodesSet = new Set(selectedNodes);
968 const firstAndLastElementsAreEqual = firstElement.is(lastElement);
970 // We choose a target to insert all nodes after. In the case of having
971 // and inline starting parent element with a starting node that has no
972 // siblings, we should insert after the starting parent element, otherwise
973 // we will incorrectly merge into the starting parent element.
974 // TODO: should we keep on traversing parents if we're inside another
975 // nested inline element?
976 const insertionTarget =
977 firstElement.isInline() && firstNode.getNextSibling() === null
981 for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
982 const lastNodeChild = lastNodeChildren[i];
985 lastNodeChild.is(firstNode) ||
986 ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
991 if (lastNodeChild.isAttached()) {
993 !selectedNodesSet.has(lastNodeChild) ||
994 lastNodeChild.is(lastElementChild)
996 if (!firstAndLastElementsAreEqual) {
997 insertionTarget.insertAfter(lastNodeChild, false);
1000 lastNodeChild.remove();
1005 if (!firstAndLastElementsAreEqual) {
1006 // Check if we have already moved out all the nodes of the
1007 // last parent, and if so, traverse the parent tree and mark
1008 // them all as being able to deleted too.
1009 let parent: ElementNode | null = lastElement;
1010 let lastRemovedParent = null;
1012 while (parent !== null) {
1013 const children = parent.getChildren();
1014 const childrenLength = children.length;
1016 childrenLength === 0 ||
1017 children[childrenLength - 1].is(lastRemovedParent)
1019 markedNodeKeysForKeep.delete(parent.__key);
1020 lastRemovedParent = parent;
1022 parent = parent.getParent();
1026 // Ensure we do splicing after moving of nodes, as splicing
1027 // can have side-effects (in the case of hashtags).
1028 if (!firstNode.isToken()) {
1029 firstNode = firstNode.spliceText(
1031 firstNodeTextLength - startOffset,
1035 if (firstNode.getTextContent() === '') {
1037 } else if (firstNode.isComposing() && this.anchor.type === 'text') {
1038 // When composing, we need to adjust the anchor offset so that
1039 // we correctly replace that right range.
1040 this.anchor.offset -= text.length;
1042 } else if (startOffset === firstNodeTextLength) {
1045 const textNode = $createTextNode(text);
1047 firstNode.replace(textNode);
1050 // Remove all selected nodes that haven't already been removed.
1051 for (let i = 1; i < selectedNodesLength; i++) {
1052 const selectedNode = selectedNodes[i];
1053 const key = selectedNode.__key;
1054 if (!markedNodeKeysForKeep.has(key)) {
1055 selectedNode.remove();
1062 * Removes the text in the Selection, adjusting the EditorState accordingly.
1064 removeText(): void {
1065 this.insertText('');
1069 * Applies the provided format to the TextNodes in the Selection, splitting or
1070 * merging nodes as necessary.
1072 * @param formatType the format type to apply to the nodes in the Selection.
1074 formatText(formatType: TextFormatType): void {
1075 if (this.isCollapsed()) {
1076 this.toggleFormat(formatType);
1077 // When changing format, we should stop composition
1078 $setCompositionKey(null);
1082 const selectedNodes = this.getNodes();
1083 const selectedTextNodes: Array<TextNode> = [];
1084 for (const selectedNode of selectedNodes) {
1085 if ($isTextNode(selectedNode)) {
1086 selectedTextNodes.push(selectedNode);
1090 const selectedTextNodesLength = selectedTextNodes.length;
1091 if (selectedTextNodesLength === 0) {
1092 this.toggleFormat(formatType);
1093 // When changing format, we should stop composition
1094 $setCompositionKey(null);
1098 const anchor = this.anchor;
1099 const focus = this.focus;
1100 const isBackward = this.isBackward();
1101 const startPoint = isBackward ? focus : anchor;
1102 const endPoint = isBackward ? anchor : focus;
1105 let firstNode = selectedTextNodes[0];
1106 let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
1108 // In case selection started at the end of text node use next text node
1110 startPoint.type === 'text' &&
1111 startOffset === firstNode.getTextContentSize()
1114 firstNode = selectedTextNodes[1];
1118 if (firstNode == null) {
1122 const firstNextFormat = firstNode.getFormatFlags(formatType, null);
1124 const lastIndex = selectedTextNodesLength - 1;
1125 let lastNode = selectedTextNodes[lastIndex];
1127 endPoint.type === 'text'
1129 : lastNode.getTextContentSize();
1131 // Single node selected
1132 if (firstNode.is(lastNode)) {
1133 // No actual text is selected, so do nothing.
1134 if (startOffset === endOffset) {
1137 // The entire node is selected or it is token, so just format it
1139 $isTokenOrSegmented(firstNode) ||
1140 (startOffset === 0 && endOffset === firstNode.getTextContentSize())
1142 firstNode.setFormat(firstNextFormat);
1144 // Node is partially selected, so split it into two nodes
1145 // add style the selected one.
1146 const splitNodes = firstNode.splitText(startOffset, endOffset);
1147 const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
1148 replacement.setFormat(firstNextFormat);
1150 // Update selection only if starts/ends on text node
1151 if (startPoint.type === 'text') {
1152 startPoint.set(replacement.__key, 0, 'text');
1154 if (endPoint.type === 'text') {
1155 endPoint.set(replacement.__key, endOffset - startOffset, 'text');
1159 this.format = firstNextFormat;
1163 // Multiple nodes selected
1164 // The entire first node isn't selected, so split it
1165 if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
1166 [, firstNode as TextNode] = firstNode.splitText(startOffset);
1169 firstNode.setFormat(firstNextFormat);
1171 const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
1172 // If the offset is 0, it means no actual characters are selected,
1173 // so we skip formatting the last node altogether.
1174 if (endOffset > 0) {
1176 endOffset !== lastNode.getTextContentSize() &&
1177 !$isTokenOrSegmented(lastNode)
1179 [lastNode as TextNode] = lastNode.splitText(endOffset);
1181 lastNode.setFormat(lastNextFormat);
1184 // Process all text nodes in between
1185 for (let i = firstIndex + 1; i < lastIndex; i++) {
1186 const textNode = selectedTextNodes[i];
1187 const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
1188 textNode.setFormat(nextFormat);
1191 // Update selection only if starts/ends on text node
1192 if (startPoint.type === 'text') {
1193 startPoint.set(firstNode.__key, startOffset, 'text');
1195 if (endPoint.type === 'text') {
1196 endPoint.set(lastNode.__key, endOffset, 'text');
1199 this.format = firstNextFormat | lastNextFormat;
1203 * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
1204 * current Selection according to a set of heuristics that determine how surrounding nodes
1205 * should be changed, replaced, or moved to accomodate the incoming ones.
1207 * @param nodes - the nodes to insert
1209 insertNodes(nodes: Array<LexicalNode>): void {
1210 if (nodes.length === 0) {
1213 if (this.anchor.key === 'root') {
1214 this.insertParagraph();
1215 const selection = $getSelection();
1217 $isRangeSelection(selection),
1218 'Expected RangeSelection after insertParagraph',
1220 return selection.insertNodes(nodes);
1223 const firstPoint = this.isBackward() ? this.focus : this.anchor;
1224 const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!;
1226 const last = nodes[nodes.length - 1]!;
1228 // CASE 1: insert inside a code block
1229 if ('__language' in firstBlock && $isElementNode(firstBlock)) {
1230 if ('__language' in nodes[0]) {
1231 this.insertText(nodes[0].getTextContent());
1233 const index = $removeTextAndSplitBlock(this);
1234 firstBlock.splice(index, 0, nodes);
1240 // CASE 2: All elements of the array are inline
1241 const notInline = (node: LexicalNode) =>
1242 ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
1244 if (!nodes.some(notInline)) {
1246 $isElementNode(firstBlock),
1247 "Expected 'firstBlock' to be an ElementNode",
1249 const index = $removeTextAndSplitBlock(this);
1250 firstBlock.splice(index, 0, nodes);
1255 // CASE 3: At least 1 element of the array is not inline
1256 const blocksParent = $wrapInlineNodes(nodes);
1257 const nodeToSelect = blocksParent.getLastDescendant()!;
1258 const blocks = blocksParent.getChildren();
1259 const isMergeable = (node: LexicalNode): node is ElementNode =>
1260 $isElementNode(node) &&
1261 INTERNAL_$isBlock(node) &&
1263 $isElementNode(firstBlock) &&
1264 (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());
1266 const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
1267 const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
1268 const lastToInsert = blocks[blocks.length - 1];
1269 let firstToInsert = blocks[0];
1270 if (isMergeable(firstToInsert)) {
1272 $isElementNode(firstBlock),
1273 "Expected 'firstBlock' to be an ElementNode",
1275 firstBlock.append(...firstToInsert.getChildren());
1276 firstToInsert = blocks[1];
1278 if (firstToInsert) {
1279 insertRangeAfter(firstBlock, firstToInsert);
1281 const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!;
1284 insertedParagraph &&
1285 $isElementNode(lastInsertedBlock) &&
1286 (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
1288 lastInsertedBlock.append(...insertedParagraph.getChildren());
1289 insertedParagraph.remove();
1291 if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
1292 firstBlock.remove();
1295 nodeToSelect.selectEnd();
1297 // To understand this take a look at the test "can wrap post-linebreak nodes into new element"
1298 const lastChild = $isElementNode(firstBlock)
1299 ? firstBlock.getLastChild()
1301 if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
1307 * Inserts a new ParagraphNode into the EditorState at the current Selection
1309 * @returns the newly inserted node.
1311 insertParagraph(): ElementNode | null {
1312 if (this.anchor.key === 'root') {
1313 const paragraph = $createParagraphNode();
1314 $getRoot().splice(this.anchor.offset, 0, [paragraph]);
1318 const index = $removeTextAndSplitBlock(this);
1319 const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!;
1320 invariant($isElementNode(block), 'Expected ancestor to be an ElementNode');
1321 const firstToAppend = block.getChildAtIndex(index);
1322 const nodesToInsert = firstToAppend
1323 ? [firstToAppend, ...firstToAppend.getNextSiblings()]
1325 const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
1327 newBlock.append(...nodesToInsert);
1328 newBlock.selectStart();
1331 // if newBlock is null, it means that block is of type CodeNode.
1336 * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
1337 * current Selection.
1339 insertLineBreak(selectStart?: boolean): void {
1340 const lineBreak = $createLineBreakNode();
1341 this.insertNodes([lineBreak]);
1342 // this is used in MacOS with the command 'ctrl-O' (openLineBreak)
1344 const parent = lineBreak.getParentOrThrow();
1345 const index = lineBreak.getIndexWithinParent();
1346 parent.select(index, index);
1351 * Extracts the nodes in the Selection, splitting nodes where necessary
1352 * to get offset-level precision.
1354 * @returns The nodes in the Selection
1356 extract(): Array<LexicalNode> {
1357 const selectedNodes = this.getNodes();
1358 const selectedNodesLength = selectedNodes.length;
1359 const lastIndex = selectedNodesLength - 1;
1360 const anchor = this.anchor;
1361 const focus = this.focus;
1362 let firstNode = selectedNodes[0];
1363 let lastNode = selectedNodes[lastIndex];
1364 const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
1366 if (selectedNodesLength === 0) {
1368 } else if (selectedNodesLength === 1) {
1369 if ($isTextNode(firstNode) && !this.isCollapsed()) {
1371 anchorOffset > focusOffset ? focusOffset : anchorOffset;
1373 anchorOffset > focusOffset ? anchorOffset : focusOffset;
1374 const splitNodes = firstNode.splitText(startOffset, endOffset);
1375 const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
1376 return node != null ? [node] : [];
1380 const isBefore = anchor.isBefore(focus);
1382 if ($isTextNode(firstNode)) {
1383 const startOffset = isBefore ? anchorOffset : focusOffset;
1384 if (startOffset === firstNode.getTextContentSize()) {
1385 selectedNodes.shift();
1386 } else if (startOffset !== 0) {
1387 [, firstNode] = firstNode.splitText(startOffset);
1388 selectedNodes[0] = firstNode;
1391 if ($isTextNode(lastNode)) {
1392 const lastNodeText = lastNode.getTextContent();
1393 const lastNodeTextLength = lastNodeText.length;
1394 const endOffset = isBefore ? focusOffset : anchorOffset;
1395 if (endOffset === 0) {
1396 selectedNodes.pop();
1397 } else if (endOffset !== lastNodeTextLength) {
1398 [lastNode] = lastNode.splitText(endOffset);
1399 selectedNodes[lastIndex] = lastNode;
1402 return selectedNodes;
1406 * Modifies the Selection according to the parameters and a set of heuristics that account for
1407 * various node types. Can be used to safely move or extend selection by one logical "unit" without
1408 * dealing explicitly with all the possible node types.
1410 * @param alter the type of modification to perform
1411 * @param isBackward whether or not selection is backwards
1412 * @param granularity the granularity at which to apply the modification
1415 alter: 'move' | 'extend',
1416 isBackward: boolean,
1417 granularity: 'character' | 'word' | 'lineboundary',
1419 const focus = this.focus;
1420 const anchor = this.anchor;
1421 const collapse = alter === 'move';
1423 // Handle the selection movement around decorators.
1424 const possibleNode = $getAdjacentNode(focus, isBackward);
1425 if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
1426 // Make it possible to move selection from range selection to
1427 // node selection on the node.
1428 if (collapse && possibleNode.isKeyboardSelectable()) {
1429 const nodeSelection = $createNodeSelection();
1430 nodeSelection.add(possibleNode.__key);
1431 $setSelection(nodeSelection);
1434 const sibling = isBackward
1435 ? possibleNode.getPreviousSibling()
1436 : possibleNode.getNextSibling();
1438 if (!$isTextNode(sibling)) {
1439 const parent = possibleNode.getParentOrThrow();
1443 if ($isElementNode(sibling)) {
1444 elementKey = sibling.__key;
1445 offset = isBackward ? sibling.getChildrenSize() : 0;
1447 offset = possibleNode.getIndexWithinParent();
1448 elementKey = parent.__key;
1453 focus.set(elementKey, offset, 'element');
1455 anchor.set(elementKey, offset, 'element');
1459 const siblingKey = sibling.__key;
1460 const offset = isBackward ? sibling.getTextContent().length : 0;
1461 focus.set(siblingKey, offset, 'text');
1463 anchor.set(siblingKey, offset, 'text');
1468 const editor = getActiveEditor();
1469 const domSelection = getDOMSelection(editor._window);
1471 if (!domSelection) {
1474 const blockCursorElement = editor._blockCursorElement;
1475 const rootElement = editor._rootElement;
1476 // Remove the block cursor element if it exists. This will ensure selection
1477 // works as intended. If we leave it in the DOM all sorts of strange bugs
1480 rootElement !== null &&
1481 blockCursorElement !== null &&
1482 $isElementNode(possibleNode) &&
1483 !possibleNode.isInline() &&
1484 !possibleNode.canBeEmpty()
1486 removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
1488 // We use the DOM selection.modify API here to "tell" us what the selection
1489 // will be. We then use it to update the Lexical selection accordingly. This
1490 // is much more reliable than waiting for a beforeinput and using the ranges
1491 // from getTargetRanges(), and is also better than trying to do it ourselves
1492 // using Intl.Segmenter or other workarounds that struggle with word segments
1493 // and line segments (especially with word wrapping and non-Roman languages).
1494 moveNativeSelection(
1497 isBackward ? 'backward' : 'forward',
1500 // Guard against no ranges
1501 if (domSelection.rangeCount > 0) {
1502 const range = domSelection.getRangeAt(0);
1503 // Apply the DOM selection to our Lexical selection.
1504 const anchorNode = this.anchor.getNode();
1505 const root = $isRootNode(anchorNode)
1507 : $getNearestRootOrShadowRoot(anchorNode);
1508 this.applyDOMRange(range);
1511 // Validate selection; make sure that the new extended selection respects shadow roots
1512 const nodes = this.getNodes();
1513 const validNodes = [];
1514 let shrinkSelection = false;
1515 for (let i = 0; i < nodes.length; i++) {
1516 const nextNode = nodes[i];
1517 if ($hasAncestor(nextNode, root)) {
1518 validNodes.push(nextNode);
1520 shrinkSelection = true;
1523 if (shrinkSelection && validNodes.length > 0) {
1524 // validNodes length check is a safeguard against an invalid selection; as getNodes()
1525 // will return an empty array in this case
1527 const firstValidNode = validNodes[0];
1528 if ($isElementNode(firstValidNode)) {
1529 firstValidNode.selectStart();
1531 firstValidNode.getParentOrThrow().selectStart();
1534 const lastValidNode = validNodes[validNodes.length - 1];
1535 if ($isElementNode(lastValidNode)) {
1536 lastValidNode.selectEnd();
1538 lastValidNode.getParentOrThrow().selectEnd();
1543 // Because a range works on start and end, we might need to flip
1544 // the anchor and focus points to match what the DOM has, not what
1545 // the range has specifically.
1547 domSelection.anchorNode !== range.startContainer ||
1548 domSelection.anchorOffset !== range.startOffset
1556 * Helper for handling forward character and word deletion that prevents element nodes
1557 * like a table, columns layout being destroyed
1559 * @param anchor the anchor
1560 * @param anchorNode the anchor node in the selection
1561 * @param isBackward whether or not selection is backwards
1565 anchorNode: TextNode | ElementNode,
1566 isBackward: boolean,
1570 // Delete forward handle case
1571 ((anchor.type === 'element' &&
1572 $isElementNode(anchorNode) &&
1573 anchor.offset === anchorNode.getChildrenSize()) ||
1574 (anchor.type === 'text' &&
1575 anchor.offset === anchorNode.getTextContentSize()))
1577 const parent = anchorNode.getParent();
1579 anchorNode.getNextSibling() ||
1580 (parent === null ? null : parent.getNextSibling());
1582 if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
1590 * Performs one logical character deletion operation on the EditorState based on the current Selection.
1591 * Handles different node types.
1593 * @param isBackward whether or not the selection is backwards.
1595 deleteCharacter(isBackward: boolean): void {
1596 const wasCollapsed = this.isCollapsed();
1597 if (this.isCollapsed()) {
1598 const anchor = this.anchor;
1599 let anchorNode: TextNode | ElementNode | null = anchor.getNode();
1600 if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
1604 // Handle the deletion around decorators.
1605 const focus = this.focus;
1606 const possibleNode = $getAdjacentNode(focus, isBackward);
1607 if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
1608 // Make it possible to move selection from range selection to
1609 // node selection on the node.
1611 possibleNode.isKeyboardSelectable() &&
1612 $isElementNode(anchorNode) &&
1613 anchorNode.getChildrenSize() === 0
1615 anchorNode.remove();
1616 const nodeSelection = $createNodeSelection();
1617 nodeSelection.add(possibleNode.__key);
1618 $setSelection(nodeSelection);
1620 possibleNode.remove();
1621 const editor = getActiveEditor();
1622 editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
1627 $isElementNode(possibleNode) &&
1628 $isElementNode(anchorNode) &&
1629 anchorNode.isEmpty()
1631 anchorNode.remove();
1632 possibleNode.selectStart();
1635 this.modify('extend', isBackward, 'character');
1637 if (!this.isCollapsed()) {
1638 const focusNode = focus.type === 'text' ? focus.getNode() : null;
1639 anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
1641 if (focusNode !== null && focusNode.isSegmented()) {
1642 const offset = focus.offset;
1643 const textContentSize = focusNode.getTextContentSize();
1645 focusNode.is(anchorNode) ||
1646 (isBackward && offset !== textContentSize) ||
1647 (!isBackward && offset !== 0)
1649 $removeSegment(focusNode, isBackward, offset);
1652 } else if (anchorNode !== null && anchorNode.isSegmented()) {
1653 const offset = anchor.offset;
1654 const textContentSize = anchorNode.getTextContentSize();
1656 anchorNode.is(focusNode) ||
1657 (isBackward && offset !== 0) ||
1658 (!isBackward && offset !== textContentSize)
1660 $removeSegment(anchorNode, isBackward, offset);
1664 $updateCaretSelectionForUnicodeCharacter(this, isBackward);
1665 } else if (isBackward && anchor.offset === 0) {
1666 // Special handling around rich text nodes
1668 anchor.type === 'element'
1670 : anchor.getNode().getParentOrThrow();
1671 if (element.collapseAtStart(this)) {
1680 this.isCollapsed() &&
1681 this.anchor.type === 'element' &&
1682 this.anchor.offset === 0
1684 const anchorNode = this.anchor.getNode();
1686 anchorNode.isEmpty() &&
1687 $isRootNode(anchorNode.getParent()) &&
1688 anchorNode.getIndexWithinParent() === 0
1690 anchorNode.collapseAtStart(this);
1696 * Performs one logical line deletion operation on the EditorState based on the current Selection.
1697 * Handles different node types.
1699 * @param isBackward whether or not the selection is backwards.
1701 deleteLine(isBackward: boolean): void {
1702 if (this.isCollapsed()) {
1703 // Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections
1704 // but doesn't properly handle selections which end on elements, a space character is added
1705 // for such selections transforming their anchor's type to 'text'
1706 const anchorIsElement = this.anchor.type === 'element';
1707 if (anchorIsElement) {
1708 this.insertText(' ');
1711 this.modify('extend', isBackward, 'lineboundary');
1713 // If selection is extended to cover text edge then extend it one character more
1714 // to delete its parent element. Otherwise text content will be deleted but empty
1715 // parent node will remain
1716 const endPoint = isBackward ? this.focus : this.anchor;
1717 if (endPoint.offset === 0) {
1718 this.modify('extend', isBackward, 'character');
1721 // Adjusts selection to include an extra character added for element anchors to remove it
1722 if (anchorIsElement) {
1723 const startPoint = isBackward ? this.anchor : this.focus;
1724 startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
1731 * Performs one logical word deletion operation on the EditorState based on the current Selection.
1732 * Handles different node types.
1734 * @param isBackward whether or not the selection is backwards.
1736 deleteWord(isBackward: boolean): void {
1737 if (this.isCollapsed()) {
1738 const anchor = this.anchor;
1739 const anchorNode: TextNode | ElementNode | null = anchor.getNode();
1740 if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
1743 this.modify('extend', isBackward, 'word');
1749 * Returns whether the Selection is "backwards", meaning the focus
1750 * logically precedes the anchor in the EditorState.
1751 * @returns true if the Selection is backwards, false otherwise.
1753 isBackward(): boolean {
1754 return this.focus.isBefore(this.anchor);
1757 getStartEndPoints(): null | [PointType, PointType] {
1758 return [this.anchor, this.focus];
1762 export function $isNodeSelection(x: unknown): x is NodeSelection {
1763 return x instanceof NodeSelection;
1766 function getCharacterOffset(point: PointType): number {
1767 const offset = point.offset;
1768 if (point.type === 'text') {
1772 const parent = point.getNode();
1773 return offset === parent.getChildrenSize()
1774 ? parent.getTextContent().length
1778 export function $getCharacterOffsets(
1779 selection: BaseSelection,
1780 ): [number, number] {
1781 const anchorAndFocus = selection.getStartEndPoints();
1782 if (anchorAndFocus === null) {
1785 const [anchor, focus] = anchorAndFocus;
1787 anchor.type === 'element' &&
1788 focus.type === 'element' &&
1789 anchor.key === focus.key &&
1790 anchor.offset === focus.offset
1794 return [getCharacterOffset(anchor), getCharacterOffset(focus)];
1797 function $swapPoints(selection: RangeSelection): void {
1798 const focus = selection.focus;
1799 const anchor = selection.anchor;
1800 const anchorKey = anchor.key;
1801 const anchorOffset = anchor.offset;
1802 const anchorType = anchor.type;
1804 $setPointValues(anchor, focus.key, focus.offset, focus.type);
1805 $setPointValues(focus, anchorKey, anchorOffset, anchorType);
1806 selection._cachedNodes = null;
1809 function moveNativeSelection(
1810 domSelection: Selection,
1811 alter: 'move' | 'extend',
1812 direction: 'backward' | 'forward' | 'left' | 'right',
1813 granularity: 'character' | 'word' | 'lineboundary',
1815 // Selection.modify() method applies a change to the current selection or cursor position,
1816 // but is still non-standard in some browsers.
1817 domSelection.modify(alter, direction, granularity);
1820 function $updateCaretSelectionForUnicodeCharacter(
1821 selection: RangeSelection,
1822 isBackward: boolean,
1824 const anchor = selection.anchor;
1825 const focus = selection.focus;
1826 const anchorNode = anchor.getNode();
1827 const focusNode = focus.getNode();
1830 anchorNode === focusNode &&
1831 anchor.type === 'text' &&
1832 focus.type === 'text'
1834 // Handling of multibyte characters
1835 const anchorOffset = anchor.offset;
1836 const focusOffset = focus.offset;
1837 const isBefore = anchorOffset < focusOffset;
1838 const startOffset = isBefore ? anchorOffset : focusOffset;
1839 const endOffset = isBefore ? focusOffset : anchorOffset;
1840 const characterOffset = endOffset - 1;
1842 if (startOffset !== characterOffset) {
1843 const text = anchorNode.getTextContent().slice(startOffset, endOffset);
1844 if (!doesContainGrapheme(text)) {
1846 focus.offset = characterOffset;
1848 anchor.offset = characterOffset;
1853 // TODO Handling of multibyte characters
1857 function $removeSegment(
1859 isBackward: boolean,
1862 const textNode = node;
1863 const textContent = textNode.getTextContent();
1864 const split = textContent.split(/(?=\s)/g);
1865 const splitLength = split.length;
1866 let segmentOffset = 0;
1867 let restoreOffset: number | undefined = 0;
1869 for (let i = 0; i < splitLength; i++) {
1870 const text = split[i];
1871 const isLast = i === splitLength - 1;
1872 restoreOffset = segmentOffset;
1873 segmentOffset += text.length;
1876 (isBackward && segmentOffset === offset) ||
1877 segmentOffset > offset ||
1882 restoreOffset = undefined;
1887 const nextTextContent = split.join('').trim();
1889 if (nextTextContent === '') {
1892 textNode.setTextContent(nextTextContent);
1893 textNode.select(restoreOffset, restoreOffset);
1897 function shouldResolveAncestor(
1898 resolvedElement: ElementNode,
1899 resolvedOffset: number,
1900 lastPoint: null | PointType,
1902 const parent = resolvedElement.getParent();
1904 lastPoint === null ||
1906 !parent.canBeEmpty() ||
1907 parent !== lastPoint.getNode()
1911 function $internalResolveSelectionPoint(
1914 lastPoint: null | PointType,
1915 editor: LexicalEditor,
1916 ): null | PointType {
1917 let resolvedOffset = offset;
1918 let resolvedNode: TextNode | LexicalNode | null;
1919 // If we have selection on an element, we will
1920 // need to figure out (using the offset) what text
1921 // node should be selected.
1923 if (dom.nodeType === DOM_ELEMENT_TYPE) {
1924 // Resolve element to a ElementNode, or TextNode, or null
1925 let moveSelectionToEnd = false;
1926 // Given we're moving selection to another node, selection is
1927 // definitely dirty.
1928 // We use the anchor to find which child node to select
1929 const childNodes = dom.childNodes;
1930 const childNodesLength = childNodes.length;
1931 const blockCursorElement = editor._blockCursorElement;
1932 // If the anchor is the same as length, then this means we
1933 // need to select the very last text node.
1934 if (resolvedOffset === childNodesLength) {
1935 moveSelectionToEnd = true;
1936 resolvedOffset = childNodesLength - 1;
1938 let childDOM = childNodes[resolvedOffset];
1939 let hasBlockCursor = false;
1940 if (childDOM === blockCursorElement) {
1941 childDOM = childNodes[resolvedOffset + 1];
1942 hasBlockCursor = true;
1943 } else if (blockCursorElement !== null) {
1944 const blockCursorElementParent = blockCursorElement.parentNode;
1945 if (dom === blockCursorElementParent) {
1946 const blockCursorOffset = Array.prototype.indexOf.call(
1947 blockCursorElementParent.children,
1950 if (offset > blockCursorOffset) {
1955 resolvedNode = $getNodeFromDOM(childDOM);
1957 if ($isTextNode(resolvedNode)) {
1958 resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd);
1960 let resolvedElement = $getNodeFromDOM(dom);
1961 // Ensure resolvedElement is actually a element.
1962 if (resolvedElement === null) {
1965 if ($isElementNode(resolvedElement)) {
1966 resolvedOffset = Math.min(
1967 resolvedElement.getChildrenSize(),
1970 let child = resolvedElement.getChildAtIndex(resolvedOffset);
1972 $isElementNode(child) &&
1973 shouldResolveAncestor(child, resolvedOffset, lastPoint)
1975 const descendant = moveSelectionToEnd
1976 ? child.getLastDescendant()
1977 : child.getFirstDescendant();
1978 if (descendant === null) {
1979 resolvedElement = child;
1982 resolvedElement = $isElementNode(child)
1984 : child.getParentOrThrow();
1988 if ($isTextNode(child)) {
1989 resolvedNode = child;
1990 resolvedElement = null;
1991 resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd);
1993 child !== resolvedElement &&
1994 moveSelectionToEnd &&
2000 const index = resolvedElement.getIndexWithinParent();
2001 // When selecting decorators, there can be some selection issues when using resolvedOffset,
2002 // and instead we should be checking if we're using the offset
2005 $isDecoratorNode(resolvedElement) &&
2006 $getNodeFromDOM(dom) === resolvedElement
2008 resolvedOffset = index;
2010 resolvedOffset = index + 1;
2012 resolvedElement = resolvedElement.getParentOrThrow();
2014 if ($isElementNode(resolvedElement)) {
2015 return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
2020 resolvedNode = $getNodeFromDOM(dom);
2022 if (!$isTextNode(resolvedNode)) {
2025 return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
2028 function resolveSelectionPointOnBoundary(
2029 point: TextPointType,
2030 isBackward: boolean,
2031 isCollapsed: boolean,
2033 const offset = point.offset;
2034 const node = point.getNode();
2037 const prevSibling = node.getPreviousSibling();
2038 const parent = node.getParent();
2042 $isElementNode(prevSibling) &&
2044 prevSibling.isInline()
2046 point.key = prevSibling.__key;
2047 point.offset = prevSibling.getChildrenSize();
2048 // @ts-expect-error: intentional
2049 point.type = 'element';
2050 } else if ($isTextNode(prevSibling)) {
2051 point.key = prevSibling.__key;
2052 point.offset = prevSibling.getTextContent().length;
2055 (isCollapsed || !isBackward) &&
2056 prevSibling === null &&
2057 $isElementNode(parent) &&
2060 const parentSibling = parent.getPreviousSibling();
2061 if ($isTextNode(parentSibling)) {
2062 point.key = parentSibling.__key;
2063 point.offset = parentSibling.getTextContent().length;
2066 } else if (offset === node.getTextContent().length) {
2067 const nextSibling = node.getNextSibling();
2068 const parent = node.getParent();
2070 if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
2071 point.key = nextSibling.__key;
2073 // @ts-expect-error: intentional
2074 point.type = 'element';
2076 (isCollapsed || isBackward) &&
2077 nextSibling === null &&
2078 $isElementNode(parent) &&
2079 parent.isInline() &&
2080 !parent.canInsertTextAfter()
2082 const parentSibling = parent.getNextSibling();
2083 if ($isTextNode(parentSibling)) {
2084 point.key = parentSibling.__key;
2091 function $normalizeSelectionPointsForBoundaries(
2094 lastSelection: null | BaseSelection,
2096 if (anchor.type === 'text' && focus.type === 'text') {
2097 const isBackward = anchor.isBefore(focus);
2098 const isCollapsed = anchor.is(focus);
2100 // Attempt to normalize the offset to the previous sibling if we're at the
2101 // start of a text node and the sibling is a text node or inline element.
2102 resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
2103 resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
2106 focus.key = anchor.key;
2107 focus.offset = anchor.offset;
2108 focus.type = anchor.type;
2110 const editor = getActiveEditor();
2113 editor.isComposing() &&
2114 editor._compositionKey !== anchor.key &&
2115 $isRangeSelection(lastSelection)
2117 const lastAnchor = lastSelection.anchor;
2118 const lastFocus = lastSelection.focus;
2125 $setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
2130 function $internalResolveSelectionPoints(
2131 anchorDOM: null | Node,
2132 anchorOffset: number,
2133 focusDOM: null | Node,
2134 focusOffset: number,
2135 editor: LexicalEditor,
2136 lastSelection: null | BaseSelection,
2137 ): null | [PointType, PointType] {
2139 anchorDOM === null ||
2140 focusDOM === null ||
2141 !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
2145 const resolvedAnchorPoint = $internalResolveSelectionPoint(
2148 $isRangeSelection(lastSelection) ? lastSelection.anchor : null,
2151 if (resolvedAnchorPoint === null) {
2154 const resolvedFocusPoint = $internalResolveSelectionPoint(
2157 $isRangeSelection(lastSelection) ? lastSelection.focus : null,
2160 if (resolvedFocusPoint === null) {
2164 resolvedAnchorPoint.type === 'element' &&
2165 resolvedFocusPoint.type === 'element'
2167 const anchorNode = $getNodeFromDOM(anchorDOM);
2168 const focusNode = $getNodeFromDOM(focusDOM);
2169 // Ensure if we're selecting the content of a decorator that we
2170 // return null for this point, as it's not in the controlled scope
2172 if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
2177 // Handle normalization of selection when it is at the boundaries.
2178 $normalizeSelectionPointsForBoundaries(
2179 resolvedAnchorPoint,
2184 return [resolvedAnchorPoint, resolvedFocusPoint];
2187 export function $isBlockElementNode(
2188 node: LexicalNode | null | undefined,
2189 ): node is ElementNode {
2190 return $isElementNode(node) && !node.isInline();
2193 // This is used to make a selection when the existing
2194 // selection is null, i.e. forcing selection on the editor
2195 // when it current exists outside the editor.
2197 export function $internalMakeRangeSelection(
2199 anchorOffset: number,
2201 focusOffset: number,
2202 anchorType: 'text' | 'element',
2203 focusType: 'text' | 'element',
2205 const editorState = getActiveEditorState();
2206 const selection = new RangeSelection(
2207 $createPoint(anchorKey, anchorOffset, anchorType),
2208 $createPoint(focusKey, focusOffset, focusType),
2212 selection.dirty = true;
2213 editorState._selection = selection;
2217 export function $createRangeSelection(): RangeSelection {
2218 const anchor = $createPoint('root', 0, 'element');
2219 const focus = $createPoint('root', 0, 'element');
2220 return new RangeSelection(anchor, focus, 0, '');
2223 export function $createNodeSelection(): NodeSelection {
2224 return new NodeSelection(new Set());
2227 export function $internalCreateSelection(
2228 editor: LexicalEditor,
2229 ): null | BaseSelection {
2230 const currentEditorState = editor.getEditorState();
2231 const lastSelection = currentEditorState._selection;
2232 const domSelection = getDOMSelection(editor._window);
2234 if ($isRangeSelection(lastSelection) || lastSelection == null) {
2235 return $internalCreateRangeSelection(
2242 return lastSelection.clone();
2245 export function $createRangeSelectionFromDom(
2246 domSelection: Selection | null,
2247 editor: LexicalEditor,
2248 ): null | RangeSelection {
2249 return $internalCreateRangeSelection(null, domSelection, editor, null);
2252 export function $internalCreateRangeSelection(
2253 lastSelection: null | BaseSelection,
2254 domSelection: Selection | null,
2255 editor: LexicalEditor,
2256 event: UIEvent | Event | null,
2257 ): null | RangeSelection {
2258 const windowObj = editor._window;
2259 if (windowObj === null) {
2262 // When we create a selection, we try to use the previous
2263 // selection where possible, unless an actual user selection
2264 // change has occurred. When we do need to create a new selection
2265 // we validate we can have text nodes for both anchor and focus
2266 // nodes. If that holds true, we then return that selection
2267 // as a mutable object that we use for the editor state for this
2268 // update cycle. If a selection gets changed, and requires a
2269 // update to native DOM selection, it gets marked as "dirty".
2270 // If the selection changes, but matches with the existing
2271 // DOM selection, then we only need to sync it. Otherwise,
2272 // we generally bail out of doing an update to selection during
2273 // reconciliation unless there are dirty nodes that need
2276 const windowEvent = event || windowObj.event;
2277 const eventType = windowEvent ? windowEvent.type : undefined;
2278 const isSelectionChange = eventType === 'selectionchange';
2279 const useDOMSelection =
2280 !getIsProcessingMutations() &&
2281 (isSelectionChange ||
2282 eventType === 'beforeinput' ||
2283 eventType === 'compositionstart' ||
2284 eventType === 'compositionend' ||
2285 (eventType === 'click' &&
2287 (windowEvent as InputEvent).detail === 3) ||
2288 eventType === 'drop' ||
2289 eventType === undefined);
2290 let anchorDOM, focusDOM, anchorOffset, focusOffset;
2292 if (!$isRangeSelection(lastSelection) || useDOMSelection) {
2293 if (domSelection === null) {
2296 anchorDOM = domSelection.anchorNode;
2297 focusDOM = domSelection.focusNode;
2298 anchorOffset = domSelection.anchorOffset;
2299 focusOffset = domSelection.focusOffset;
2301 isSelectionChange &&
2302 $isRangeSelection(lastSelection) &&
2303 !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
2305 return lastSelection.clone();
2308 return lastSelection.clone();
2310 // Let's resolve the text nodes from the offsets and DOM nodes we have from
2311 // native selection.
2312 const resolvedSelectionPoints = $internalResolveSelectionPoints(
2320 if (resolvedSelectionPoints === null) {
2323 const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints;
2324 return new RangeSelection(
2325 resolvedAnchorPoint,
2327 !$isRangeSelection(lastSelection) ? 0 : lastSelection.format,
2328 !$isRangeSelection(lastSelection) ? '' : lastSelection.style,
2332 export function $getSelection(): null | BaseSelection {
2333 const editorState = getActiveEditorState();
2334 return editorState._selection;
2337 export function $getPreviousSelection(): null | BaseSelection {
2338 const editor = getActiveEditor();
2339 return editor._editorState._selection;
2342 export function $updateElementSelectionOnCreateDeleteNode(
2343 selection: RangeSelection,
2344 parentNode: LexicalNode,
2348 const anchor = selection.anchor;
2349 const focus = selection.focus;
2350 const anchorNode = anchor.getNode();
2351 const focusNode = focus.getNode();
2352 if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
2355 const parentKey = parentNode.__key;
2356 // Single node. We shift selection but never redimension it
2357 if (selection.isCollapsed()) {
2358 const selectionOffset = anchor.offset;
2360 (nodeOffset <= selectionOffset && times > 0) ||
2361 (nodeOffset < selectionOffset && times < 0)
2363 const newSelectionOffset = Math.max(0, selectionOffset + times);
2364 anchor.set(parentKey, newSelectionOffset, 'element');
2365 focus.set(parentKey, newSelectionOffset, 'element');
2366 // The new selection might point to text nodes, try to resolve them
2367 $updateSelectionResolveTextNodes(selection);
2370 // Multiple nodes selected. We shift or redimension selection
2371 const isBackward = selection.isBackward();
2372 const firstPoint = isBackward ? focus : anchor;
2373 const firstPointNode = firstPoint.getNode();
2374 const lastPoint = isBackward ? anchor : focus;
2375 const lastPointNode = lastPoint.getNode();
2376 if (parentNode.is(firstPointNode)) {
2377 const firstPointOffset = firstPoint.offset;
2379 (nodeOffset <= firstPointOffset && times > 0) ||
2380 (nodeOffset < firstPointOffset && times < 0)
2384 Math.max(0, firstPointOffset + times),
2389 if (parentNode.is(lastPointNode)) {
2390 const lastPointOffset = lastPoint.offset;
2392 (nodeOffset <= lastPointOffset && times > 0) ||
2393 (nodeOffset < lastPointOffset && times < 0)
2397 Math.max(0, lastPointOffset + times),
2403 // The new selection might point to text nodes, try to resolve them
2404 $updateSelectionResolveTextNodes(selection);
2407 function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
2408 const anchor = selection.anchor;
2409 const anchorOffset = anchor.offset;
2410 const focus = selection.focus;
2411 const focusOffset = focus.offset;
2412 const anchorNode = anchor.getNode();
2413 const focusNode = focus.getNode();
2414 if (selection.isCollapsed()) {
2415 if (!$isElementNode(anchorNode)) {
2418 const childSize = anchorNode.getChildrenSize();
2419 const anchorOffsetAtEnd = anchorOffset >= childSize;
2420 const child = anchorOffsetAtEnd
2421 ? anchorNode.getChildAtIndex(childSize - 1)
2422 : anchorNode.getChildAtIndex(anchorOffset);
2423 if ($isTextNode(child)) {
2425 if (anchorOffsetAtEnd) {
2426 newOffset = child.getTextContentSize();
2428 anchor.set(child.__key, newOffset, 'text');
2429 focus.set(child.__key, newOffset, 'text');
2433 if ($isElementNode(anchorNode)) {
2434 const childSize = anchorNode.getChildrenSize();
2435 const anchorOffsetAtEnd = anchorOffset >= childSize;
2436 const child = anchorOffsetAtEnd
2437 ? anchorNode.getChildAtIndex(childSize - 1)
2438 : anchorNode.getChildAtIndex(anchorOffset);
2439 if ($isTextNode(child)) {
2441 if (anchorOffsetAtEnd) {
2442 newOffset = child.getTextContentSize();
2444 anchor.set(child.__key, newOffset, 'text');
2447 if ($isElementNode(focusNode)) {
2448 const childSize = focusNode.getChildrenSize();
2449 const focusOffsetAtEnd = focusOffset >= childSize;
2450 const child = focusOffsetAtEnd
2451 ? focusNode.getChildAtIndex(childSize - 1)
2452 : focusNode.getChildAtIndex(focusOffset);
2453 if ($isTextNode(child)) {
2455 if (focusOffsetAtEnd) {
2456 newOffset = child.getTextContentSize();
2458 focus.set(child.__key, newOffset, 'text');
2463 export function applySelectionTransforms(
2464 nextEditorState: EditorState,
2465 editor: LexicalEditor,
2467 const prevEditorState = editor.getEditorState();
2468 const prevSelection = prevEditorState._selection;
2469 const nextSelection = nextEditorState._selection;
2470 if ($isRangeSelection(nextSelection)) {
2471 const anchor = nextSelection.anchor;
2472 const focus = nextSelection.focus;
2475 if (anchor.type === 'text') {
2476 anchorNode = anchor.getNode();
2477 anchorNode.selectionTransform(prevSelection, nextSelection);
2479 if (focus.type === 'text') {
2480 const focusNode = focus.getNode();
2481 if (anchorNode !== focusNode) {
2482 focusNode.selectionTransform(prevSelection, nextSelection);
2488 export function moveSelectionPointToSibling(
2491 parent: ElementNode,
2492 prevSibling: LexicalNode | null,
2493 nextSibling: LexicalNode | null,
2495 let siblingKey = null;
2497 let type: 'text' | 'element' | null = null;
2498 if (prevSibling !== null) {
2499 siblingKey = prevSibling.__key;
2500 if ($isTextNode(prevSibling)) {
2501 offset = prevSibling.getTextContentSize();
2503 } else if ($isElementNode(prevSibling)) {
2504 offset = prevSibling.getChildrenSize();
2508 if (nextSibling !== null) {
2509 siblingKey = nextSibling.__key;
2510 if ($isTextNode(nextSibling)) {
2512 } else if ($isElementNode(nextSibling)) {
2517 if (siblingKey !== null && type !== null) {
2518 point.set(siblingKey, offset, type);
2520 offset = node.getIndexWithinParent();
2521 if (offset === -1) {
2522 // Move selection to end of parent
2523 offset = parent.getChildrenSize();
2525 point.set(parent.__key, offset, 'element');
2529 export function adjustPointOffsetForMergedSibling(
2536 if (point.type === 'text') {
2539 point.offset += textLength;
2541 } else if (point.offset > target.getIndexWithinParent()) {
2546 export function updateDOMSelection(
2547 prevSelection: BaseSelection | null,
2548 nextSelection: BaseSelection | null,
2549 editor: LexicalEditor,
2550 domSelection: Selection,
2552 rootElement: HTMLElement,
2555 const anchorDOMNode = domSelection.anchorNode;
2556 const focusDOMNode = domSelection.focusNode;
2557 const anchorOffset = domSelection.anchorOffset;
2558 const focusOffset = domSelection.focusOffset;
2559 const activeElement = document.activeElement;
2561 // TODO: make this not hard-coded, and add another config option
2562 // that makes this configurable.
2564 (tags.has('collaboration') && activeElement !== rootElement) ||
2565 (activeElement !== null &&
2566 isSelectionCapturedInDecoratorInput(activeElement))
2571 if (!$isRangeSelection(nextSelection)) {
2573 // If the DOM selection enters a decorator node update the selection to a single node selection
2574 if (activeElement !== null && domSelection.isCollapsed && focusDOMNode instanceof Node) {
2575 const node = $getNearestNodeFromDOMNode(focusDOMNode);
2576 if ($isDecoratorNode(node)) {
2577 domSelection.removeAllRanges();
2578 $selectSingleNode(node);
2583 // We don't remove selection if the prevSelection is null because
2584 // of editor.setRootElement(). If this occurs on init when the
2585 // editor is already focused, then this can cause the editor to
2588 prevSelection !== null &&
2589 isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode)
2591 domSelection.removeAllRanges();
2597 const anchor = nextSelection.anchor;
2598 const focus = nextSelection.focus;
2599 const anchorKey = anchor.key;
2600 const focusKey = focus.key;
2601 const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
2602 const focusDOM = getElementByKeyOrThrow(editor, focusKey);
2603 const nextAnchorOffset = anchor.offset;
2604 const nextFocusOffset = focus.offset;
2605 const nextFormat = nextSelection.format;
2606 const nextStyle = nextSelection.style;
2607 const isCollapsed = nextSelection.isCollapsed();
2608 let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
2609 let nextFocusNode: HTMLElement | Text | null = focusDOM;
2610 let anchorFormatOrStyleChanged = false;
2612 if (anchor.type === 'text') {
2613 nextAnchorNode = getDOMTextNode(anchorDOM);
2614 const anchorNode = anchor.getNode();
2615 anchorFormatOrStyleChanged =
2616 anchorNode.getFormat() !== nextFormat ||
2617 anchorNode.getStyle() !== nextStyle;
2619 $isRangeSelection(prevSelection) &&
2620 prevSelection.anchor.type === 'text'
2622 anchorFormatOrStyleChanged = true;
2625 if (focus.type === 'text') {
2626 nextFocusNode = getDOMTextNode(focusDOM);
2629 // If we can't get an underlying text node for selection, then
2630 // we should avoid setting selection to something incorrect.
2631 if (nextAnchorNode === null || nextFocusNode === null) {
2637 (prevSelection === null ||
2638 anchorFormatOrStyleChanged ||
2639 ($isRangeSelection(prevSelection) &&
2640 (prevSelection.format !== nextFormat ||
2641 prevSelection.style !== nextStyle)))
2643 markCollapsedSelectionFormat(
2652 // Diff against the native DOM selection to ensure we don't do
2653 // an unnecessary selection update. We also skip this check if
2654 // we're moving selection to within an element, as this can
2655 // sometimes be problematic around scrolling.
2657 anchorOffset === nextAnchorOffset &&
2658 focusOffset === nextFocusOffset &&
2659 anchorDOMNode === nextAnchorNode &&
2660 focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482
2661 !(domSelection.type === 'Range' && isCollapsed)
2663 // If the root element does not have focus, ensure it has focus
2664 if (activeElement === null || !rootElement.contains(activeElement)) {
2666 preventScroll: true,
2669 if (anchor.type !== 'element') {
2674 // Apply the updated selection to the DOM. Note: this will trigger
2675 // a "selectionchange" event, although it will be asynchronous.
2677 domSelection.setBaseAndExtent(
2684 // If we encounter an error, continue. This can sometimes
2685 // occur with FF and there's no good reason as to why it
2688 console.warn(error);
2692 !tags.has('skip-scroll-into-view') &&
2693 nextSelection.isCollapsed() &&
2694 rootElement !== null &&
2695 rootElement === document.activeElement
2697 const selectionTarget: null | Range | HTMLElement | Text =
2698 nextSelection instanceof RangeSelection &&
2699 nextSelection.anchor.type === 'element'
2700 ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
2702 : domSelection.rangeCount > 0
2703 ? domSelection.getRangeAt(0)
2705 if (selectionTarget !== null) {
2706 let selectionRect: DOMRect;
2707 if (selectionTarget instanceof Text) {
2708 const range = document.createRange();
2709 range.selectNode(selectionTarget);
2710 selectionRect = range.getBoundingClientRect();
2712 selectionRect = selectionTarget.getBoundingClientRect();
2714 scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
2718 markSelectionChangeFromDOMUpdate();
2721 export function $insertNodes(nodes: Array<LexicalNode>) {
2722 let selection = $getSelection() || $getPreviousSelection();
2724 if (selection === null) {
2725 selection = $getRoot().selectEnd();
2727 selection.insertNodes(nodes);
2730 export function $getTextContent(): string {
2731 const selection = $getSelection();
2732 if (selection === null) {
2735 return selection.getTextContent();
2738 function $removeTextAndSplitBlock(selection: RangeSelection): number {
2739 let selection_ = selection;
2740 if (!selection.isCollapsed()) {
2741 selection_.removeText();
2743 // A new selection can originate as a result of node replacement, in which case is registered via
2745 const newSelection = $getSelection();
2746 if ($isRangeSelection(newSelection)) {
2747 selection_ = newSelection;
2751 $isRangeSelection(selection_),
2752 'Unexpected dirty selection to be null',
2755 const anchor = selection_.anchor;
2756 let node = anchor.getNode();
2757 let offset = anchor.offset;
2759 while (!INTERNAL_$isBlock(node)) {
2760 [node, offset] = $splitNodeAtPoint(node, offset);
2766 function $splitNodeAtPoint(
2769 ): [parent: ElementNode, offset: number] {
2770 const parent = node.getParent();
2772 const paragraph = $createParagraphNode();
2773 $getRoot().append(paragraph);
2775 return [$getRoot(), 0];
2778 if ($isTextNode(node)) {
2779 const split = node.splitText(offset);
2780 if (split.length === 0) {
2781 return [parent, node.getIndexWithinParent()];
2783 const x = offset === 0 ? 0 : 1;
2784 const index = split[0].getIndexWithinParent() + x;
2786 return [parent, index];
2789 if (!$isElementNode(node) || offset === 0) {
2790 return [parent, node.getIndexWithinParent()];
2793 const firstToAppend = node.getChildAtIndex(offset);
2794 if (firstToAppend) {
2795 const insertPoint = new RangeSelection(
2796 $createPoint(node.__key, offset, 'element'),
2797 $createPoint(node.__key, offset, 'element'),
2801 const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
2803 newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
2806 return [parent, node.getIndexWithinParent() + 1];
2809 function $wrapInlineNodes(nodes: LexicalNode[]) {
2810 // We temporarily insert the topLevelNodes into an arbitrary ElementNode,
2811 // since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
2812 const virtualRoot = $createParagraphNode();
2814 let currentBlock = null;
2815 for (let i = 0; i < nodes.length; i++) {
2816 const node = nodes[i];
2818 const isLineBreakNode = $isLineBreakNode(node);
2822 ($isDecoratorNode(node) && node.isInline()) ||
2823 ($isElementNode(node) && node.isInline()) ||
2824 $isTextNode(node) ||
2825 node.isParentRequired()
2827 if (currentBlock === null) {
2828 currentBlock = node.createParentElementNode();
2829 virtualRoot.append(currentBlock);
2830 // In the case of LineBreakNode, we just need to
2831 // add an empty ParagraphNode to the topLevelBlocks.
2832 if (isLineBreakNode) {
2837 if (currentBlock !== null) {
2838 currentBlock.append(node);
2841 virtualRoot.append(node);
2842 currentBlock = null;