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';
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';
67 export type TextPointType = {
68 _selection: BaseSelection;
69 getNode: () => TextNode;
70 is: (point: PointType) => boolean;
71 isBefore: (point: PointType) => boolean;
74 set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
78 export type ElementPointType = {
79 _selection: BaseSelection;
80 getNode: () => ElementNode;
81 is: (point: PointType) => boolean;
82 isBefore: (point: PointType) => boolean;
85 set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
89 export type PointType = TextPointType | ElementPointType;
94 type: 'text' | 'element';
95 _selection: BaseSelection | null;
97 constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
98 this._selection = null;
100 this.offset = offset;
104 is(point: PointType): boolean {
106 this.key === point.key &&
107 this.offset === point.offset &&
108 this.type === point.type
112 isBefore(b: PointType): boolean {
113 let aNode = this.getNode();
114 let bNode = b.getNode();
115 const aOffset = this.offset;
116 const bOffset = b.offset;
118 if ($isElementNode(aNode)) {
119 const aNodeDescendant = aNode.getDescendantByIndex<ElementNode>(aOffset);
120 aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
122 if ($isElementNode(bNode)) {
123 const bNodeDescendant = bNode.getDescendantByIndex<ElementNode>(bOffset);
124 bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
126 if (aNode === bNode) {
127 return aOffset < bOffset;
129 return aNode.isBefore(bNode);
132 getNode(): LexicalNode {
133 const key = this.key;
134 const node = $getNodeByKey(key);
136 invariant(false, 'Point.getNode: node not found');
141 set(key: NodeKey, offset: number, type: 'text' | 'element'): void {
142 const selection = this._selection;
143 const oldKey = this.key;
145 this.offset = offset;
147 if (!isCurrentlyReadOnlyMode()) {
148 if ($getCompositionKey() === oldKey) {
149 $setCompositionKey(key);
151 if (selection !== null) {
152 selection.setCachedNodes(null);
153 selection.dirty = true;
159 export function $createPoint(
162 type: 'text' | 'element',
164 // @ts-expect-error: intentionally cast as we use a class for perf reasons
165 return new Point(key, offset, type);
168 function selectPointOnNode(point: PointType, node: LexicalNode): void {
169 let key = node.__key;
170 let offset = point.offset;
171 let type: 'element' | 'text' = 'element';
172 if ($isTextNode(node)) {
174 const textContentLength = node.getTextContentSize();
175 if (offset > textContentLength) {
176 offset = textContentLength;
178 } else if (!$isElementNode(node)) {
179 const nextSibling = node.getNextSibling();
180 if ($isTextNode(nextSibling)) {
181 key = nextSibling.__key;
185 const parentNode = node.getParent();
187 key = parentNode.__key;
188 offset = node.getIndexWithinParent() + 1;
192 point.set(key, offset, type);
195 export function $moveSelectionPointToEnd(
199 if ($isElementNode(node)) {
200 const lastNode = node.getLastDescendant();
201 if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
202 selectPointOnNode(point, lastNode);
204 selectPointOnNode(point, node);
207 selectPointOnNode(point, node);
211 function $transferStartingElementPointToTextPoint(
212 start: ElementPointType,
217 const element = start.getNode();
218 const placementNode = element.getChildAtIndex(start.offset);
219 const textNode = $createTextNode();
220 const target = $isRootNode(element)
221 ? $createParagraphNode().append(textNode)
223 textNode.setFormat(format);
224 textNode.setStyle(style);
225 if (placementNode === null) {
226 element.append(target);
228 placementNode.insertBefore(target);
230 // Transfer the element point to a text point.
232 end.set(textNode.__key, 0, 'text');
234 start.set(textNode.__key, 0, 'text');
237 function $setPointValues(
241 type: 'text' | 'element',
244 point.offset = offset;
248 export interface BaseSelection {
249 _cachedNodes: Array<LexicalNode> | null;
252 clone(): BaseSelection;
253 extract(): Array<LexicalNode>;
254 getNodes(): Array<LexicalNode>;
255 getTextContent(): string;
256 insertText(text: string): void;
257 insertRawText(text: string): void;
258 is(selection: null | BaseSelection): boolean;
259 insertNodes(nodes: Array<LexicalNode>): void;
260 getStartEndPoints(): null | [PointType, PointType];
261 isCollapsed(): boolean;
262 isBackward(): boolean;
263 getCachedNodes(): LexicalNode[] | null;
264 setCachedNodes(nodes: LexicalNode[] | null): void;
267 export class NodeSelection implements BaseSelection {
268 _nodes: Set<NodeKey>;
269 _cachedNodes: Array<LexicalNode> | null;
272 constructor(objects: Set<NodeKey>) {
273 this._cachedNodes = null;
274 this._nodes = objects;
278 getCachedNodes(): LexicalNode[] | null {
279 return this._cachedNodes;
282 setCachedNodes(nodes: LexicalNode[] | null): void {
283 this._cachedNodes = nodes;
286 is(selection: null | BaseSelection): boolean {
287 if (!$isNodeSelection(selection)) {
290 const a: Set<NodeKey> = this._nodes;
291 const b: Set<NodeKey> = selection._nodes;
292 return a.size === b.size && Array.from(a).every((key) => b.has(key));
295 isCollapsed(): boolean {
299 isBackward(): boolean {
303 getStartEndPoints(): null {
307 add(key: NodeKey): void {
309 this._nodes.add(key);
310 this._cachedNodes = null;
313 delete(key: NodeKey): void {
315 this._nodes.delete(key);
316 this._cachedNodes = null;
322 this._cachedNodes = null;
325 has(key: NodeKey): boolean {
326 return this._nodes.has(key);
329 clone(): NodeSelection {
330 return new NodeSelection(new Set(this._nodes));
333 extract(): Array<LexicalNode> {
334 return this.getNodes();
337 insertRawText(text: string): void {
345 insertNodes(nodes: Array<LexicalNode>) {
346 const selectedNodes = this.getNodes();
347 const selectedNodesLength = selectedNodes.length;
348 const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
349 let selectionAtEnd: RangeSelection;
351 if ($isTextNode(lastSelectedNode)) {
352 selectionAtEnd = lastSelectedNode.select();
354 const index = lastSelectedNode.getIndexWithinParent() + 1;
355 selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
357 selectionAtEnd.insertNodes(nodes);
358 // Remove selected nodes
359 for (let i = 0; i < selectedNodesLength; i++) {
360 selectedNodes[i].remove();
364 getNodes(): Array<LexicalNode> {
365 const cachedNodes = this._cachedNodes;
366 if (cachedNodes !== null) {
369 const objects = this._nodes;
371 for (const object of objects) {
372 const node = $getNodeByKey(object);
377 if (!isCurrentlyReadOnlyMode()) {
378 this._cachedNodes = nodes;
383 getTextContent(): string {
384 const nodes = this.getNodes();
385 let textContent = '';
386 for (let i = 0; i < nodes.length; i++) {
387 textContent += nodes[i].getTextContent();
393 export function $isRangeSelection(x: unknown): x is RangeSelection {
394 return x instanceof RangeSelection;
397 export class RangeSelection implements BaseSelection {
402 _cachedNodes: Array<LexicalNode> | null;
411 this.anchor = anchor;
413 anchor._selection = this;
414 focus._selection = this;
415 this._cachedNodes = null;
416 this.format = format;
421 getCachedNodes(): LexicalNode[] | null {
422 return this._cachedNodes;
425 setCachedNodes(nodes: LexicalNode[] | null): void {
426 this._cachedNodes = nodes;
430 * Used to check if the provided selections is equal to this one by value,
431 * inluding anchor, focus, format, and style properties.
432 * @param selection - the Selection to compare this one to.
433 * @returns true if the Selections are equal, false otherwise.
435 is(selection: null | BaseSelection): boolean {
436 if (!$isRangeSelection(selection)) {
440 this.anchor.is(selection.anchor) &&
441 this.focus.is(selection.focus) &&
442 this.format === selection.format &&
443 this.style === selection.style
448 * Returns whether the Selection is "collapsed", meaning the anchor and focus are
449 * the same node and have the same offset.
451 * @returns true if the Selection is collapsed, false otherwise.
453 isCollapsed(): boolean {
454 return this.anchor.is(this.focus);
458 * Gets all the nodes in the Selection. Uses caching to make it generally suitable
459 * for use in hot paths.
461 * @returns an Array containing all the nodes in the Selection
463 getNodes(): Array<LexicalNode> {
464 const cachedNodes = this._cachedNodes;
465 if (cachedNodes !== null) {
468 const anchor = this.anchor;
469 const focus = this.focus;
470 const isBefore = anchor.isBefore(focus);
471 const firstPoint = isBefore ? anchor : focus;
472 const lastPoint = isBefore ? focus : anchor;
473 let firstNode = firstPoint.getNode();
474 let lastNode = lastPoint.getNode();
475 const startOffset = firstPoint.offset;
476 const endOffset = lastPoint.offset;
478 if ($isElementNode(firstNode)) {
479 const firstNodeDescendant =
480 firstNode.getDescendantByIndex<ElementNode>(startOffset);
481 firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
483 if ($isElementNode(lastNode)) {
484 let lastNodeDescendant =
485 lastNode.getDescendantByIndex<ElementNode>(endOffset);
486 // We don't want to over-select, as node selection infers the child before
487 // the last descendant, not including that descendant.
489 lastNodeDescendant !== null &&
490 lastNodeDescendant !== firstNode &&
491 lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
493 lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
495 lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
498 let nodes: Array<LexicalNode>;
500 if (firstNode.is(lastNode)) {
501 if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
507 nodes = firstNode.getNodesBetween(lastNode);
509 if (!isCurrentlyReadOnlyMode()) {
510 this._cachedNodes = nodes;
516 * Sets this Selection to be of type "text" at the provided anchor and focus values.
518 * @param anchorNode - the anchor node to set on the Selection
519 * @param anchorOffset - the offset to set on the Selection
520 * @param focusNode - the focus node to set on the Selection
521 * @param focusOffset - the focus offset to set on the Selection
524 anchorNode: TextNode,
525 anchorOffset: number,
529 $setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
530 $setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
531 this._cachedNodes = null;
536 * Gets the (plain) text content of all the nodes in the selection.
538 * @returns a string representing the text content of all the nodes in the Selection
540 getTextContent(): string {
541 const nodes = this.getNodes();
542 if (nodes.length === 0) {
545 const firstNode = nodes[0];
546 const lastNode = nodes[nodes.length - 1];
547 const anchor = this.anchor;
548 const focus = this.focus;
549 const isBefore = anchor.isBefore(focus);
550 const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
551 let textContent = '';
552 let prevWasElement = true;
553 for (let i = 0; i < nodes.length; i++) {
554 const node = nodes[i];
555 if ($isElementNode(node) && !node.isInline()) {
556 if (!prevWasElement) {
559 if (node.isEmpty()) {
560 prevWasElement = false;
562 prevWasElement = true;
565 prevWasElement = false;
566 if ($isTextNode(node)) {
567 let text = node.getTextContent();
568 if (node === firstNode) {
569 if (node === lastNode) {
571 anchor.type !== 'element' ||
572 focus.type !== 'element' ||
573 focus.offset === anchor.offset
576 anchorOffset < focusOffset
577 ? text.slice(anchorOffset, focusOffset)
578 : text.slice(focusOffset, anchorOffset);
582 ? text.slice(anchorOffset)
583 : text.slice(focusOffset);
585 } else if (node === lastNode) {
587 ? text.slice(0, focusOffset)
588 : text.slice(0, anchorOffset);
592 ($isDecoratorNode(node) || $isLineBreakNode(node)) &&
593 (node !== lastNode || !this.isCollapsed())
595 textContent += node.getTextContent();
603 * Attempts to map a DOM selection range onto this Lexical Selection,
604 * setting the anchor, focus, and type accordingly
606 * @param range a DOM Selection range conforming to the StaticRange interface.
608 applyDOMRange(range: StaticRange): void {
609 const editor = getActiveEditor();
610 const currentEditorState = editor.getEditorState();
611 const lastSelection = currentEditorState._selection;
612 const resolvedSelectionPoints = $internalResolveSelectionPoints(
613 range.startContainer,
620 if (resolvedSelectionPoints === null) {
623 const [anchorPoint, focusPoint] = resolvedSelectionPoints;
636 this._cachedNodes = null;
640 * Creates a new RangeSelection, copying over all the property values from this one.
642 * @returns a new RangeSelection with the same property values as this one.
644 clone(): RangeSelection {
645 const anchor = this.anchor;
646 const focus = this.focus;
647 const selection = new RangeSelection(
648 $createPoint(anchor.key, anchor.offset, anchor.type),
649 $createPoint(focus.key, focus.offset, focus.type),
657 * Toggles the provided format on all the TextNodes in the Selection.
659 * @param format a string TextFormatType to toggle on the TextNodes in the selection
661 toggleFormat(format: TextFormatType): void {
662 this.format = toggleTextFormatType(this.format, format, null);
667 * Sets the value of the style property on the Selection
669 * @param style - the style to set at the value of the style property.
671 setStyle(style: string): void {
677 * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
678 * has the specified format.
680 * @param type the TextFormatType to check for.
681 * @returns true if the provided format is currently toggled on on the Selection, false otherwise.
683 hasFormat(type: TextFormatType): boolean {
684 const formatFlag = TEXT_TYPE_TO_FORMAT[type];
685 return (this.format & formatFlag) !== 0;
689 * Attempts to insert the provided text into the EditorState at the current Selection.
690 * converts tabs, newlines, and carriage returns into LexicalNodes.
692 * @param text the text to insert into the Selection
694 insertRawText(text: string): void {
695 const parts = text.split(/(\r?\n|\t)/);
697 const length = parts.length;
698 for (let i = 0; i < length; i++) {
699 const part = parts[i];
700 if (part === '\n' || part === '\r\n') {
701 nodes.push($createLineBreakNode());
702 } else if (part === '\t') {
703 nodes.push($createTabNode());
705 nodes.push($createTextNode(part));
708 this.insertNodes(nodes);
712 * Attempts to insert the provided text into the EditorState at the current Selection as a new
713 * Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
715 * @param text the text to insert into the Selection
717 insertText(text: string): void {
718 const anchor = this.anchor;
719 const focus = this.focus;
720 const format = this.format;
721 const style = this.style;
722 let firstPoint = anchor;
723 let endPoint = focus;
724 if (!this.isCollapsed() && focus.isBefore(anchor)) {
728 if (firstPoint.type === 'element') {
729 $transferStartingElementPointToTextPoint(
736 const startOffset = firstPoint.offset;
737 let endOffset = endPoint.offset;
738 const selectedNodes = this.getNodes();
739 const selectedNodesLength = selectedNodes.length;
740 let firstNode: TextNode = selectedNodes[0] as TextNode;
742 if (!$isTextNode(firstNode)) {
743 invariant(false, 'insertText: first node is not a text node');
745 const firstNodeText = firstNode.getTextContent();
746 const firstNodeTextLength = firstNodeText.length;
747 const firstNodeParent = firstNode.getParentOrThrow();
748 const lastIndex = selectedNodesLength - 1;
749 let lastNode = selectedNodes[lastIndex];
751 if (selectedNodesLength === 1 && endPoint.type === 'element') {
752 endOffset = firstNodeTextLength;
753 endPoint.set(firstPoint.key, endOffset, 'text');
757 this.isCollapsed() &&
758 startOffset === firstNodeTextLength &&
759 (firstNode.isSegmented() ||
760 firstNode.isToken() ||
761 !firstNode.canInsertTextAfter() ||
762 (!firstNodeParent.canInsertTextAfter() &&
763 firstNode.getNextSibling() === null))
765 let nextSibling = firstNode.getNextSibling<TextNode>();
767 !$isTextNode(nextSibling) ||
768 !nextSibling.canInsertTextBefore() ||
769 $isTokenOrSegmented(nextSibling)
771 nextSibling = $createTextNode();
772 nextSibling.setFormat(format);
773 nextSibling.setStyle(style);
774 if (!firstNodeParent.canInsertTextAfter()) {
775 firstNodeParent.insertAfter(nextSibling);
777 firstNode.insertAfter(nextSibling);
780 nextSibling.select(0, 0);
781 firstNode = nextSibling;
783 this.insertText(text);
787 this.isCollapsed() &&
789 (firstNode.isSegmented() ||
790 firstNode.isToken() ||
791 !firstNode.canInsertTextBefore() ||
792 (!firstNodeParent.canInsertTextBefore() &&
793 firstNode.getPreviousSibling() === null))
795 let prevSibling = firstNode.getPreviousSibling<TextNode>();
796 if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
797 prevSibling = $createTextNode();
798 prevSibling.setFormat(format);
799 if (!firstNodeParent.canInsertTextBefore()) {
800 firstNodeParent.insertBefore(prevSibling);
802 firstNode.insertBefore(prevSibling);
805 prevSibling.select();
806 firstNode = prevSibling;
808 this.insertText(text);
811 } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
812 const textNode = $createTextNode(firstNode.getTextContent());
813 textNode.setFormat(format);
814 firstNode.replace(textNode);
815 firstNode = textNode;
816 } else if (!this.isCollapsed() && text !== '') {
817 // When the firstNode or lastNode parents are elements that
818 // do not allow text to be inserted before or after, we first
819 // clear the content. Then we normalize selection, then insert
821 const lastNodeParent = lastNode.getParent();
824 !firstNodeParent.canInsertTextBefore() ||
825 !firstNodeParent.canInsertTextAfter() ||
826 ($isElementNode(lastNodeParent) &&
827 (!lastNodeParent.canInsertTextBefore() ||
828 !lastNodeParent.canInsertTextAfter()))
831 $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
832 this.insertText(text);
837 if (selectedNodesLength === 1) {
838 if (firstNode.isToken()) {
839 const textNode = $createTextNode(text);
841 firstNode.replace(textNode);
844 const firstNodeFormat = firstNode.getFormat();
845 const firstNodeStyle = firstNode.getStyle();
848 startOffset === endOffset &&
849 (firstNodeFormat !== format || firstNodeStyle !== style)
851 if (firstNode.getTextContent() === '') {
852 firstNode.setFormat(format);
853 firstNode.setStyle(style);
855 const textNode = $createTextNode(text);
856 textNode.setFormat(format);
857 textNode.setStyle(style);
859 if (startOffset === 0) {
860 firstNode.insertBefore(textNode, false);
862 const [targetNode] = firstNode.splitText(startOffset);
863 targetNode.insertAfter(textNode, false);
865 // When composing, we need to adjust the anchor offset so that
866 // we correctly replace that right range.
867 if (textNode.isComposing() && this.anchor.type === 'text') {
868 this.anchor.offset -= text.length;
872 } else if ($isTabNode(firstNode)) {
873 // We don't need to check for delCount because there is only the entire selected node case
874 // that can hit here for content size 1 and with canInsertTextBeforeAfter false
875 const textNode = $createTextNode(text);
876 textNode.setFormat(format);
877 textNode.setStyle(style);
879 firstNode.replace(textNode);
882 const delCount = endOffset - startOffset;
884 firstNode = firstNode.spliceText(startOffset, delCount, text, true);
885 if (firstNode.getTextContent() === '') {
887 } else if (this.anchor.type === 'text') {
888 if (firstNode.isComposing()) {
889 // When composing, we need to adjust the anchor offset so that
890 // we correctly replace that right range.
891 this.anchor.offset -= text.length;
893 this.format = firstNodeFormat;
894 this.style = firstNodeStyle;
898 const markedNodeKeysForKeep = new Set([
899 ...firstNode.getParentKeys(),
900 ...lastNode.getParentKeys(),
903 // We have to get the parent elements before the next section,
904 // as in that section we might mutate the lastNode.
905 const firstElement = $isElementNode(firstNode)
907 : firstNode.getParentOrThrow();
908 let lastElement = $isElementNode(lastNode)
910 : lastNode.getParentOrThrow();
911 let lastElementChild = lastNode;
913 // If the last element is inline, we should instead look at getting
914 // the nodes of its parent, rather than itself. This behavior will
915 // then better match how text node insertions work. We will need to
916 // also update the last element's child accordingly as we do this.
917 if (!firstElement.is(lastElement) && lastElement.isInline()) {
918 // Keep traversing till we have a non-inline element parent.
920 lastElementChild = lastElement;
921 lastElement = lastElement.getParentOrThrow();
922 } while (lastElement.isInline());
925 // Handle mutations to the last node.
927 (endPoint.type === 'text' &&
928 (endOffset !== 0 || lastNode.getTextContent() === '')) ||
929 (endPoint.type === 'element' &&
930 lastNode.getIndexWithinParent() < endOffset)
933 $isTextNode(lastNode) &&
934 !lastNode.isToken() &&
935 endOffset !== lastNode.getTextContentSize()
937 if (lastNode.isSegmented()) {
938 const textNode = $createTextNode(lastNode.getTextContent());
939 lastNode.replace(textNode);
942 // root node selections only select whole nodes, so no text splice is necessary
943 if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
944 lastNode = (lastNode as TextNode).spliceText(0, endOffset, '');
946 markedNodeKeysForKeep.add(lastNode.__key);
948 const lastNodeParent = lastNode.getParentOrThrow();
950 !lastNodeParent.canBeEmpty() &&
951 lastNodeParent.getChildrenSize() === 1
953 lastNodeParent.remove();
959 markedNodeKeysForKeep.add(lastNode.__key);
962 // Either move the remaining nodes of the last parent to after
963 // the first child, or remove them entirely. If the last parent
964 // is the same as the first parent, this logic also works.
965 const lastNodeChildren = lastElement.getChildren();
966 const selectedNodesSet = new Set(selectedNodes);
967 const firstAndLastElementsAreEqual = firstElement.is(lastElement);
969 // We choose a target to insert all nodes after. In the case of having
970 // and inline starting parent element with a starting node that has no
971 // siblings, we should insert after the starting parent element, otherwise
972 // we will incorrectly merge into the starting parent element.
973 // TODO: should we keep on traversing parents if we're inside another
974 // nested inline element?
975 const insertionTarget =
976 firstElement.isInline() && firstNode.getNextSibling() === null
980 for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
981 const lastNodeChild = lastNodeChildren[i];
984 lastNodeChild.is(firstNode) ||
985 ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
990 if (lastNodeChild.isAttached()) {
992 !selectedNodesSet.has(lastNodeChild) ||
993 lastNodeChild.is(lastElementChild)
995 if (!firstAndLastElementsAreEqual) {
996 insertionTarget.insertAfter(lastNodeChild, false);
999 lastNodeChild.remove();
1004 if (!firstAndLastElementsAreEqual) {
1005 // Check if we have already moved out all the nodes of the
1006 // last parent, and if so, traverse the parent tree and mark
1007 // them all as being able to deleted too.
1008 let parent: ElementNode | null = lastElement;
1009 let lastRemovedParent = null;
1011 while (parent !== null) {
1012 const children = parent.getChildren();
1013 const childrenLength = children.length;
1015 childrenLength === 0 ||
1016 children[childrenLength - 1].is(lastRemovedParent)
1018 markedNodeKeysForKeep.delete(parent.__key);
1019 lastRemovedParent = parent;
1021 parent = parent.getParent();
1025 // Ensure we do splicing after moving of nodes, as splicing
1026 // can have side-effects (in the case of hashtags).
1027 if (!firstNode.isToken()) {
1028 firstNode = firstNode.spliceText(
1030 firstNodeTextLength - startOffset,
1034 if (firstNode.getTextContent() === '') {
1036 } else if (firstNode.isComposing() && this.anchor.type === 'text') {
1037 // When composing, we need to adjust the anchor offset so that
1038 // we correctly replace that right range.
1039 this.anchor.offset -= text.length;
1041 } else if (startOffset === firstNodeTextLength) {
1044 const textNode = $createTextNode(text);
1046 firstNode.replace(textNode);
1049 // Remove all selected nodes that haven't already been removed.
1050 for (let i = 1; i < selectedNodesLength; i++) {
1051 const selectedNode = selectedNodes[i];
1052 const key = selectedNode.__key;
1053 if (!markedNodeKeysForKeep.has(key)) {
1054 selectedNode.remove();
1061 * Removes the text in the Selection, adjusting the EditorState accordingly.
1063 removeText(): void {
1064 this.insertText('');
1068 * Applies the provided format to the TextNodes in the Selection, splitting or
1069 * merging nodes as necessary.
1071 * @param formatType the format type to apply to the nodes in the Selection.
1073 formatText(formatType: TextFormatType): void {
1074 if (this.isCollapsed()) {
1075 this.toggleFormat(formatType);
1076 // When changing format, we should stop composition
1077 $setCompositionKey(null);
1081 const selectedNodes = this.getNodes();
1082 const selectedTextNodes: Array<TextNode> = [];
1083 for (const selectedNode of selectedNodes) {
1084 if ($isTextNode(selectedNode)) {
1085 selectedTextNodes.push(selectedNode);
1089 const selectedTextNodesLength = selectedTextNodes.length;
1090 if (selectedTextNodesLength === 0) {
1091 this.toggleFormat(formatType);
1092 // When changing format, we should stop composition
1093 $setCompositionKey(null);
1097 const anchor = this.anchor;
1098 const focus = this.focus;
1099 const isBackward = this.isBackward();
1100 const startPoint = isBackward ? focus : anchor;
1101 const endPoint = isBackward ? anchor : focus;
1104 let firstNode = selectedTextNodes[0];
1105 let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
1107 // In case selection started at the end of text node use next text node
1109 startPoint.type === 'text' &&
1110 startOffset === firstNode.getTextContentSize()
1113 firstNode = selectedTextNodes[1];
1117 if (firstNode == null) {
1121 const firstNextFormat = firstNode.getFormatFlags(formatType, null);
1123 const lastIndex = selectedTextNodesLength - 1;
1124 let lastNode = selectedTextNodes[lastIndex];
1126 endPoint.type === 'text'
1128 : lastNode.getTextContentSize();
1130 // Single node selected
1131 if (firstNode.is(lastNode)) {
1132 // No actual text is selected, so do nothing.
1133 if (startOffset === endOffset) {
1136 // The entire node is selected or it is token, so just format it
1138 $isTokenOrSegmented(firstNode) ||
1139 (startOffset === 0 && endOffset === firstNode.getTextContentSize())
1141 firstNode.setFormat(firstNextFormat);
1143 // Node is partially selected, so split it into two nodes
1144 // add style the selected one.
1145 const splitNodes = firstNode.splitText(startOffset, endOffset);
1146 const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
1147 replacement.setFormat(firstNextFormat);
1149 // Update selection only if starts/ends on text node
1150 if (startPoint.type === 'text') {
1151 startPoint.set(replacement.__key, 0, 'text');
1153 if (endPoint.type === 'text') {
1154 endPoint.set(replacement.__key, endOffset - startOffset, 'text');
1158 this.format = firstNextFormat;
1162 // Multiple nodes selected
1163 // The entire first node isn't selected, so split it
1164 if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
1165 [, firstNode as TextNode] = firstNode.splitText(startOffset);
1168 firstNode.setFormat(firstNextFormat);
1170 const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
1171 // If the offset is 0, it means no actual characters are selected,
1172 // so we skip formatting the last node altogether.
1173 if (endOffset > 0) {
1175 endOffset !== lastNode.getTextContentSize() &&
1176 !$isTokenOrSegmented(lastNode)
1178 [lastNode as TextNode] = lastNode.splitText(endOffset);
1180 lastNode.setFormat(lastNextFormat);
1183 // Process all text nodes in between
1184 for (let i = firstIndex + 1; i < lastIndex; i++) {
1185 const textNode = selectedTextNodes[i];
1186 const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
1187 textNode.setFormat(nextFormat);
1190 // Update selection only if starts/ends on text node
1191 if (startPoint.type === 'text') {
1192 startPoint.set(firstNode.__key, startOffset, 'text');
1194 if (endPoint.type === 'text') {
1195 endPoint.set(lastNode.__key, endOffset, 'text');
1198 this.format = firstNextFormat | lastNextFormat;
1202 * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
1203 * current Selection according to a set of heuristics that determine how surrounding nodes
1204 * should be changed, replaced, or moved to accomodate the incoming ones.
1206 * @param nodes - the nodes to insert
1208 insertNodes(nodes: Array<LexicalNode>): void {
1209 if (nodes.length === 0) {
1212 if (this.anchor.key === 'root') {
1213 this.insertParagraph();
1214 const selection = $getSelection();
1216 $isRangeSelection(selection),
1217 'Expected RangeSelection after insertParagraph',
1219 return selection.insertNodes(nodes);
1222 const firstPoint = this.isBackward() ? this.focus : this.anchor;
1223 const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!;
1225 const last = nodes[nodes.length - 1]!;
1227 // CASE 1: insert inside a code block
1228 if ('__language' in firstBlock && $isElementNode(firstBlock)) {
1229 if ('__language' in nodes[0]) {
1230 this.insertText(nodes[0].getTextContent());
1232 const index = $removeTextAndSplitBlock(this);
1233 firstBlock.splice(index, 0, nodes);
1239 // CASE 2: All elements of the array are inline
1240 const notInline = (node: LexicalNode) =>
1241 ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
1243 if (!nodes.some(notInline)) {
1245 $isElementNode(firstBlock),
1246 "Expected 'firstBlock' to be an ElementNode",
1248 const index = $removeTextAndSplitBlock(this);
1249 firstBlock.splice(index, 0, nodes);
1254 // CASE 3: At least 1 element of the array is not inline
1255 const blocksParent = $wrapInlineNodes(nodes);
1256 const nodeToSelect = blocksParent.getLastDescendant()!;
1257 const blocks = blocksParent.getChildren();
1258 const isMergeable = (node: LexicalNode): node is ElementNode =>
1259 $isElementNode(node) &&
1260 INTERNAL_$isBlock(node) &&
1262 $isElementNode(firstBlock) &&
1263 (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());
1265 const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
1266 const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
1267 const lastToInsert = blocks[blocks.length - 1];
1268 let firstToInsert = blocks[0];
1269 if (isMergeable(firstToInsert)) {
1271 $isElementNode(firstBlock),
1272 "Expected 'firstBlock' to be an ElementNode",
1274 firstBlock.append(...firstToInsert.getChildren());
1275 firstToInsert = blocks[1];
1277 if (firstToInsert) {
1278 insertRangeAfter(firstBlock, firstToInsert);
1280 const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!;
1283 insertedParagraph &&
1284 $isElementNode(lastInsertedBlock) &&
1285 (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
1287 lastInsertedBlock.append(...insertedParagraph.getChildren());
1288 insertedParagraph.remove();
1290 if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
1291 firstBlock.remove();
1294 nodeToSelect.selectEnd();
1296 // To understand this take a look at the test "can wrap post-linebreak nodes into new element"
1297 const lastChild = $isElementNode(firstBlock)
1298 ? firstBlock.getLastChild()
1300 if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
1306 * Inserts a new ParagraphNode into the EditorState at the current Selection
1308 * @returns the newly inserted node.
1310 insertParagraph(): ElementNode | null {
1311 if (this.anchor.key === 'root') {
1312 const paragraph = $createParagraphNode();
1313 $getRoot().splice(this.anchor.offset, 0, [paragraph]);
1317 const index = $removeTextAndSplitBlock(this);
1318 const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!;
1319 invariant($isElementNode(block), 'Expected ancestor to be an ElementNode');
1320 const firstToAppend = block.getChildAtIndex(index);
1321 const nodesToInsert = firstToAppend
1322 ? [firstToAppend, ...firstToAppend.getNextSiblings()]
1324 const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
1326 newBlock.append(...nodesToInsert);
1327 newBlock.selectStart();
1330 // if newBlock is null, it means that block is of type CodeNode.
1335 * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
1336 * current Selection.
1338 insertLineBreak(selectStart?: boolean): void {
1339 const lineBreak = $createLineBreakNode();
1340 this.insertNodes([lineBreak]);
1341 // this is used in MacOS with the command 'ctrl-O' (openLineBreak)
1343 const parent = lineBreak.getParentOrThrow();
1344 const index = lineBreak.getIndexWithinParent();
1345 parent.select(index, index);
1350 * Extracts the nodes in the Selection, splitting nodes where necessary
1351 * to get offset-level precision.
1353 * @returns The nodes in the Selection
1355 extract(): Array<LexicalNode> {
1356 const selectedNodes = this.getNodes();
1357 const selectedNodesLength = selectedNodes.length;
1358 const lastIndex = selectedNodesLength - 1;
1359 const anchor = this.anchor;
1360 const focus = this.focus;
1361 let firstNode = selectedNodes[0];
1362 let lastNode = selectedNodes[lastIndex];
1363 const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
1365 if (selectedNodesLength === 0) {
1367 } else if (selectedNodesLength === 1) {
1368 if ($isTextNode(firstNode) && !this.isCollapsed()) {
1370 anchorOffset > focusOffset ? focusOffset : anchorOffset;
1372 anchorOffset > focusOffset ? anchorOffset : focusOffset;
1373 const splitNodes = firstNode.splitText(startOffset, endOffset);
1374 const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
1375 return node != null ? [node] : [];
1379 const isBefore = anchor.isBefore(focus);
1381 if ($isTextNode(firstNode)) {
1382 const startOffset = isBefore ? anchorOffset : focusOffset;
1383 if (startOffset === firstNode.getTextContentSize()) {
1384 selectedNodes.shift();
1385 } else if (startOffset !== 0) {
1386 [, firstNode] = firstNode.splitText(startOffset);
1387 selectedNodes[0] = firstNode;
1390 if ($isTextNode(lastNode)) {
1391 const lastNodeText = lastNode.getTextContent();
1392 const lastNodeTextLength = lastNodeText.length;
1393 const endOffset = isBefore ? focusOffset : anchorOffset;
1394 if (endOffset === 0) {
1395 selectedNodes.pop();
1396 } else if (endOffset !== lastNodeTextLength) {
1397 [lastNode] = lastNode.splitText(endOffset);
1398 selectedNodes[lastIndex] = lastNode;
1401 return selectedNodes;
1405 * Modifies the Selection according to the parameters and a set of heuristics that account for
1406 * various node types. Can be used to safely move or extend selection by one logical "unit" without
1407 * dealing explicitly with all the possible node types.
1409 * @param alter the type of modification to perform
1410 * @param isBackward whether or not selection is backwards
1411 * @param granularity the granularity at which to apply the modification
1414 alter: 'move' | 'extend',
1415 isBackward: boolean,
1416 granularity: 'character' | 'word' | 'lineboundary',
1418 const focus = this.focus;
1419 const anchor = this.anchor;
1420 const collapse = alter === 'move';
1422 // Handle the selection movement around decorators.
1423 const possibleNode = $getAdjacentNode(focus, isBackward);
1424 if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
1425 // Make it possible to move selection from range selection to
1426 // node selection on the node.
1427 if (collapse && possibleNode.isKeyboardSelectable()) {
1428 const nodeSelection = $createNodeSelection();
1429 nodeSelection.add(possibleNode.__key);
1430 $setSelection(nodeSelection);
1433 const sibling = isBackward
1434 ? possibleNode.getPreviousSibling()
1435 : possibleNode.getNextSibling();
1437 if (!$isTextNode(sibling)) {
1438 const parent = possibleNode.getParentOrThrow();
1442 if ($isElementNode(sibling)) {
1443 elementKey = sibling.__key;
1444 offset = isBackward ? sibling.getChildrenSize() : 0;
1446 offset = possibleNode.getIndexWithinParent();
1447 elementKey = parent.__key;
1452 focus.set(elementKey, offset, 'element');
1454 anchor.set(elementKey, offset, 'element');
1458 const siblingKey = sibling.__key;
1459 const offset = isBackward ? sibling.getTextContent().length : 0;
1460 focus.set(siblingKey, offset, 'text');
1462 anchor.set(siblingKey, offset, 'text');
1467 const editor = getActiveEditor();
1468 const domSelection = getDOMSelection(editor._window);
1470 if (!domSelection) {
1473 const blockCursorElement = editor._blockCursorElement;
1474 const rootElement = editor._rootElement;
1475 // Remove the block cursor element if it exists. This will ensure selection
1476 // works as intended. If we leave it in the DOM all sorts of strange bugs
1479 rootElement !== null &&
1480 blockCursorElement !== null &&
1481 $isElementNode(possibleNode) &&
1482 !possibleNode.isInline() &&
1483 !possibleNode.canBeEmpty()
1485 removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
1487 // We use the DOM selection.modify API here to "tell" us what the selection
1488 // will be. We then use it to update the Lexical selection accordingly. This
1489 // is much more reliable than waiting for a beforeinput and using the ranges
1490 // from getTargetRanges(), and is also better than trying to do it ourselves
1491 // using Intl.Segmenter or other workarounds that struggle with word segments
1492 // and line segments (especially with word wrapping and non-Roman languages).
1493 moveNativeSelection(
1496 isBackward ? 'backward' : 'forward',
1499 // Guard against no ranges
1500 if (domSelection.rangeCount > 0) {
1501 const range = domSelection.getRangeAt(0);
1502 // Apply the DOM selection to our Lexical selection.
1503 const anchorNode = this.anchor.getNode();
1504 const root = $isRootNode(anchorNode)
1506 : $getNearestRootOrShadowRoot(anchorNode);
1507 this.applyDOMRange(range);
1510 // Validate selection; make sure that the new extended selection respects shadow roots
1511 const nodes = this.getNodes();
1512 const validNodes = [];
1513 let shrinkSelection = false;
1514 for (let i = 0; i < nodes.length; i++) {
1515 const nextNode = nodes[i];
1516 if ($hasAncestor(nextNode, root)) {
1517 validNodes.push(nextNode);
1519 shrinkSelection = true;
1522 if (shrinkSelection && validNodes.length > 0) {
1523 // validNodes length check is a safeguard against an invalid selection; as getNodes()
1524 // will return an empty array in this case
1526 const firstValidNode = validNodes[0];
1527 if ($isElementNode(firstValidNode)) {
1528 firstValidNode.selectStart();
1530 firstValidNode.getParentOrThrow().selectStart();
1533 const lastValidNode = validNodes[validNodes.length - 1];
1534 if ($isElementNode(lastValidNode)) {
1535 lastValidNode.selectEnd();
1537 lastValidNode.getParentOrThrow().selectEnd();
1542 // Because a range works on start and end, we might need to flip
1543 // the anchor and focus points to match what the DOM has, not what
1544 // the range has specifically.
1546 domSelection.anchorNode !== range.startContainer ||
1547 domSelection.anchorOffset !== range.startOffset
1555 * Helper for handling forward character and word deletion that prevents element nodes
1556 * like a table, columns layout being destroyed
1558 * @param anchor the anchor
1559 * @param anchorNode the anchor node in the selection
1560 * @param isBackward whether or not selection is backwards
1564 anchorNode: TextNode | ElementNode,
1565 isBackward: boolean,
1569 // Delete forward handle case
1570 ((anchor.type === 'element' &&
1571 $isElementNode(anchorNode) &&
1572 anchor.offset === anchorNode.getChildrenSize()) ||
1573 (anchor.type === 'text' &&
1574 anchor.offset === anchorNode.getTextContentSize()))
1576 const parent = anchorNode.getParent();
1578 anchorNode.getNextSibling() ||
1579 (parent === null ? null : parent.getNextSibling());
1581 if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
1589 * Performs one logical character deletion operation on the EditorState based on the current Selection.
1590 * Handles different node types.
1592 * @param isBackward whether or not the selection is backwards.
1594 deleteCharacter(isBackward: boolean): void {
1595 const wasCollapsed = this.isCollapsed();
1596 if (this.isCollapsed()) {
1597 const anchor = this.anchor;
1598 let anchorNode: TextNode | ElementNode | null = anchor.getNode();
1599 if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
1603 // Handle the deletion around decorators.
1604 const focus = this.focus;
1605 const possibleNode = $getAdjacentNode(focus, isBackward);
1606 if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
1607 // Make it possible to move selection from range selection to
1608 // node selection on the node.
1610 possibleNode.isKeyboardSelectable() &&
1611 $isElementNode(anchorNode) &&
1612 anchorNode.getChildrenSize() === 0
1614 anchorNode.remove();
1615 const nodeSelection = $createNodeSelection();
1616 nodeSelection.add(possibleNode.__key);
1617 $setSelection(nodeSelection);
1619 possibleNode.remove();
1620 const editor = getActiveEditor();
1621 editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
1626 $isElementNode(possibleNode) &&
1627 $isElementNode(anchorNode) &&
1628 anchorNode.isEmpty()
1630 anchorNode.remove();
1631 possibleNode.selectStart();
1634 this.modify('extend', isBackward, 'character');
1636 if (!this.isCollapsed()) {
1637 const focusNode = focus.type === 'text' ? focus.getNode() : null;
1638 anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
1640 if (focusNode !== null && focusNode.isSegmented()) {
1641 const offset = focus.offset;
1642 const textContentSize = focusNode.getTextContentSize();
1644 focusNode.is(anchorNode) ||
1645 (isBackward && offset !== textContentSize) ||
1646 (!isBackward && offset !== 0)
1648 $removeSegment(focusNode, isBackward, offset);
1651 } else if (anchorNode !== null && anchorNode.isSegmented()) {
1652 const offset = anchor.offset;
1653 const textContentSize = anchorNode.getTextContentSize();
1655 anchorNode.is(focusNode) ||
1656 (isBackward && offset !== 0) ||
1657 (!isBackward && offset !== textContentSize)
1659 $removeSegment(anchorNode, isBackward, offset);
1663 $updateCaretSelectionForUnicodeCharacter(this, isBackward);
1664 } else if (isBackward && anchor.offset === 0) {
1665 // Special handling around rich text nodes
1667 anchor.type === 'element'
1669 : anchor.getNode().getParentOrThrow();
1670 if (element.collapseAtStart(this)) {
1679 this.isCollapsed() &&
1680 this.anchor.type === 'element' &&
1681 this.anchor.offset === 0
1683 const anchorNode = this.anchor.getNode();
1685 anchorNode.isEmpty() &&
1686 $isRootNode(anchorNode.getParent()) &&
1687 anchorNode.getIndexWithinParent() === 0
1689 anchorNode.collapseAtStart(this);
1695 * Performs one logical line deletion operation on the EditorState based on the current Selection.
1696 * Handles different node types.
1698 * @param isBackward whether or not the selection is backwards.
1700 deleteLine(isBackward: boolean): void {
1701 if (this.isCollapsed()) {
1702 // Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections
1703 // but doesn't properly handle selections which end on elements, a space character is added
1704 // for such selections transforming their anchor's type to 'text'
1705 const anchorIsElement = this.anchor.type === 'element';
1706 if (anchorIsElement) {
1707 this.insertText(' ');
1710 this.modify('extend', isBackward, 'lineboundary');
1712 // If selection is extended to cover text edge then extend it one character more
1713 // to delete its parent element. Otherwise text content will be deleted but empty
1714 // parent node will remain
1715 const endPoint = isBackward ? this.focus : this.anchor;
1716 if (endPoint.offset === 0) {
1717 this.modify('extend', isBackward, 'character');
1720 // Adjusts selection to include an extra character added for element anchors to remove it
1721 if (anchorIsElement) {
1722 const startPoint = isBackward ? this.anchor : this.focus;
1723 startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
1730 * Performs one logical word deletion operation on the EditorState based on the current Selection.
1731 * Handles different node types.
1733 * @param isBackward whether or not the selection is backwards.
1735 deleteWord(isBackward: boolean): void {
1736 if (this.isCollapsed()) {
1737 const anchor = this.anchor;
1738 const anchorNode: TextNode | ElementNode | null = anchor.getNode();
1739 if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
1742 this.modify('extend', isBackward, 'word');
1748 * Returns whether the Selection is "backwards", meaning the focus
1749 * logically precedes the anchor in the EditorState.
1750 * @returns true if the Selection is backwards, false otherwise.
1752 isBackward(): boolean {
1753 return this.focus.isBefore(this.anchor);
1756 getStartEndPoints(): null | [PointType, PointType] {
1757 return [this.anchor, this.focus];
1761 export function $isNodeSelection(x: unknown): x is NodeSelection {
1762 return x instanceof NodeSelection;
1765 function getCharacterOffset(point: PointType): number {
1766 const offset = point.offset;
1767 if (point.type === 'text') {
1771 const parent = point.getNode();
1772 return offset === parent.getChildrenSize()
1773 ? parent.getTextContent().length
1777 export function $getCharacterOffsets(
1778 selection: BaseSelection,
1779 ): [number, number] {
1780 const anchorAndFocus = selection.getStartEndPoints();
1781 if (anchorAndFocus === null) {
1784 const [anchor, focus] = anchorAndFocus;
1786 anchor.type === 'element' &&
1787 focus.type === 'element' &&
1788 anchor.key === focus.key &&
1789 anchor.offset === focus.offset
1793 return [getCharacterOffset(anchor), getCharacterOffset(focus)];
1796 function $swapPoints(selection: RangeSelection): void {
1797 const focus = selection.focus;
1798 const anchor = selection.anchor;
1799 const anchorKey = anchor.key;
1800 const anchorOffset = anchor.offset;
1801 const anchorType = anchor.type;
1803 $setPointValues(anchor, focus.key, focus.offset, focus.type);
1804 $setPointValues(focus, anchorKey, anchorOffset, anchorType);
1805 selection._cachedNodes = null;
1808 function moveNativeSelection(
1809 domSelection: Selection,
1810 alter: 'move' | 'extend',
1811 direction: 'backward' | 'forward' | 'left' | 'right',
1812 granularity: 'character' | 'word' | 'lineboundary',
1814 // Selection.modify() method applies a change to the current selection or cursor position,
1815 // but is still non-standard in some browsers.
1816 domSelection.modify(alter, direction, granularity);
1819 function $updateCaretSelectionForUnicodeCharacter(
1820 selection: RangeSelection,
1821 isBackward: boolean,
1823 const anchor = selection.anchor;
1824 const focus = selection.focus;
1825 const anchorNode = anchor.getNode();
1826 const focusNode = focus.getNode();
1829 anchorNode === focusNode &&
1830 anchor.type === 'text' &&
1831 focus.type === 'text'
1833 // Handling of multibyte characters
1834 const anchorOffset = anchor.offset;
1835 const focusOffset = focus.offset;
1836 const isBefore = anchorOffset < focusOffset;
1837 const startOffset = isBefore ? anchorOffset : focusOffset;
1838 const endOffset = isBefore ? focusOffset : anchorOffset;
1839 const characterOffset = endOffset - 1;
1841 if (startOffset !== characterOffset) {
1842 const text = anchorNode.getTextContent().slice(startOffset, endOffset);
1843 if (!doesContainGrapheme(text)) {
1845 focus.offset = characterOffset;
1847 anchor.offset = characterOffset;
1852 // TODO Handling of multibyte characters
1856 function $removeSegment(
1858 isBackward: boolean,
1861 const textNode = node;
1862 const textContent = textNode.getTextContent();
1863 const split = textContent.split(/(?=\s)/g);
1864 const splitLength = split.length;
1865 let segmentOffset = 0;
1866 let restoreOffset: number | undefined = 0;
1868 for (let i = 0; i < splitLength; i++) {
1869 const text = split[i];
1870 const isLast = i === splitLength - 1;
1871 restoreOffset = segmentOffset;
1872 segmentOffset += text.length;
1875 (isBackward && segmentOffset === offset) ||
1876 segmentOffset > offset ||
1881 restoreOffset = undefined;
1886 const nextTextContent = split.join('').trim();
1888 if (nextTextContent === '') {
1891 textNode.setTextContent(nextTextContent);
1892 textNode.select(restoreOffset, restoreOffset);
1896 function shouldResolveAncestor(
1897 resolvedElement: ElementNode,
1898 resolvedOffset: number,
1899 lastPoint: null | PointType,
1901 const parent = resolvedElement.getParent();
1903 lastPoint === null ||
1905 !parent.canBeEmpty() ||
1906 parent !== lastPoint.getNode()
1910 function $internalResolveSelectionPoint(
1913 lastPoint: null | PointType,
1914 editor: LexicalEditor,
1915 ): null | PointType {
1916 let resolvedOffset = offset;
1917 let resolvedNode: TextNode | LexicalNode | null;
1918 // If we have selection on an element, we will
1919 // need to figure out (using the offset) what text
1920 // node should be selected.
1922 if (dom.nodeType === DOM_ELEMENT_TYPE) {
1923 // Resolve element to a ElementNode, or TextNode, or null
1924 let moveSelectionToEnd = false;
1925 // Given we're moving selection to another node, selection is
1926 // definitely dirty.
1927 // We use the anchor to find which child node to select
1928 const childNodes = dom.childNodes;
1929 const childNodesLength = childNodes.length;
1930 const blockCursorElement = editor._blockCursorElement;
1931 // If the anchor is the same as length, then this means we
1932 // need to select the very last text node.
1933 if (resolvedOffset === childNodesLength) {
1934 moveSelectionToEnd = true;
1935 resolvedOffset = childNodesLength - 1;
1937 let childDOM = childNodes[resolvedOffset];
1938 let hasBlockCursor = false;
1939 if (childDOM === blockCursorElement) {
1940 childDOM = childNodes[resolvedOffset + 1];
1941 hasBlockCursor = true;
1942 } else if (blockCursorElement !== null) {
1943 const blockCursorElementParent = blockCursorElement.parentNode;
1944 if (dom === blockCursorElementParent) {
1945 const blockCursorOffset = Array.prototype.indexOf.call(
1946 blockCursorElementParent.children,
1949 if (offset > blockCursorOffset) {
1954 resolvedNode = $getNodeFromDOM(childDOM);
1956 if ($isTextNode(resolvedNode)) {
1957 resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd);
1959 let resolvedElement = $getNodeFromDOM(dom);
1960 // Ensure resolvedElement is actually a element.
1961 if (resolvedElement === null) {
1964 if ($isElementNode(resolvedElement)) {
1965 resolvedOffset = Math.min(
1966 resolvedElement.getChildrenSize(),
1969 let child = resolvedElement.getChildAtIndex(resolvedOffset);
1971 $isElementNode(child) &&
1972 shouldResolveAncestor(child, resolvedOffset, lastPoint)
1974 const descendant = moveSelectionToEnd
1975 ? child.getLastDescendant()
1976 : child.getFirstDescendant();
1977 if (descendant === null) {
1978 resolvedElement = child;
1981 resolvedElement = $isElementNode(child)
1983 : child.getParentOrThrow();
1987 if ($isTextNode(child)) {
1988 resolvedNode = child;
1989 resolvedElement = null;
1990 resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd);
1992 child !== resolvedElement &&
1993 moveSelectionToEnd &&
1999 const index = resolvedElement.getIndexWithinParent();
2000 // When selecting decorators, there can be some selection issues when using resolvedOffset,
2001 // and instead we should be checking if we're using the offset
2004 $isDecoratorNode(resolvedElement) &&
2005 $getNodeFromDOM(dom) === resolvedElement
2007 resolvedOffset = index;
2009 resolvedOffset = index + 1;
2011 resolvedElement = resolvedElement.getParentOrThrow();
2013 if ($isElementNode(resolvedElement)) {
2014 return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
2019 resolvedNode = $getNodeFromDOM(dom);
2021 if (!$isTextNode(resolvedNode)) {
2024 return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
2027 function resolveSelectionPointOnBoundary(
2028 point: TextPointType,
2029 isBackward: boolean,
2030 isCollapsed: boolean,
2032 const offset = point.offset;
2033 const node = point.getNode();
2036 const prevSibling = node.getPreviousSibling();
2037 const parent = node.getParent();
2041 $isElementNode(prevSibling) &&
2043 prevSibling.isInline()
2045 point.key = prevSibling.__key;
2046 point.offset = prevSibling.getChildrenSize();
2047 // @ts-expect-error: intentional
2048 point.type = 'element';
2049 } else if ($isTextNode(prevSibling)) {
2050 point.key = prevSibling.__key;
2051 point.offset = prevSibling.getTextContent().length;
2054 (isCollapsed || !isBackward) &&
2055 prevSibling === null &&
2056 $isElementNode(parent) &&
2059 const parentSibling = parent.getPreviousSibling();
2060 if ($isTextNode(parentSibling)) {
2061 point.key = parentSibling.__key;
2062 point.offset = parentSibling.getTextContent().length;
2065 } else if (offset === node.getTextContent().length) {
2066 const nextSibling = node.getNextSibling();
2067 const parent = node.getParent();
2069 if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
2070 point.key = nextSibling.__key;
2072 // @ts-expect-error: intentional
2073 point.type = 'element';
2075 (isCollapsed || isBackward) &&
2076 nextSibling === null &&
2077 $isElementNode(parent) &&
2078 parent.isInline() &&
2079 !parent.canInsertTextAfter()
2081 const parentSibling = parent.getNextSibling();
2082 if ($isTextNode(parentSibling)) {
2083 point.key = parentSibling.__key;
2090 function $normalizeSelectionPointsForBoundaries(
2093 lastSelection: null | BaseSelection,
2095 if (anchor.type === 'text' && focus.type === 'text') {
2096 const isBackward = anchor.isBefore(focus);
2097 const isCollapsed = anchor.is(focus);
2099 // Attempt to normalize the offset to the previous sibling if we're at the
2100 // start of a text node and the sibling is a text node or inline element.
2101 resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
2102 resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
2105 focus.key = anchor.key;
2106 focus.offset = anchor.offset;
2107 focus.type = anchor.type;
2109 const editor = getActiveEditor();
2112 editor.isComposing() &&
2113 editor._compositionKey !== anchor.key &&
2114 $isRangeSelection(lastSelection)
2116 const lastAnchor = lastSelection.anchor;
2117 const lastFocus = lastSelection.focus;
2124 $setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
2129 function $internalResolveSelectionPoints(
2130 anchorDOM: null | Node,
2131 anchorOffset: number,
2132 focusDOM: null | Node,
2133 focusOffset: number,
2134 editor: LexicalEditor,
2135 lastSelection: null | BaseSelection,
2136 ): null | [PointType, PointType] {
2138 anchorDOM === null ||
2139 focusDOM === null ||
2140 !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
2144 const resolvedAnchorPoint = $internalResolveSelectionPoint(
2147 $isRangeSelection(lastSelection) ? lastSelection.anchor : null,
2150 if (resolvedAnchorPoint === null) {
2153 const resolvedFocusPoint = $internalResolveSelectionPoint(
2156 $isRangeSelection(lastSelection) ? lastSelection.focus : null,
2159 if (resolvedFocusPoint === null) {
2163 resolvedAnchorPoint.type === 'element' &&
2164 resolvedFocusPoint.type === 'element'
2166 const anchorNode = $getNodeFromDOM(anchorDOM);
2167 const focusNode = $getNodeFromDOM(focusDOM);
2168 // Ensure if we're selecting the content of a decorator that we
2169 // return null for this point, as it's not in the controlled scope
2171 if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
2176 // Handle normalization of selection when it is at the boundaries.
2177 $normalizeSelectionPointsForBoundaries(
2178 resolvedAnchorPoint,
2183 return [resolvedAnchorPoint, resolvedFocusPoint];
2186 export function $isBlockElementNode(
2187 node: LexicalNode | null | undefined,
2188 ): node is ElementNode {
2189 return $isElementNode(node) && !node.isInline();
2192 // This is used to make a selection when the existing
2193 // selection is null, i.e. forcing selection on the editor
2194 // when it current exists outside the editor.
2196 export function $internalMakeRangeSelection(
2198 anchorOffset: number,
2200 focusOffset: number,
2201 anchorType: 'text' | 'element',
2202 focusType: 'text' | 'element',
2204 const editorState = getActiveEditorState();
2205 const selection = new RangeSelection(
2206 $createPoint(anchorKey, anchorOffset, anchorType),
2207 $createPoint(focusKey, focusOffset, focusType),
2211 selection.dirty = true;
2212 editorState._selection = selection;
2216 export function $createRangeSelection(): RangeSelection {
2217 const anchor = $createPoint('root', 0, 'element');
2218 const focus = $createPoint('root', 0, 'element');
2219 return new RangeSelection(anchor, focus, 0, '');
2222 export function $createNodeSelection(): NodeSelection {
2223 return new NodeSelection(new Set());
2226 export function $internalCreateSelection(
2227 editor: LexicalEditor,
2228 ): null | BaseSelection {
2229 const currentEditorState = editor.getEditorState();
2230 const lastSelection = currentEditorState._selection;
2231 const domSelection = getDOMSelection(editor._window);
2233 if ($isRangeSelection(lastSelection) || lastSelection == null) {
2234 return $internalCreateRangeSelection(
2241 return lastSelection.clone();
2244 export function $createRangeSelectionFromDom(
2245 domSelection: Selection | null,
2246 editor: LexicalEditor,
2247 ): null | RangeSelection {
2248 return $internalCreateRangeSelection(null, domSelection, editor, null);
2251 export function $internalCreateRangeSelection(
2252 lastSelection: null | BaseSelection,
2253 domSelection: Selection | null,
2254 editor: LexicalEditor,
2255 event: UIEvent | Event | null,
2256 ): null | RangeSelection {
2257 const windowObj = editor._window;
2258 if (windowObj === null) {
2261 // When we create a selection, we try to use the previous
2262 // selection where possible, unless an actual user selection
2263 // change has occurred. When we do need to create a new selection
2264 // we validate we can have text nodes for both anchor and focus
2265 // nodes. If that holds true, we then return that selection
2266 // as a mutable object that we use for the editor state for this
2267 // update cycle. If a selection gets changed, and requires a
2268 // update to native DOM selection, it gets marked as "dirty".
2269 // If the selection changes, but matches with the existing
2270 // DOM selection, then we only need to sync it. Otherwise,
2271 // we generally bail out of doing an update to selection during
2272 // reconciliation unless there are dirty nodes that need
2275 const windowEvent = event || windowObj.event;
2276 const eventType = windowEvent ? windowEvent.type : undefined;
2277 const isSelectionChange = eventType === 'selectionchange';
2278 const useDOMSelection =
2279 !getIsProcessingMutations() &&
2280 (isSelectionChange ||
2281 eventType === 'beforeinput' ||
2282 eventType === 'compositionstart' ||
2283 eventType === 'compositionend' ||
2284 (eventType === 'click' &&
2286 (windowEvent as InputEvent).detail === 3) ||
2287 eventType === 'drop' ||
2288 eventType === undefined);
2289 let anchorDOM, focusDOM, anchorOffset, focusOffset;
2291 if (!$isRangeSelection(lastSelection) || useDOMSelection) {
2292 if (domSelection === null) {
2295 anchorDOM = domSelection.anchorNode;
2296 focusDOM = domSelection.focusNode;
2297 anchorOffset = domSelection.anchorOffset;
2298 focusOffset = domSelection.focusOffset;
2300 isSelectionChange &&
2301 $isRangeSelection(lastSelection) &&
2302 !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
2304 return lastSelection.clone();
2307 return lastSelection.clone();
2309 // Let's resolve the text nodes from the offsets and DOM nodes we have from
2310 // native selection.
2311 const resolvedSelectionPoints = $internalResolveSelectionPoints(
2319 if (resolvedSelectionPoints === null) {
2322 const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints;
2323 return new RangeSelection(
2324 resolvedAnchorPoint,
2326 !$isRangeSelection(lastSelection) ? 0 : lastSelection.format,
2327 !$isRangeSelection(lastSelection) ? '' : lastSelection.style,
2331 export function $getSelection(): null | BaseSelection {
2332 const editorState = getActiveEditorState();
2333 return editorState._selection;
2336 export function $getPreviousSelection(): null | BaseSelection {
2337 const editor = getActiveEditor();
2338 return editor._editorState._selection;
2341 export function $updateElementSelectionOnCreateDeleteNode(
2342 selection: RangeSelection,
2343 parentNode: LexicalNode,
2347 const anchor = selection.anchor;
2348 const focus = selection.focus;
2349 const anchorNode = anchor.getNode();
2350 const focusNode = focus.getNode();
2351 if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
2354 const parentKey = parentNode.__key;
2355 // Single node. We shift selection but never redimension it
2356 if (selection.isCollapsed()) {
2357 const selectionOffset = anchor.offset;
2359 (nodeOffset <= selectionOffset && times > 0) ||
2360 (nodeOffset < selectionOffset && times < 0)
2362 const newSelectionOffset = Math.max(0, selectionOffset + times);
2363 anchor.set(parentKey, newSelectionOffset, 'element');
2364 focus.set(parentKey, newSelectionOffset, 'element');
2365 // The new selection might point to text nodes, try to resolve them
2366 $updateSelectionResolveTextNodes(selection);
2369 // Multiple nodes selected. We shift or redimension selection
2370 const isBackward = selection.isBackward();
2371 const firstPoint = isBackward ? focus : anchor;
2372 const firstPointNode = firstPoint.getNode();
2373 const lastPoint = isBackward ? anchor : focus;
2374 const lastPointNode = lastPoint.getNode();
2375 if (parentNode.is(firstPointNode)) {
2376 const firstPointOffset = firstPoint.offset;
2378 (nodeOffset <= firstPointOffset && times > 0) ||
2379 (nodeOffset < firstPointOffset && times < 0)
2383 Math.max(0, firstPointOffset + times),
2388 if (parentNode.is(lastPointNode)) {
2389 const lastPointOffset = lastPoint.offset;
2391 (nodeOffset <= lastPointOffset && times > 0) ||
2392 (nodeOffset < lastPointOffset && times < 0)
2396 Math.max(0, lastPointOffset + times),
2402 // The new selection might point to text nodes, try to resolve them
2403 $updateSelectionResolveTextNodes(selection);
2406 function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
2407 const anchor = selection.anchor;
2408 const anchorOffset = anchor.offset;
2409 const focus = selection.focus;
2410 const focusOffset = focus.offset;
2411 const anchorNode = anchor.getNode();
2412 const focusNode = focus.getNode();
2413 if (selection.isCollapsed()) {
2414 if (!$isElementNode(anchorNode)) {
2417 const childSize = anchorNode.getChildrenSize();
2418 const anchorOffsetAtEnd = anchorOffset >= childSize;
2419 const child = anchorOffsetAtEnd
2420 ? anchorNode.getChildAtIndex(childSize - 1)
2421 : anchorNode.getChildAtIndex(anchorOffset);
2422 if ($isTextNode(child)) {
2424 if (anchorOffsetAtEnd) {
2425 newOffset = child.getTextContentSize();
2427 anchor.set(child.__key, newOffset, 'text');
2428 focus.set(child.__key, newOffset, 'text');
2432 if ($isElementNode(anchorNode)) {
2433 const childSize = anchorNode.getChildrenSize();
2434 const anchorOffsetAtEnd = anchorOffset >= childSize;
2435 const child = anchorOffsetAtEnd
2436 ? anchorNode.getChildAtIndex(childSize - 1)
2437 : anchorNode.getChildAtIndex(anchorOffset);
2438 if ($isTextNode(child)) {
2440 if (anchorOffsetAtEnd) {
2441 newOffset = child.getTextContentSize();
2443 anchor.set(child.__key, newOffset, 'text');
2446 if ($isElementNode(focusNode)) {
2447 const childSize = focusNode.getChildrenSize();
2448 const focusOffsetAtEnd = focusOffset >= childSize;
2449 const child = focusOffsetAtEnd
2450 ? focusNode.getChildAtIndex(childSize - 1)
2451 : focusNode.getChildAtIndex(focusOffset);
2452 if ($isTextNode(child)) {
2454 if (focusOffsetAtEnd) {
2455 newOffset = child.getTextContentSize();
2457 focus.set(child.__key, newOffset, 'text');
2462 export function applySelectionTransforms(
2463 nextEditorState: EditorState,
2464 editor: LexicalEditor,
2466 const prevEditorState = editor.getEditorState();
2467 const prevSelection = prevEditorState._selection;
2468 const nextSelection = nextEditorState._selection;
2469 if ($isRangeSelection(nextSelection)) {
2470 const anchor = nextSelection.anchor;
2471 const focus = nextSelection.focus;
2474 if (anchor.type === 'text') {
2475 anchorNode = anchor.getNode();
2476 anchorNode.selectionTransform(prevSelection, nextSelection);
2478 if (focus.type === 'text') {
2479 const focusNode = focus.getNode();
2480 if (anchorNode !== focusNode) {
2481 focusNode.selectionTransform(prevSelection, nextSelection);
2487 export function moveSelectionPointToSibling(
2490 parent: ElementNode,
2491 prevSibling: LexicalNode | null,
2492 nextSibling: LexicalNode | null,
2494 let siblingKey = null;
2496 let type: 'text' | 'element' | null = null;
2497 if (prevSibling !== null) {
2498 siblingKey = prevSibling.__key;
2499 if ($isTextNode(prevSibling)) {
2500 offset = prevSibling.getTextContentSize();
2502 } else if ($isElementNode(prevSibling)) {
2503 offset = prevSibling.getChildrenSize();
2507 if (nextSibling !== null) {
2508 siblingKey = nextSibling.__key;
2509 if ($isTextNode(nextSibling)) {
2511 } else if ($isElementNode(nextSibling)) {
2516 if (siblingKey !== null && type !== null) {
2517 point.set(siblingKey, offset, type);
2519 offset = node.getIndexWithinParent();
2520 if (offset === -1) {
2521 // Move selection to end of parent
2522 offset = parent.getChildrenSize();
2524 point.set(parent.__key, offset, 'element');
2528 export function adjustPointOffsetForMergedSibling(
2535 if (point.type === 'text') {
2538 point.offset += textLength;
2540 } else if (point.offset > target.getIndexWithinParent()) {
2545 export function updateDOMSelection(
2546 prevSelection: BaseSelection | null,
2547 nextSelection: BaseSelection | null,
2548 editor: LexicalEditor,
2549 domSelection: Selection,
2551 rootElement: HTMLElement,
2554 const anchorDOMNode = domSelection.anchorNode;
2555 const focusDOMNode = domSelection.focusNode;
2556 const anchorOffset = domSelection.anchorOffset;
2557 const focusOffset = domSelection.focusOffset;
2558 const activeElement = document.activeElement;
2560 // TODO: make this not hard-coded, and add another config option
2561 // that makes this configurable.
2563 (tags.has('collaboration') && activeElement !== rootElement) ||
2564 (activeElement !== null &&
2565 isSelectionCapturedInDecoratorInput(activeElement))
2570 if (!$isRangeSelection(nextSelection)) {
2571 // We don't remove selection if the prevSelection is null because
2572 // of editor.setRootElement(). If this occurs on init when the
2573 // editor is already focused, then this can cause the editor to
2576 prevSelection !== null &&
2577 isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode)
2579 domSelection.removeAllRanges();
2585 const anchor = nextSelection.anchor;
2586 const focus = nextSelection.focus;
2587 const anchorKey = anchor.key;
2588 const focusKey = focus.key;
2589 const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
2590 const focusDOM = getElementByKeyOrThrow(editor, focusKey);
2591 const nextAnchorOffset = anchor.offset;
2592 const nextFocusOffset = focus.offset;
2593 const nextFormat = nextSelection.format;
2594 const nextStyle = nextSelection.style;
2595 const isCollapsed = nextSelection.isCollapsed();
2596 let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
2597 let nextFocusNode: HTMLElement | Text | null = focusDOM;
2598 let anchorFormatOrStyleChanged = false;
2600 if (anchor.type === 'text') {
2601 nextAnchorNode = getDOMTextNode(anchorDOM);
2602 const anchorNode = anchor.getNode();
2603 anchorFormatOrStyleChanged =
2604 anchorNode.getFormat() !== nextFormat ||
2605 anchorNode.getStyle() !== nextStyle;
2607 $isRangeSelection(prevSelection) &&
2608 prevSelection.anchor.type === 'text'
2610 anchorFormatOrStyleChanged = true;
2613 if (focus.type === 'text') {
2614 nextFocusNode = getDOMTextNode(focusDOM);
2617 // If we can't get an underlying text node for selection, then
2618 // we should avoid setting selection to something incorrect.
2619 if (nextAnchorNode === null || nextFocusNode === null) {
2625 (prevSelection === null ||
2626 anchorFormatOrStyleChanged ||
2627 ($isRangeSelection(prevSelection) &&
2628 (prevSelection.format !== nextFormat ||
2629 prevSelection.style !== nextStyle)))
2631 markCollapsedSelectionFormat(
2640 // Diff against the native DOM selection to ensure we don't do
2641 // an unnecessary selection update. We also skip this check if
2642 // we're moving selection to within an element, as this can
2643 // sometimes be problematic around scrolling.
2645 anchorOffset === nextAnchorOffset &&
2646 focusOffset === nextFocusOffset &&
2647 anchorDOMNode === nextAnchorNode &&
2648 focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482
2649 !(domSelection.type === 'Range' && isCollapsed)
2651 // If the root element does not have focus, ensure it has focus
2652 if (activeElement === null || !rootElement.contains(activeElement)) {
2654 preventScroll: true,
2657 if (anchor.type !== 'element') {
2662 // Apply the updated selection to the DOM. Note: this will trigger
2663 // a "selectionchange" event, although it will be asynchronous.
2665 domSelection.setBaseAndExtent(
2672 // If we encounter an error, continue. This can sometimes
2673 // occur with FF and there's no good reason as to why it
2676 console.warn(error);
2680 !tags.has('skip-scroll-into-view') &&
2681 nextSelection.isCollapsed() &&
2682 rootElement !== null &&
2683 rootElement === document.activeElement
2685 const selectionTarget: null | Range | HTMLElement | Text =
2686 nextSelection instanceof RangeSelection &&
2687 nextSelection.anchor.type === 'element'
2688 ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
2690 : domSelection.rangeCount > 0
2691 ? domSelection.getRangeAt(0)
2693 if (selectionTarget !== null) {
2694 let selectionRect: DOMRect;
2695 if (selectionTarget instanceof Text) {
2696 const range = document.createRange();
2697 range.selectNode(selectionTarget);
2698 selectionRect = range.getBoundingClientRect();
2700 selectionRect = selectionTarget.getBoundingClientRect();
2702 scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
2706 markSelectionChangeFromDOMUpdate();
2709 export function $insertNodes(nodes: Array<LexicalNode>) {
2710 let selection = $getSelection() || $getPreviousSelection();
2712 if (selection === null) {
2713 selection = $getRoot().selectEnd();
2715 selection.insertNodes(nodes);
2718 export function $getTextContent(): string {
2719 const selection = $getSelection();
2720 if (selection === null) {
2723 return selection.getTextContent();
2726 function $removeTextAndSplitBlock(selection: RangeSelection): number {
2727 let selection_ = selection;
2728 if (!selection.isCollapsed()) {
2729 selection_.removeText();
2731 // A new selection can originate as a result of node replacement, in which case is registered via
2733 const newSelection = $getSelection();
2734 if ($isRangeSelection(newSelection)) {
2735 selection_ = newSelection;
2739 $isRangeSelection(selection_),
2740 'Unexpected dirty selection to be null',
2743 const anchor = selection_.anchor;
2744 let node = anchor.getNode();
2745 let offset = anchor.offset;
2747 while (!INTERNAL_$isBlock(node)) {
2748 [node, offset] = $splitNodeAtPoint(node, offset);
2754 function $splitNodeAtPoint(
2757 ): [parent: ElementNode, offset: number] {
2758 const parent = node.getParent();
2760 const paragraph = $createParagraphNode();
2761 $getRoot().append(paragraph);
2763 return [$getRoot(), 0];
2766 if ($isTextNode(node)) {
2767 const split = node.splitText(offset);
2768 if (split.length === 0) {
2769 return [parent, node.getIndexWithinParent()];
2771 const x = offset === 0 ? 0 : 1;
2772 const index = split[0].getIndexWithinParent() + x;
2774 return [parent, index];
2777 if (!$isElementNode(node) || offset === 0) {
2778 return [parent, node.getIndexWithinParent()];
2781 const firstToAppend = node.getChildAtIndex(offset);
2782 if (firstToAppend) {
2783 const insertPoint = new RangeSelection(
2784 $createPoint(node.__key, offset, 'element'),
2785 $createPoint(node.__key, offset, 'element'),
2789 const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
2791 newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
2794 return [parent, node.getIndexWithinParent() + 1];
2797 function $wrapInlineNodes(nodes: LexicalNode[]) {
2798 // We temporarily insert the topLevelNodes into an arbitrary ElementNode,
2799 // since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
2800 const virtualRoot = $createParagraphNode();
2802 let currentBlock = null;
2803 for (let i = 0; i < nodes.length; i++) {
2804 const node = nodes[i];
2806 const isLineBreakNode = $isLineBreakNode(node);
2810 ($isDecoratorNode(node) && node.isInline()) ||
2811 ($isElementNode(node) && node.isInline()) ||
2812 $isTextNode(node) ||
2813 node.isParentRequired()
2815 if (currentBlock === null) {
2816 currentBlock = node.createParentElementNode();
2817 virtualRoot.append(currentBlock);
2818 // In the case of LineBreakNode, we just need to
2819 // add an empty ParagraphNode to the topLevelBlocks.
2820 if (isLineBreakNode) {
2825 if (currentBlock !== null) {
2826 currentBlock.append(node);
2829 virtualRoot.append(node);
2830 currentBlock = null;