2836 lines
		
	
	
		
			87 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			2836 lines
		
	
	
		
			87 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /**
 | |
|  * Copyright (c) Meta Platforms, Inc. and affiliates.
 | |
|  *
 | |
|  * This source code is licensed under the MIT license found in the
 | |
|  * LICENSE file in the root directory of this source tree.
 | |
|  *
 | |
|  */
 | |
| 
 | |
| import type {LexicalEditor} from './LexicalEditor';
 | |
| import type {EditorState} from './LexicalEditorState';
 | |
| import type {NodeKey} from './LexicalNode';
 | |
| import type {ElementNode} from './nodes/LexicalElementNode';
 | |
| import type {TextFormatType} from './nodes/LexicalTextNode';
 | |
| 
 | |
| import invariant from 'lexical/shared/invariant';
 | |
| 
 | |
| import {
 | |
|   $createLineBreakNode,
 | |
|   $createParagraphNode,
 | |
|   $createTextNode,
 | |
|   $isDecoratorNode,
 | |
|   $isElementNode,
 | |
|   $isLineBreakNode,
 | |
|   $isRootNode,
 | |
|   $isTextNode,
 | |
|   $setSelection,
 | |
|   SELECTION_CHANGE_COMMAND,
 | |
|   TextNode,
 | |
| } from '.';
 | |
| import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
 | |
| import {
 | |
|   markCollapsedSelectionFormat,
 | |
|   markSelectionChangeFromDOMUpdate,
 | |
| } from './LexicalEvents';
 | |
| import {getIsProcessingMutations} from './LexicalMutations';
 | |
| import {insertRangeAfter, LexicalNode} from './LexicalNode';
 | |
| import {
 | |
|   getActiveEditor,
 | |
|   getActiveEditorState,
 | |
|   isCurrentlyReadOnlyMode,
 | |
| } from './LexicalUpdates';
 | |
| import {
 | |
|   $getAdjacentNode,
 | |
|   $getAncestor,
 | |
|   $getCompositionKey,
 | |
|   $getNearestRootOrShadowRoot,
 | |
|   $getNodeByKey,
 | |
|   $getNodeFromDOM,
 | |
|   $getRoot,
 | |
|   $hasAncestor,
 | |
|   $isTokenOrSegmented,
 | |
|   $setCompositionKey,
 | |
|   doesContainGrapheme,
 | |
|   getDOMSelection,
 | |
|   getDOMTextNode,
 | |
|   getElementByKeyOrThrow,
 | |
|   getTextNodeOffset,
 | |
|   INTERNAL_$isBlock,
 | |
|   isSelectionCapturedInDecoratorInput,
 | |
|   isSelectionWithinEditor,
 | |
|   removeDOMBlockCursorElement,
 | |
|   scrollIntoViewIfNeeded,
 | |
|   toggleTextFormatType,
 | |
| } from './LexicalUtils';
 | |
| import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
 | |
| 
 | |
| export type TextPointType = {
 | |
|   _selection: BaseSelection;
 | |
|   getNode: () => TextNode;
 | |
|   is: (point: PointType) => boolean;
 | |
|   isBefore: (point: PointType) => boolean;
 | |
|   key: NodeKey;
 | |
|   offset: number;
 | |
|   set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
 | |
|   type: 'text';
 | |
| };
 | |
| 
 | |
| export type ElementPointType = {
 | |
|   _selection: BaseSelection;
 | |
|   getNode: () => ElementNode;
 | |
|   is: (point: PointType) => boolean;
 | |
|   isBefore: (point: PointType) => boolean;
 | |
|   key: NodeKey;
 | |
|   offset: number;
 | |
|   set: (key: NodeKey, offset: number, type: 'text' | 'element') => void;
 | |
|   type: 'element';
 | |
| };
 | |
| 
 | |
| export type PointType = TextPointType | ElementPointType;
 | |
| 
 | |
| export class Point {
 | |
|   key: NodeKey;
 | |
|   offset: number;
 | |
|   type: 'text' | 'element';
 | |
|   _selection: BaseSelection | null;
 | |
| 
 | |
|   constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
 | |
|     this._selection = null;
 | |
|     this.key = key;
 | |
|     this.offset = offset;
 | |
|     this.type = type;
 | |
|   }
 | |
| 
 | |
|   is(point: PointType): boolean {
 | |
|     return (
 | |
|       this.key === point.key &&
 | |
|       this.offset === point.offset &&
 | |
|       this.type === point.type
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   isBefore(b: PointType): boolean {
 | |
|     let aNode = this.getNode();
 | |
|     let bNode = b.getNode();
 | |
|     const aOffset = this.offset;
 | |
|     const bOffset = b.offset;
 | |
| 
 | |
|     if ($isElementNode(aNode)) {
 | |
|       const aNodeDescendant = aNode.getDescendantByIndex<ElementNode>(aOffset);
 | |
|       aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
 | |
|     }
 | |
|     if ($isElementNode(bNode)) {
 | |
|       const bNodeDescendant = bNode.getDescendantByIndex<ElementNode>(bOffset);
 | |
|       bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
 | |
|     }
 | |
|     if (aNode === bNode) {
 | |
|       return aOffset < bOffset;
 | |
|     }
 | |
|     return aNode.isBefore(bNode);
 | |
|   }
 | |
| 
 | |
|   getNode(): LexicalNode {
 | |
|     const key = this.key;
 | |
|     const node = $getNodeByKey(key);
 | |
|     if (node === null) {
 | |
|       invariant(false, 'Point.getNode: node not found');
 | |
|     }
 | |
|     return node;
 | |
|   }
 | |
| 
 | |
|   set(key: NodeKey, offset: number, type: 'text' | 'element'): void {
 | |
|     const selection = this._selection;
 | |
|     const oldKey = this.key;
 | |
|     this.key = key;
 | |
|     this.offset = offset;
 | |
|     this.type = type;
 | |
|     if (!isCurrentlyReadOnlyMode()) {
 | |
|       if ($getCompositionKey() === oldKey) {
 | |
|         $setCompositionKey(key);
 | |
|       }
 | |
|       if (selection !== null) {
 | |
|         selection.setCachedNodes(null);
 | |
|         selection.dirty = true;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function $createPoint(
 | |
|   key: NodeKey,
 | |
|   offset: number,
 | |
|   type: 'text' | 'element',
 | |
| ): PointType {
 | |
|   // @ts-expect-error: intentionally cast as we use a class for perf reasons
 | |
|   return new Point(key, offset, type);
 | |
| }
 | |
| 
 | |
| function selectPointOnNode(point: PointType, node: LexicalNode): void {
 | |
|   let key = node.__key;
 | |
|   let offset = point.offset;
 | |
|   let type: 'element' | 'text' = 'element';
 | |
|   if ($isTextNode(node)) {
 | |
|     type = 'text';
 | |
|     const textContentLength = node.getTextContentSize();
 | |
|     if (offset > textContentLength) {
 | |
|       offset = textContentLength;
 | |
|     }
 | |
|   } else if (!$isElementNode(node)) {
 | |
|     const nextSibling = node.getNextSibling();
 | |
|     if ($isTextNode(nextSibling)) {
 | |
|       key = nextSibling.__key;
 | |
|       offset = 0;
 | |
|       type = 'text';
 | |
|     } else {
 | |
|       const parentNode = node.getParent();
 | |
|       if (parentNode) {
 | |
|         key = parentNode.__key;
 | |
|         offset = node.getIndexWithinParent() + 1;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   point.set(key, offset, type);
 | |
| }
 | |
| 
 | |
| export function $moveSelectionPointToEnd(
 | |
|   point: PointType,
 | |
|   node: LexicalNode,
 | |
| ): void {
 | |
|   if ($isElementNode(node)) {
 | |
|     const lastNode = node.getLastDescendant();
 | |
|     if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
 | |
|       selectPointOnNode(point, lastNode);
 | |
|     } else {
 | |
|       selectPointOnNode(point, node);
 | |
|     }
 | |
|   } else {
 | |
|     selectPointOnNode(point, node);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $transferStartingElementPointToTextPoint(
 | |
|   start: ElementPointType,
 | |
|   end: PointType,
 | |
|   format: number,
 | |
|   style: string,
 | |
| ): void {
 | |
|   const element = start.getNode();
 | |
|   const placementNode = element.getChildAtIndex(start.offset);
 | |
|   const textNode = $createTextNode();
 | |
|   const target = $isRootNode(element)
 | |
|     ? $createParagraphNode().append(textNode)
 | |
|     : textNode;
 | |
|   textNode.setFormat(format);
 | |
|   textNode.setStyle(style);
 | |
|   if (placementNode === null) {
 | |
|     element.append(target);
 | |
|   } else {
 | |
|     placementNode.insertBefore(target);
 | |
|   }
 | |
|   // Transfer the element point to a text point.
 | |
|   if (start.is(end)) {
 | |
|     end.set(textNode.__key, 0, 'text');
 | |
|   }
 | |
|   start.set(textNode.__key, 0, 'text');
 | |
| }
 | |
| 
 | |
| function $setPointValues(
 | |
|   point: PointType,
 | |
|   key: NodeKey,
 | |
|   offset: number,
 | |
|   type: 'text' | 'element',
 | |
| ): void {
 | |
|   point.key = key;
 | |
|   point.offset = offset;
 | |
|   point.type = type;
 | |
| }
 | |
| 
 | |
| export interface BaseSelection {
 | |
|   _cachedNodes: Array<LexicalNode> | null;
 | |
|   dirty: boolean;
 | |
| 
 | |
|   clone(): BaseSelection;
 | |
|   extract(): Array<LexicalNode>;
 | |
|   getNodes(): Array<LexicalNode>;
 | |
|   getTextContent(): string;
 | |
|   insertText(text: string): void;
 | |
|   insertRawText(text: string): void;
 | |
|   is(selection: null | BaseSelection): boolean;
 | |
|   insertNodes(nodes: Array<LexicalNode>): void;
 | |
|   getStartEndPoints(): null | [PointType, PointType];
 | |
|   isCollapsed(): boolean;
 | |
|   isBackward(): boolean;
 | |
|   getCachedNodes(): LexicalNode[] | null;
 | |
|   setCachedNodes(nodes: LexicalNode[] | null): void;
 | |
| }
 | |
| 
 | |
| export class NodeSelection implements BaseSelection {
 | |
|   _nodes: Set<NodeKey>;
 | |
|   _cachedNodes: Array<LexicalNode> | null;
 | |
|   dirty: boolean;
 | |
| 
 | |
|   constructor(objects: Set<NodeKey>) {
 | |
|     this._cachedNodes = null;
 | |
|     this._nodes = objects;
 | |
|     this.dirty = false;
 | |
|   }
 | |
| 
 | |
|   getCachedNodes(): LexicalNode[] | null {
 | |
|     return this._cachedNodes;
 | |
|   }
 | |
| 
 | |
|   setCachedNodes(nodes: LexicalNode[] | null): void {
 | |
|     this._cachedNodes = nodes;
 | |
|   }
 | |
| 
 | |
|   is(selection: null | BaseSelection): boolean {
 | |
|     if (!$isNodeSelection(selection)) {
 | |
|       return false;
 | |
|     }
 | |
|     const a: Set<NodeKey> = this._nodes;
 | |
|     const b: Set<NodeKey> = selection._nodes;
 | |
|     return a.size === b.size && Array.from(a).every((key) => b.has(key));
 | |
|   }
 | |
| 
 | |
|   isCollapsed(): boolean {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   isBackward(): boolean {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   getStartEndPoints(): null {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   add(key: NodeKey): void {
 | |
|     this.dirty = true;
 | |
|     this._nodes.add(key);
 | |
|     this._cachedNodes = null;
 | |
|   }
 | |
| 
 | |
|   delete(key: NodeKey): void {
 | |
|     this.dirty = true;
 | |
|     this._nodes.delete(key);
 | |
|     this._cachedNodes = null;
 | |
|   }
 | |
| 
 | |
|   clear(): void {
 | |
|     this.dirty = true;
 | |
|     this._nodes.clear();
 | |
|     this._cachedNodes = null;
 | |
|   }
 | |
| 
 | |
|   has(key: NodeKey): boolean {
 | |
|     return this._nodes.has(key);
 | |
|   }
 | |
| 
 | |
|   clone(): NodeSelection {
 | |
|     return new NodeSelection(new Set(this._nodes));
 | |
|   }
 | |
| 
 | |
|   extract(): Array<LexicalNode> {
 | |
|     return this.getNodes();
 | |
|   }
 | |
| 
 | |
|   insertRawText(text: string): void {
 | |
|     // Do nothing?
 | |
|   }
 | |
| 
 | |
|   insertText(): void {
 | |
|     // Do nothing?
 | |
|   }
 | |
| 
 | |
|   insertNodes(nodes: Array<LexicalNode>) {
 | |
|     const selectedNodes = this.getNodes();
 | |
|     const selectedNodesLength = selectedNodes.length;
 | |
|     const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
 | |
|     let selectionAtEnd: RangeSelection;
 | |
|     // Insert nodes
 | |
|     if ($isTextNode(lastSelectedNode)) {
 | |
|       selectionAtEnd = lastSelectedNode.select();
 | |
|     } else {
 | |
|       const index = lastSelectedNode.getIndexWithinParent() + 1;
 | |
|       selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
 | |
|     }
 | |
|     selectionAtEnd.insertNodes(nodes);
 | |
|     // Remove selected nodes
 | |
|     for (let i = 0; i < selectedNodesLength; i++) {
 | |
|       selectedNodes[i].remove();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   getNodes(): Array<LexicalNode> {
 | |
|     const cachedNodes = this._cachedNodes;
 | |
|     if (cachedNodes !== null) {
 | |
|       return cachedNodes;
 | |
|     }
 | |
|     const objects = this._nodes;
 | |
|     const nodes = [];
 | |
|     for (const object of objects) {
 | |
|       const node = $getNodeByKey(object);
 | |
|       if (node !== null) {
 | |
|         nodes.push(node);
 | |
|       }
 | |
|     }
 | |
|     if (!isCurrentlyReadOnlyMode()) {
 | |
|       this._cachedNodes = nodes;
 | |
|     }
 | |
|     return nodes;
 | |
|   }
 | |
| 
 | |
|   getTextContent(): string {
 | |
|     const nodes = this.getNodes();
 | |
|     let textContent = '';
 | |
|     for (let i = 0; i < nodes.length; i++) {
 | |
|       textContent += nodes[i].getTextContent();
 | |
|     }
 | |
|     return textContent;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function $isRangeSelection(x: unknown): x is RangeSelection {
 | |
|   return x instanceof RangeSelection;
 | |
| }
 | |
| 
 | |
| export class RangeSelection implements BaseSelection {
 | |
|   format: number;
 | |
|   style: string;
 | |
|   anchor: PointType;
 | |
|   focus: PointType;
 | |
|   _cachedNodes: Array<LexicalNode> | null;
 | |
|   dirty: boolean;
 | |
| 
 | |
|   constructor(
 | |
|     anchor: PointType,
 | |
|     focus: PointType,
 | |
|     format: number,
 | |
|     style: string,
 | |
|   ) {
 | |
|     this.anchor = anchor;
 | |
|     this.focus = focus;
 | |
|     anchor._selection = this;
 | |
|     focus._selection = this;
 | |
|     this._cachedNodes = null;
 | |
|     this.format = format;
 | |
|     this.style = style;
 | |
|     this.dirty = false;
 | |
|   }
 | |
| 
 | |
|   getCachedNodes(): LexicalNode[] | null {
 | |
|     return this._cachedNodes;
 | |
|   }
 | |
| 
 | |
|   setCachedNodes(nodes: LexicalNode[] | null): void {
 | |
|     this._cachedNodes = nodes;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Used to check if the provided selections is equal to this one by value,
 | |
|    * inluding anchor, focus, format, and style properties.
 | |
|    * @param selection - the Selection to compare this one to.
 | |
|    * @returns true if the Selections are equal, false otherwise.
 | |
|    */
 | |
|   is(selection: null | BaseSelection): boolean {
 | |
|     if (!$isRangeSelection(selection)) {
 | |
|       return false;
 | |
|     }
 | |
|     return (
 | |
|       this.anchor.is(selection.anchor) &&
 | |
|       this.focus.is(selection.focus) &&
 | |
|       this.format === selection.format &&
 | |
|       this.style === selection.style
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns whether the Selection is "collapsed", meaning the anchor and focus are
 | |
|    * the same node and have the same offset.
 | |
|    *
 | |
|    * @returns true if the Selection is collapsed, false otherwise.
 | |
|    */
 | |
|   isCollapsed(): boolean {
 | |
|     return this.anchor.is(this.focus);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Gets all the nodes in the Selection. Uses caching to make it generally suitable
 | |
|    * for use in hot paths.
 | |
|    *
 | |
|    * @returns an Array containing all the nodes in the Selection
 | |
|    */
 | |
|   getNodes(): Array<LexicalNode> {
 | |
|     const cachedNodes = this._cachedNodes;
 | |
|     if (cachedNodes !== null) {
 | |
|       return cachedNodes;
 | |
|     }
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     const isBefore = anchor.isBefore(focus);
 | |
|     const firstPoint = isBefore ? anchor : focus;
 | |
|     const lastPoint = isBefore ? focus : anchor;
 | |
|     let firstNode = firstPoint.getNode();
 | |
|     let lastNode = lastPoint.getNode();
 | |
|     const startOffset = firstPoint.offset;
 | |
|     const endOffset = lastPoint.offset;
 | |
| 
 | |
|     if ($isElementNode(firstNode)) {
 | |
|       const firstNodeDescendant =
 | |
|         firstNode.getDescendantByIndex<ElementNode>(startOffset);
 | |
|       firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
 | |
|     }
 | |
|     if ($isElementNode(lastNode)) {
 | |
|       let lastNodeDescendant =
 | |
|         lastNode.getDescendantByIndex<ElementNode>(endOffset);
 | |
|       // We don't want to over-select, as node selection infers the child before
 | |
|       // the last descendant, not including that descendant.
 | |
|       if (
 | |
|         lastNodeDescendant !== null &&
 | |
|         lastNodeDescendant !== firstNode &&
 | |
|         lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
 | |
|       ) {
 | |
|         lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
 | |
|       }
 | |
|       lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
 | |
|     }
 | |
| 
 | |
|     let nodes: Array<LexicalNode>;
 | |
| 
 | |
|     if (firstNode.is(lastNode)) {
 | |
|       if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
 | |
|         nodes = [];
 | |
|       } else {
 | |
|         nodes = [firstNode];
 | |
|       }
 | |
|     } else {
 | |
|       nodes = firstNode.getNodesBetween(lastNode);
 | |
|     }
 | |
|     if (!isCurrentlyReadOnlyMode()) {
 | |
|       this._cachedNodes = nodes;
 | |
|     }
 | |
|     return nodes;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Sets this Selection to be of type "text" at the provided anchor and focus values.
 | |
|    *
 | |
|    * @param anchorNode - the anchor node to set on the Selection
 | |
|    * @param anchorOffset - the offset to set on the Selection
 | |
|    * @param focusNode - the focus node to set on the Selection
 | |
|    * @param focusOffset - the focus offset to set on the Selection
 | |
|    */
 | |
|   setTextNodeRange(
 | |
|     anchorNode: TextNode,
 | |
|     anchorOffset: number,
 | |
|     focusNode: TextNode,
 | |
|     focusOffset: number,
 | |
|   ): void {
 | |
|     $setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
 | |
|     $setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
 | |
|     this._cachedNodes = null;
 | |
|     this.dirty = true;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Gets the (plain) text content of all the nodes in the selection.
 | |
|    *
 | |
|    * @returns a string representing the text content of all the nodes in the Selection
 | |
|    */
 | |
|   getTextContent(): string {
 | |
|     const nodes = this.getNodes();
 | |
|     if (nodes.length === 0) {
 | |
|       return '';
 | |
|     }
 | |
|     const firstNode = nodes[0];
 | |
|     const lastNode = nodes[nodes.length - 1];
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     const isBefore = anchor.isBefore(focus);
 | |
|     const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
 | |
|     let textContent = '';
 | |
|     let prevWasElement = true;
 | |
|     for (let i = 0; i < nodes.length; i++) {
 | |
|       const node = nodes[i];
 | |
|       if ($isElementNode(node) && !node.isInline()) {
 | |
|         if (!prevWasElement) {
 | |
|           textContent += '\n';
 | |
|         }
 | |
|         if (node.isEmpty()) {
 | |
|           prevWasElement = false;
 | |
|         } else {
 | |
|           prevWasElement = true;
 | |
|         }
 | |
|       } else {
 | |
|         prevWasElement = false;
 | |
|         if ($isTextNode(node)) {
 | |
|           let text = node.getTextContent();
 | |
|           if (node === firstNode) {
 | |
|             if (node === lastNode) {
 | |
|               if (
 | |
|                 anchor.type !== 'element' ||
 | |
|                 focus.type !== 'element' ||
 | |
|                 focus.offset === anchor.offset
 | |
|               ) {
 | |
|                 text =
 | |
|                   anchorOffset < focusOffset
 | |
|                     ? text.slice(anchorOffset, focusOffset)
 | |
|                     : text.slice(focusOffset, anchorOffset);
 | |
|               }
 | |
|             } else {
 | |
|               text = isBefore
 | |
|                 ? text.slice(anchorOffset)
 | |
|                 : text.slice(focusOffset);
 | |
|             }
 | |
|           } else if (node === lastNode) {
 | |
|             text = isBefore
 | |
|               ? text.slice(0, focusOffset)
 | |
|               : text.slice(0, anchorOffset);
 | |
|           }
 | |
|           textContent += text;
 | |
|         } else if (
 | |
|           ($isDecoratorNode(node) || $isLineBreakNode(node)) &&
 | |
|           (node !== lastNode || !this.isCollapsed())
 | |
|         ) {
 | |
|           textContent += node.getTextContent();
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     return textContent;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Attempts to map a DOM selection range onto this Lexical Selection,
 | |
|    * setting the anchor, focus, and type accordingly
 | |
|    *
 | |
|    * @param range a DOM Selection range conforming to the StaticRange interface.
 | |
|    */
 | |
|   applyDOMRange(range: StaticRange): void {
 | |
|     const editor = getActiveEditor();
 | |
|     const currentEditorState = editor.getEditorState();
 | |
|     const lastSelection = currentEditorState._selection;
 | |
|     const resolvedSelectionPoints = $internalResolveSelectionPoints(
 | |
|       range.startContainer,
 | |
|       range.startOffset,
 | |
|       range.endContainer,
 | |
|       range.endOffset,
 | |
|       editor,
 | |
|       lastSelection,
 | |
|     );
 | |
|     if (resolvedSelectionPoints === null) {
 | |
|       return;
 | |
|     }
 | |
|     const [anchorPoint, focusPoint] = resolvedSelectionPoints;
 | |
|     $setPointValues(
 | |
|       this.anchor,
 | |
|       anchorPoint.key,
 | |
|       anchorPoint.offset,
 | |
|       anchorPoint.type,
 | |
|     );
 | |
|     $setPointValues(
 | |
|       this.focus,
 | |
|       focusPoint.key,
 | |
|       focusPoint.offset,
 | |
|       focusPoint.type,
 | |
|     );
 | |
|     this._cachedNodes = null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Creates a new RangeSelection, copying over all the property values from this one.
 | |
|    *
 | |
|    * @returns a new RangeSelection with the same property values as this one.
 | |
|    */
 | |
|   clone(): RangeSelection {
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     const selection = new RangeSelection(
 | |
|       $createPoint(anchor.key, anchor.offset, anchor.type),
 | |
|       $createPoint(focus.key, focus.offset, focus.type),
 | |
|       this.format,
 | |
|       this.style,
 | |
|     );
 | |
|     return selection;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Toggles the provided format on all the TextNodes in the Selection.
 | |
|    *
 | |
|    * @param format a string TextFormatType to toggle on the TextNodes in the selection
 | |
|    */
 | |
|   toggleFormat(format: TextFormatType): void {
 | |
|     this.format = toggleTextFormatType(this.format, format, null);
 | |
|     this.dirty = true;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Sets the value of the style property on the Selection
 | |
|    *
 | |
|    * @param style - the style to set at the value of the style property.
 | |
|    */
 | |
|   setStyle(style: string): void {
 | |
|     this.style = style;
 | |
|     this.dirty = true;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
 | |
|    * has the specified format.
 | |
|    *
 | |
|    * @param type the TextFormatType to check for.
 | |
|    * @returns true if the provided format is currently toggled on on the Selection, false otherwise.
 | |
|    */
 | |
|   hasFormat(type: TextFormatType): boolean {
 | |
|     const formatFlag = TEXT_TYPE_TO_FORMAT[type];
 | |
|     return (this.format & formatFlag) !== 0;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Attempts to insert the provided text into the EditorState at the current Selection.
 | |
|    * converts tabs, newlines, and carriage returns into LexicalNodes.
 | |
|    *
 | |
|    * @param text the text to insert into the Selection
 | |
|    */
 | |
|   insertRawText(text: string): void {
 | |
|     const parts = text.split(/(\r?\n|\t)/);
 | |
|     const nodes = [];
 | |
|     const length = parts.length;
 | |
|     for (let i = 0; i < length; i++) {
 | |
|       const part = parts[i];
 | |
|       if (part === '\n' || part === '\r\n') {
 | |
|         nodes.push($createLineBreakNode());
 | |
|       } else if (part === '\t') {
 | |
|         nodes.push($createTabNode());
 | |
|       } else {
 | |
|         nodes.push($createTextNode(part));
 | |
|       }
 | |
|     }
 | |
|     this.insertNodes(nodes);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Attempts to insert the provided text into the EditorState at the current Selection as a new
 | |
|    * Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
 | |
|    *
 | |
|    * @param text the text to insert into the Selection
 | |
|    */
 | |
|   insertText(text: string): void {
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     const format = this.format;
 | |
|     const style = this.style;
 | |
|     let firstPoint = anchor;
 | |
|     let endPoint = focus;
 | |
|     if (!this.isCollapsed() && focus.isBefore(anchor)) {
 | |
|       firstPoint = focus;
 | |
|       endPoint = anchor;
 | |
|     }
 | |
|     if (firstPoint.type === 'element') {
 | |
|       $transferStartingElementPointToTextPoint(
 | |
|         firstPoint,
 | |
|         endPoint,
 | |
|         format,
 | |
|         style,
 | |
|       );
 | |
|     }
 | |
|     const startOffset = firstPoint.offset;
 | |
|     let endOffset = endPoint.offset;
 | |
|     const selectedNodes = this.getNodes();
 | |
|     const selectedNodesLength = selectedNodes.length;
 | |
|     let firstNode: TextNode = selectedNodes[0] as TextNode;
 | |
| 
 | |
|     if (!$isTextNode(firstNode)) {
 | |
|       invariant(false, 'insertText: first node is not a text node');
 | |
|     }
 | |
|     const firstNodeText = firstNode.getTextContent();
 | |
|     const firstNodeTextLength = firstNodeText.length;
 | |
|     const firstNodeParent = firstNode.getParentOrThrow();
 | |
|     const lastIndex = selectedNodesLength - 1;
 | |
|     let lastNode = selectedNodes[lastIndex];
 | |
| 
 | |
|     if (selectedNodesLength === 1 && endPoint.type === 'element') {
 | |
|       endOffset = firstNodeTextLength;
 | |
|       endPoint.set(firstPoint.key, endOffset, 'text');
 | |
|     }
 | |
| 
 | |
|     if (
 | |
|       this.isCollapsed() &&
 | |
|       startOffset === firstNodeTextLength &&
 | |
|       (firstNode.isSegmented() ||
 | |
|         firstNode.isToken() ||
 | |
|         !firstNode.canInsertTextAfter() ||
 | |
|         (!firstNodeParent.canInsertTextAfter() &&
 | |
|           firstNode.getNextSibling() === null))
 | |
|     ) {
 | |
|       let nextSibling = firstNode.getNextSibling<TextNode>();
 | |
|       if (
 | |
|         !$isTextNode(nextSibling) ||
 | |
|         !nextSibling.canInsertTextBefore() ||
 | |
|         $isTokenOrSegmented(nextSibling)
 | |
|       ) {
 | |
|         nextSibling = $createTextNode();
 | |
|         nextSibling.setFormat(format);
 | |
|         nextSibling.setStyle(style);
 | |
|         if (!firstNodeParent.canInsertTextAfter()) {
 | |
|           firstNodeParent.insertAfter(nextSibling);
 | |
|         } else {
 | |
|           firstNode.insertAfter(nextSibling);
 | |
|         }
 | |
|       }
 | |
|       nextSibling.select(0, 0);
 | |
|       firstNode = nextSibling;
 | |
|       if (text !== '') {
 | |
|         this.insertText(text);
 | |
|         return;
 | |
|       }
 | |
|     } else if (
 | |
|       this.isCollapsed() &&
 | |
|       startOffset === 0 &&
 | |
|       (firstNode.isSegmented() ||
 | |
|         firstNode.isToken() ||
 | |
|         !firstNode.canInsertTextBefore() ||
 | |
|         (!firstNodeParent.canInsertTextBefore() &&
 | |
|           firstNode.getPreviousSibling() === null))
 | |
|     ) {
 | |
|       let prevSibling = firstNode.getPreviousSibling<TextNode>();
 | |
|       if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
 | |
|         prevSibling = $createTextNode();
 | |
|         prevSibling.setFormat(format);
 | |
|         if (!firstNodeParent.canInsertTextBefore()) {
 | |
|           firstNodeParent.insertBefore(prevSibling);
 | |
|         } else {
 | |
|           firstNode.insertBefore(prevSibling);
 | |
|         }
 | |
|       }
 | |
|       prevSibling.select();
 | |
|       firstNode = prevSibling;
 | |
|       if (text !== '') {
 | |
|         this.insertText(text);
 | |
|         return;
 | |
|       }
 | |
|     } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
 | |
|       const textNode = $createTextNode(firstNode.getTextContent());
 | |
|       textNode.setFormat(format);
 | |
|       firstNode.replace(textNode);
 | |
|       firstNode = textNode;
 | |
|     } else if (!this.isCollapsed() && text !== '') {
 | |
|       // When the firstNode or lastNode parents are elements that
 | |
|       // do not allow text to be inserted before or after, we first
 | |
|       // clear the content. Then we normalize selection, then insert
 | |
|       // the new content.
 | |
|       const lastNodeParent = lastNode.getParent();
 | |
| 
 | |
|       if (
 | |
|         !firstNodeParent.canInsertTextBefore() ||
 | |
|         !firstNodeParent.canInsertTextAfter() ||
 | |
|         ($isElementNode(lastNodeParent) &&
 | |
|           (!lastNodeParent.canInsertTextBefore() ||
 | |
|             !lastNodeParent.canInsertTextAfter()))
 | |
|       ) {
 | |
|         this.insertText('');
 | |
|         $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
 | |
|         this.insertText(text);
 | |
|         return;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (selectedNodesLength === 1) {
 | |
|       if (firstNode.isToken()) {
 | |
|         const textNode = $createTextNode(text);
 | |
|         textNode.select();
 | |
|         firstNode.replace(textNode);
 | |
|         return;
 | |
|       }
 | |
|       const firstNodeFormat = firstNode.getFormat();
 | |
|       const firstNodeStyle = firstNode.getStyle();
 | |
| 
 | |
|       if (
 | |
|         startOffset === endOffset &&
 | |
|         (firstNodeFormat !== format || firstNodeStyle !== style)
 | |
|       ) {
 | |
|         if (firstNode.getTextContent() === '') {
 | |
|           firstNode.setFormat(format);
 | |
|           firstNode.setStyle(style);
 | |
|         } else {
 | |
|           const textNode = $createTextNode(text);
 | |
|           textNode.setFormat(format);
 | |
|           textNode.setStyle(style);
 | |
|           textNode.select();
 | |
|           if (startOffset === 0) {
 | |
|             firstNode.insertBefore(textNode, false);
 | |
|           } else {
 | |
|             const [targetNode] = firstNode.splitText(startOffset);
 | |
|             targetNode.insertAfter(textNode, false);
 | |
|           }
 | |
|           // When composing, we need to adjust the anchor offset so that
 | |
|           // we correctly replace that right range.
 | |
|           if (textNode.isComposing() && this.anchor.type === 'text') {
 | |
|             this.anchor.offset -= text.length;
 | |
|           }
 | |
|           return;
 | |
|         }
 | |
|       } else if ($isTabNode(firstNode)) {
 | |
|         // We don't need to check for delCount because there is only the entire selected node case
 | |
|         // that can hit here for content size 1 and with canInsertTextBeforeAfter false
 | |
|         const textNode = $createTextNode(text);
 | |
|         textNode.setFormat(format);
 | |
|         textNode.setStyle(style);
 | |
|         textNode.select();
 | |
|         firstNode.replace(textNode);
 | |
|         return;
 | |
|       }
 | |
|       const delCount = endOffset - startOffset;
 | |
| 
 | |
|       firstNode = firstNode.spliceText(startOffset, delCount, text, true);
 | |
|       if (firstNode.getTextContent() === '') {
 | |
|         firstNode.remove();
 | |
|       } else if (this.anchor.type === 'text') {
 | |
|         if (firstNode.isComposing()) {
 | |
|           // When composing, we need to adjust the anchor offset so that
 | |
|           // we correctly replace that right range.
 | |
|           this.anchor.offset -= text.length;
 | |
|         } else {
 | |
|           this.format = firstNodeFormat;
 | |
|           this.style = firstNodeStyle;
 | |
|         }
 | |
|       }
 | |
|     } else {
 | |
|       const markedNodeKeysForKeep = new Set([
 | |
|         ...firstNode.getParentKeys(),
 | |
|         ...lastNode.getParentKeys(),
 | |
|       ]);
 | |
| 
 | |
|       // We have to get the parent elements before the next section,
 | |
|       // as in that section we might mutate the lastNode.
 | |
|       const firstElement = $isElementNode(firstNode)
 | |
|         ? firstNode
 | |
|         : firstNode.getParentOrThrow();
 | |
|       let lastElement = $isElementNode(lastNode)
 | |
|         ? lastNode
 | |
|         : lastNode.getParentOrThrow();
 | |
|       let lastElementChild = lastNode;
 | |
| 
 | |
|       // If the last element is inline, we should instead look at getting
 | |
|       // the nodes of its parent, rather than itself. This behavior will
 | |
|       // then better match how text node insertions work. We will need to
 | |
|       // also update the last element's child accordingly as we do this.
 | |
|       if (!firstElement.is(lastElement) && lastElement.isInline()) {
 | |
|         // Keep traversing till we have a non-inline element parent.
 | |
|         do {
 | |
|           lastElementChild = lastElement;
 | |
|           lastElement = lastElement.getParentOrThrow();
 | |
|         } while (lastElement.isInline());
 | |
|       }
 | |
| 
 | |
|       // Handle mutations to the last node.
 | |
|       if (
 | |
|         (endPoint.type === 'text' &&
 | |
|           (endOffset !== 0 || lastNode.getTextContent() === '')) ||
 | |
|         (endPoint.type === 'element' &&
 | |
|           lastNode.getIndexWithinParent() < endOffset)
 | |
|       ) {
 | |
|         if (
 | |
|           $isTextNode(lastNode) &&
 | |
|           !lastNode.isToken() &&
 | |
|           endOffset !== lastNode.getTextContentSize()
 | |
|         ) {
 | |
|           if (lastNode.isSegmented()) {
 | |
|             const textNode = $createTextNode(lastNode.getTextContent());
 | |
|             lastNode.replace(textNode);
 | |
|             lastNode = textNode;
 | |
|           }
 | |
|           // root node selections only select whole nodes, so no text splice is necessary
 | |
|           if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
 | |
|             lastNode = (lastNode as TextNode).spliceText(0, endOffset, '');
 | |
|           }
 | |
|           markedNodeKeysForKeep.add(lastNode.__key);
 | |
|         } else {
 | |
|           const lastNodeParent = lastNode.getParentOrThrow();
 | |
|           if (
 | |
|             !lastNodeParent.canBeEmpty() &&
 | |
|             lastNodeParent.getChildrenSize() === 1
 | |
|           ) {
 | |
|             lastNodeParent.remove();
 | |
|           } else {
 | |
|             lastNode.remove();
 | |
|           }
 | |
|         }
 | |
|       } else {
 | |
|         markedNodeKeysForKeep.add(lastNode.__key);
 | |
|       }
 | |
| 
 | |
|       // Either move the remaining nodes of the last parent to after
 | |
|       // the first child, or remove them entirely. If the last parent
 | |
|       // is the same as the first parent, this logic also works.
 | |
|       const lastNodeChildren = lastElement.getChildren();
 | |
|       const selectedNodesSet = new Set(selectedNodes);
 | |
|       const firstAndLastElementsAreEqual = firstElement.is(lastElement);
 | |
| 
 | |
|       // We choose a target to insert all nodes after. In the case of having
 | |
|       // and inline starting parent element with a starting node that has no
 | |
|       // siblings, we should insert after the starting parent element, otherwise
 | |
|       // we will incorrectly merge into the starting parent element.
 | |
|       // TODO: should we keep on traversing parents if we're inside another
 | |
|       // nested inline element?
 | |
|       const insertionTarget =
 | |
|         firstElement.isInline() && firstNode.getNextSibling() === null
 | |
|           ? firstElement
 | |
|           : firstNode;
 | |
| 
 | |
|       for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
 | |
|         const lastNodeChild = lastNodeChildren[i];
 | |
| 
 | |
|         if (
 | |
|           lastNodeChild.is(firstNode) ||
 | |
|           ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
 | |
|         ) {
 | |
|           break;
 | |
|         }
 | |
| 
 | |
|         if (lastNodeChild.isAttached()) {
 | |
|           if (
 | |
|             !selectedNodesSet.has(lastNodeChild) ||
 | |
|             lastNodeChild.is(lastElementChild)
 | |
|           ) {
 | |
|             if (!firstAndLastElementsAreEqual) {
 | |
|               insertionTarget.insertAfter(lastNodeChild, false);
 | |
|             }
 | |
|           } else {
 | |
|             lastNodeChild.remove();
 | |
|           }
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (!firstAndLastElementsAreEqual) {
 | |
|         // Check if we have already moved out all the nodes of the
 | |
|         // last parent, and if so, traverse the parent tree and mark
 | |
|         // them all as being able to deleted too.
 | |
|         let parent: ElementNode | null = lastElement;
 | |
|         let lastRemovedParent = null;
 | |
| 
 | |
|         while (parent !== null) {
 | |
|           const children = parent.getChildren();
 | |
|           const childrenLength = children.length;
 | |
|           if (
 | |
|             childrenLength === 0 ||
 | |
|             children[childrenLength - 1].is(lastRemovedParent)
 | |
|           ) {
 | |
|             markedNodeKeysForKeep.delete(parent.__key);
 | |
|             lastRemovedParent = parent;
 | |
|           }
 | |
|           parent = parent.getParent();
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // Ensure we do splicing after moving of nodes, as splicing
 | |
|       // can have side-effects (in the case of hashtags).
 | |
|       if (!firstNode.isToken()) {
 | |
|         firstNode = firstNode.spliceText(
 | |
|           startOffset,
 | |
|           firstNodeTextLength - startOffset,
 | |
|           text,
 | |
|           true,
 | |
|         );
 | |
|         if (firstNode.getTextContent() === '') {
 | |
|           firstNode.remove();
 | |
|         } else if (firstNode.isComposing() && this.anchor.type === 'text') {
 | |
|           // When composing, we need to adjust the anchor offset so that
 | |
|           // we correctly replace that right range.
 | |
|           this.anchor.offset -= text.length;
 | |
|         }
 | |
|       } else if (startOffset === firstNodeTextLength) {
 | |
|         firstNode.select();
 | |
|       } else {
 | |
|         const textNode = $createTextNode(text);
 | |
|         textNode.select();
 | |
|         firstNode.replace(textNode);
 | |
|       }
 | |
| 
 | |
|       // Remove all selected nodes that haven't already been removed.
 | |
|       for (let i = 1; i < selectedNodesLength; i++) {
 | |
|         const selectedNode = selectedNodes[i];
 | |
|         const key = selectedNode.__key;
 | |
|         if (!markedNodeKeysForKeep.has(key)) {
 | |
|           selectedNode.remove();
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Removes the text in the Selection, adjusting the EditorState accordingly.
 | |
|    */
 | |
|   removeText(): void {
 | |
|     this.insertText('');
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Applies the provided format to the TextNodes in the Selection, splitting or
 | |
|    * merging nodes as necessary.
 | |
|    *
 | |
|    * @param formatType the format type to apply to the nodes in the Selection.
 | |
|    */
 | |
|   formatText(formatType: TextFormatType): void {
 | |
|     if (this.isCollapsed()) {
 | |
|       this.toggleFormat(formatType);
 | |
|       // When changing format, we should stop composition
 | |
|       $setCompositionKey(null);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const selectedNodes = this.getNodes();
 | |
|     const selectedTextNodes: Array<TextNode> = [];
 | |
|     for (const selectedNode of selectedNodes) {
 | |
|       if ($isTextNode(selectedNode)) {
 | |
|         selectedTextNodes.push(selectedNode);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const selectedTextNodesLength = selectedTextNodes.length;
 | |
|     if (selectedTextNodesLength === 0) {
 | |
|       this.toggleFormat(formatType);
 | |
|       // When changing format, we should stop composition
 | |
|       $setCompositionKey(null);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     const isBackward = this.isBackward();
 | |
|     const startPoint = isBackward ? focus : anchor;
 | |
|     const endPoint = isBackward ? anchor : focus;
 | |
| 
 | |
|     let firstIndex = 0;
 | |
|     let firstNode = selectedTextNodes[0];
 | |
|     let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
 | |
| 
 | |
|     // In case selection started at the end of text node use next text node
 | |
|     if (
 | |
|       startPoint.type === 'text' &&
 | |
|       startOffset === firstNode.getTextContentSize()
 | |
|     ) {
 | |
|       firstIndex = 1;
 | |
|       firstNode = selectedTextNodes[1];
 | |
|       startOffset = 0;
 | |
|     }
 | |
| 
 | |
|     if (firstNode == null) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const firstNextFormat = firstNode.getFormatFlags(formatType, null);
 | |
| 
 | |
|     const lastIndex = selectedTextNodesLength - 1;
 | |
|     let lastNode = selectedTextNodes[lastIndex];
 | |
|     const endOffset =
 | |
|       endPoint.type === 'text'
 | |
|         ? endPoint.offset
 | |
|         : lastNode.getTextContentSize();
 | |
| 
 | |
|     // Single node selected
 | |
|     if (firstNode.is(lastNode)) {
 | |
|       // No actual text is selected, so do nothing.
 | |
|       if (startOffset === endOffset) {
 | |
|         return;
 | |
|       }
 | |
|       // The entire node is selected or it is token, so just format it
 | |
|       if (
 | |
|         $isTokenOrSegmented(firstNode) ||
 | |
|         (startOffset === 0 && endOffset === firstNode.getTextContentSize())
 | |
|       ) {
 | |
|         firstNode.setFormat(firstNextFormat);
 | |
|       } else {
 | |
|         // Node is partially selected, so split it into two nodes
 | |
|         // add style the selected one.
 | |
|         const splitNodes = firstNode.splitText(startOffset, endOffset);
 | |
|         const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
 | |
|         replacement.setFormat(firstNextFormat);
 | |
| 
 | |
|         // Update selection only if starts/ends on text node
 | |
|         if (startPoint.type === 'text') {
 | |
|           startPoint.set(replacement.__key, 0, 'text');
 | |
|         }
 | |
|         if (endPoint.type === 'text') {
 | |
|           endPoint.set(replacement.__key, endOffset - startOffset, 'text');
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       this.format = firstNextFormat;
 | |
| 
 | |
|       return;
 | |
|     }
 | |
|     // Multiple nodes selected
 | |
|     // The entire first node isn't selected, so split it
 | |
|     if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
 | |
|       [, firstNode as TextNode] = firstNode.splitText(startOffset);
 | |
|       startOffset = 0;
 | |
|     }
 | |
|     firstNode.setFormat(firstNextFormat);
 | |
| 
 | |
|     const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
 | |
|     // If the offset is 0, it means no actual characters are selected,
 | |
|     // so we skip formatting the last node altogether.
 | |
|     if (endOffset > 0) {
 | |
|       if (
 | |
|         endOffset !== lastNode.getTextContentSize() &&
 | |
|         !$isTokenOrSegmented(lastNode)
 | |
|       ) {
 | |
|         [lastNode as TextNode] = lastNode.splitText(endOffset);
 | |
|       }
 | |
|       lastNode.setFormat(lastNextFormat);
 | |
|     }
 | |
| 
 | |
|     // Process all text nodes in between
 | |
|     for (let i = firstIndex + 1; i < lastIndex; i++) {
 | |
|       const textNode = selectedTextNodes[i];
 | |
|       const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
 | |
|       textNode.setFormat(nextFormat);
 | |
|     }
 | |
| 
 | |
|     // Update selection only if starts/ends on text node
 | |
|     if (startPoint.type === 'text') {
 | |
|       startPoint.set(firstNode.__key, startOffset, 'text');
 | |
|     }
 | |
|     if (endPoint.type === 'text') {
 | |
|       endPoint.set(lastNode.__key, endOffset, 'text');
 | |
|     }
 | |
| 
 | |
|     this.format = firstNextFormat | lastNextFormat;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
 | |
|    * current Selection according to a set of heuristics that determine how surrounding nodes
 | |
|    * should be changed, replaced, or moved to accomodate the incoming ones.
 | |
|    *
 | |
|    * @param nodes - the nodes to insert
 | |
|    */
 | |
|   insertNodes(nodes: Array<LexicalNode>): void {
 | |
|     if (nodes.length === 0) {
 | |
|       return;
 | |
|     }
 | |
|     if (this.anchor.key === 'root') {
 | |
|       this.insertParagraph();
 | |
|       const selection = $getSelection();
 | |
|       invariant(
 | |
|         $isRangeSelection(selection),
 | |
|         'Expected RangeSelection after insertParagraph',
 | |
|       );
 | |
|       return selection.insertNodes(nodes);
 | |
|     }
 | |
| 
 | |
|     const firstPoint = this.isBackward() ? this.focus : this.anchor;
 | |
|     const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!;
 | |
| 
 | |
|     const last = nodes[nodes.length - 1]!;
 | |
| 
 | |
|     // CASE 1: insert inside a code block
 | |
|     if ('__language' in firstBlock && $isElementNode(firstBlock)) {
 | |
|       if ('__language' in nodes[0]) {
 | |
|         this.insertText(nodes[0].getTextContent());
 | |
|       } else {
 | |
|         const index = $removeTextAndSplitBlock(this);
 | |
|         firstBlock.splice(index, 0, nodes);
 | |
|         last.selectEnd();
 | |
|       }
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // CASE 2: All elements of the array are inline
 | |
|     const notInline = (node: LexicalNode) =>
 | |
|       ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
 | |
| 
 | |
|     if (!nodes.some(notInline)) {
 | |
|       invariant(
 | |
|         $isElementNode(firstBlock),
 | |
|         "Expected 'firstBlock' to be an ElementNode",
 | |
|       );
 | |
|       const index = $removeTextAndSplitBlock(this);
 | |
|       firstBlock.splice(index, 0, nodes);
 | |
|       last.selectEnd();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // CASE 3: At least 1 element of the array is not inline
 | |
|     const blocksParent = $wrapInlineNodes(nodes);
 | |
|     const nodeToSelect = blocksParent.getLastDescendant()!;
 | |
|     const blocks = blocksParent.getChildren();
 | |
|     const isMergeable = (node: LexicalNode): node is ElementNode =>
 | |
|       $isElementNode(node) &&
 | |
|       INTERNAL_$isBlock(node) &&
 | |
|       !node.isEmpty() &&
 | |
|       $isElementNode(firstBlock) &&
 | |
|       (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());
 | |
| 
 | |
|     const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
 | |
|     const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
 | |
|     const lastToInsert = blocks[blocks.length - 1];
 | |
|     let firstToInsert = blocks[0];
 | |
|     if (isMergeable(firstToInsert)) {
 | |
|       invariant(
 | |
|         $isElementNode(firstBlock),
 | |
|         "Expected 'firstBlock' to be an ElementNode",
 | |
|       );
 | |
|       firstBlock.append(...firstToInsert.getChildren());
 | |
|       firstToInsert = blocks[1];
 | |
|     }
 | |
|     if (firstToInsert) {
 | |
|       insertRangeAfter(firstBlock, firstToInsert);
 | |
|     }
 | |
|     const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!;
 | |
| 
 | |
|     if (
 | |
|       insertedParagraph &&
 | |
|       $isElementNode(lastInsertedBlock) &&
 | |
|       (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
 | |
|     ) {
 | |
|       lastInsertedBlock.append(...insertedParagraph.getChildren());
 | |
|       insertedParagraph.remove();
 | |
|     }
 | |
|     if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
 | |
|       firstBlock.remove();
 | |
|     }
 | |
| 
 | |
|     nodeToSelect.selectEnd();
 | |
| 
 | |
|     // To understand this take a look at the test "can wrap post-linebreak nodes into new element"
 | |
|     const lastChild = $isElementNode(firstBlock)
 | |
|       ? firstBlock.getLastChild()
 | |
|       : null;
 | |
|     if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
 | |
|       lastChild.remove();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Inserts a new ParagraphNode into the EditorState at the current Selection
 | |
|    *
 | |
|    * @returns the newly inserted node.
 | |
|    */
 | |
|   insertParagraph(): ElementNode | null {
 | |
|     if (this.anchor.key === 'root') {
 | |
|       const paragraph = $createParagraphNode();
 | |
|       $getRoot().splice(this.anchor.offset, 0, [paragraph]);
 | |
|       paragraph.select();
 | |
|       return paragraph;
 | |
|     }
 | |
|     const index = $removeTextAndSplitBlock(this);
 | |
|     const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!;
 | |
|     invariant($isElementNode(block), 'Expected ancestor to be an ElementNode');
 | |
|     const firstToAppend = block.getChildAtIndex(index);
 | |
|     const nodesToInsert = firstToAppend
 | |
|       ? [firstToAppend, ...firstToAppend.getNextSiblings()]
 | |
|       : [];
 | |
|     const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
 | |
|     if (newBlock) {
 | |
|       newBlock.append(...nodesToInsert);
 | |
|       newBlock.selectStart();
 | |
|       return newBlock;
 | |
|     }
 | |
|     // if newBlock is null, it means that block is of type CodeNode.
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
 | |
|    * current Selection.
 | |
|    */
 | |
|   insertLineBreak(selectStart?: boolean): void {
 | |
|     const lineBreak = $createLineBreakNode();
 | |
|     this.insertNodes([lineBreak]);
 | |
|     // this is used in MacOS with the command 'ctrl-O' (openLineBreak)
 | |
|     if (selectStart) {
 | |
|       const parent = lineBreak.getParentOrThrow();
 | |
|       const index = lineBreak.getIndexWithinParent();
 | |
|       parent.select(index, index);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Extracts the nodes in the Selection, splitting nodes where necessary
 | |
|    * to get offset-level precision.
 | |
|    *
 | |
|    * @returns The nodes in the Selection
 | |
|    */
 | |
|   extract(): Array<LexicalNode> {
 | |
|     const selectedNodes = this.getNodes();
 | |
|     const selectedNodesLength = selectedNodes.length;
 | |
|     const lastIndex = selectedNodesLength - 1;
 | |
|     const anchor = this.anchor;
 | |
|     const focus = this.focus;
 | |
|     let firstNode = selectedNodes[0];
 | |
|     let lastNode = selectedNodes[lastIndex];
 | |
|     const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
 | |
| 
 | |
|     if (selectedNodesLength === 0) {
 | |
|       return [];
 | |
|     } else if (selectedNodesLength === 1) {
 | |
|       if ($isTextNode(firstNode) && !this.isCollapsed()) {
 | |
|         const startOffset =
 | |
|           anchorOffset > focusOffset ? focusOffset : anchorOffset;
 | |
|         const endOffset =
 | |
|           anchorOffset > focusOffset ? anchorOffset : focusOffset;
 | |
|         const splitNodes = firstNode.splitText(startOffset, endOffset);
 | |
|         const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
 | |
|         return node != null ? [node] : [];
 | |
|       }
 | |
|       return [firstNode];
 | |
|     }
 | |
|     const isBefore = anchor.isBefore(focus);
 | |
| 
 | |
|     if ($isTextNode(firstNode)) {
 | |
|       const startOffset = isBefore ? anchorOffset : focusOffset;
 | |
|       if (startOffset === firstNode.getTextContentSize()) {
 | |
|         selectedNodes.shift();
 | |
|       } else if (startOffset !== 0) {
 | |
|         [, firstNode] = firstNode.splitText(startOffset);
 | |
|         selectedNodes[0] = firstNode;
 | |
|       }
 | |
|     }
 | |
|     if ($isTextNode(lastNode)) {
 | |
|       const lastNodeText = lastNode.getTextContent();
 | |
|       const lastNodeTextLength = lastNodeText.length;
 | |
|       const endOffset = isBefore ? focusOffset : anchorOffset;
 | |
|       if (endOffset === 0) {
 | |
|         selectedNodes.pop();
 | |
|       } else if (endOffset !== lastNodeTextLength) {
 | |
|         [lastNode] = lastNode.splitText(endOffset);
 | |
|         selectedNodes[lastIndex] = lastNode;
 | |
|       }
 | |
|     }
 | |
|     return selectedNodes;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Modifies the Selection according to the parameters and a set of heuristics that account for
 | |
|    * various node types. Can be used to safely move or extend selection by one logical "unit" without
 | |
|    * dealing explicitly with all the possible node types.
 | |
|    *
 | |
|    * @param alter the type of modification to perform
 | |
|    * @param isBackward whether or not selection is backwards
 | |
|    * @param granularity the granularity at which to apply the modification
 | |
|    */
 | |
|   modify(
 | |
|     alter: 'move' | 'extend',
 | |
|     isBackward: boolean,
 | |
|     granularity: 'character' | 'word' | 'lineboundary',
 | |
|   ): void {
 | |
|     const focus = this.focus;
 | |
|     const anchor = this.anchor;
 | |
|     const collapse = alter === 'move';
 | |
| 
 | |
|     // Handle the selection movement around decorators.
 | |
|     const possibleNode = $getAdjacentNode(focus, isBackward);
 | |
|     if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
 | |
|       // Make it possible to move selection from range selection to
 | |
|       // node selection on the node.
 | |
|       if (collapse && possibleNode.isKeyboardSelectable()) {
 | |
|         const nodeSelection = $createNodeSelection();
 | |
|         nodeSelection.add(possibleNode.__key);
 | |
|         $setSelection(nodeSelection);
 | |
|         return;
 | |
|       }
 | |
|       const sibling = isBackward
 | |
|         ? possibleNode.getPreviousSibling()
 | |
|         : possibleNode.getNextSibling();
 | |
| 
 | |
|       if (!$isTextNode(sibling)) {
 | |
|         const parent = possibleNode.getParentOrThrow();
 | |
|         let offset;
 | |
|         let elementKey;
 | |
| 
 | |
|         if ($isElementNode(sibling)) {
 | |
|           elementKey = sibling.__key;
 | |
|           offset = isBackward ? sibling.getChildrenSize() : 0;
 | |
|         } else {
 | |
|           offset = possibleNode.getIndexWithinParent();
 | |
|           elementKey = parent.__key;
 | |
|           if (!isBackward) {
 | |
|             offset++;
 | |
|           }
 | |
|         }
 | |
|         focus.set(elementKey, offset, 'element');
 | |
|         if (collapse) {
 | |
|           anchor.set(elementKey, offset, 'element');
 | |
|         }
 | |
|         return;
 | |
|       } else {
 | |
|         const siblingKey = sibling.__key;
 | |
|         const offset = isBackward ? sibling.getTextContent().length : 0;
 | |
|         focus.set(siblingKey, offset, 'text');
 | |
|         if (collapse) {
 | |
|           anchor.set(siblingKey, offset, 'text');
 | |
|         }
 | |
|         return;
 | |
|       }
 | |
|     }
 | |
|     const editor = getActiveEditor();
 | |
|     const domSelection = getDOMSelection(editor._window);
 | |
| 
 | |
|     if (!domSelection) {
 | |
|       return;
 | |
|     }
 | |
|     const blockCursorElement = editor._blockCursorElement;
 | |
|     const rootElement = editor._rootElement;
 | |
|     // Remove the block cursor element if it exists. This will ensure selection
 | |
|     // works as intended. If we leave it in the DOM all sorts of strange bugs
 | |
|     // occur. :/
 | |
|     if (
 | |
|       rootElement !== null &&
 | |
|       blockCursorElement !== null &&
 | |
|       $isElementNode(possibleNode) &&
 | |
|       !possibleNode.isInline() &&
 | |
|       !possibleNode.canBeEmpty()
 | |
|     ) {
 | |
|       removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
 | |
|     }
 | |
|     // We use the DOM selection.modify API here to "tell" us what the selection
 | |
|     // will be. We then use it to update the Lexical selection accordingly. This
 | |
|     // is much more reliable than waiting for a beforeinput and using the ranges
 | |
|     // from getTargetRanges(), and is also better than trying to do it ourselves
 | |
|     // using Intl.Segmenter or other workarounds that struggle with word segments
 | |
|     // and line segments (especially with word wrapping and non-Roman languages).
 | |
|     moveNativeSelection(
 | |
|       domSelection,
 | |
|       alter,
 | |
|       isBackward ? 'backward' : 'forward',
 | |
|       granularity,
 | |
|     );
 | |
|     // Guard against no ranges
 | |
|     if (domSelection.rangeCount > 0) {
 | |
|       const range = domSelection.getRangeAt(0);
 | |
|       // Apply the DOM selection to our Lexical selection.
 | |
|       const anchorNode = this.anchor.getNode();
 | |
|       const root = $isRootNode(anchorNode)
 | |
|         ? anchorNode
 | |
|         : $getNearestRootOrShadowRoot(anchorNode);
 | |
|       this.applyDOMRange(range);
 | |
|       this.dirty = true;
 | |
|       if (!collapse) {
 | |
|         // Validate selection; make sure that the new extended selection respects shadow roots
 | |
|         const nodes = this.getNodes();
 | |
|         const validNodes = [];
 | |
|         let shrinkSelection = false;
 | |
|         for (let i = 0; i < nodes.length; i++) {
 | |
|           const nextNode = nodes[i];
 | |
|           if ($hasAncestor(nextNode, root)) {
 | |
|             validNodes.push(nextNode);
 | |
|           } else {
 | |
|             shrinkSelection = true;
 | |
|           }
 | |
|         }
 | |
|         if (shrinkSelection && validNodes.length > 0) {
 | |
|           // validNodes length check is a safeguard against an invalid selection; as getNodes()
 | |
|           // will return an empty array in this case
 | |
|           if (isBackward) {
 | |
|             const firstValidNode = validNodes[0];
 | |
|             if ($isElementNode(firstValidNode)) {
 | |
|               firstValidNode.selectStart();
 | |
|             } else {
 | |
|               firstValidNode.getParentOrThrow().selectStart();
 | |
|             }
 | |
|           } else {
 | |
|             const lastValidNode = validNodes[validNodes.length - 1];
 | |
|             if ($isElementNode(lastValidNode)) {
 | |
|               lastValidNode.selectEnd();
 | |
|             } else {
 | |
|               lastValidNode.getParentOrThrow().selectEnd();
 | |
|             }
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // Because a range works on start and end, we might need to flip
 | |
|         // the anchor and focus points to match what the DOM has, not what
 | |
|         // the range has specifically.
 | |
|         if (
 | |
|           domSelection.anchorNode !== range.startContainer ||
 | |
|           domSelection.anchorOffset !== range.startOffset
 | |
|         ) {
 | |
|           $swapPoints(this);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   /**
 | |
|    * Helper for handling forward character and word deletion that prevents element nodes
 | |
|    * like a table, columns layout being destroyed
 | |
|    *
 | |
|    * @param anchor the anchor
 | |
|    * @param anchorNode the anchor node in the selection
 | |
|    * @param isBackward whether or not selection is backwards
 | |
|    */
 | |
|   forwardDeletion(
 | |
|     anchor: PointType,
 | |
|     anchorNode: TextNode | ElementNode,
 | |
|     isBackward: boolean,
 | |
|   ): boolean {
 | |
|     if (
 | |
|       !isBackward &&
 | |
|       // Delete forward handle case
 | |
|       ((anchor.type === 'element' &&
 | |
|         $isElementNode(anchorNode) &&
 | |
|         anchor.offset === anchorNode.getChildrenSize()) ||
 | |
|         (anchor.type === 'text' &&
 | |
|           anchor.offset === anchorNode.getTextContentSize()))
 | |
|     ) {
 | |
|       const parent = anchorNode.getParent();
 | |
|       const nextSibling =
 | |
|         anchorNode.getNextSibling() ||
 | |
|         (parent === null ? null : parent.getNextSibling());
 | |
| 
 | |
|       if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
 | |
|         return true;
 | |
|       }
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Performs one logical character deletion operation on the EditorState based on the current Selection.
 | |
|    * Handles different node types.
 | |
|    *
 | |
|    * @param isBackward whether or not the selection is backwards.
 | |
|    */
 | |
|   deleteCharacter(isBackward: boolean): void {
 | |
|     const wasCollapsed = this.isCollapsed();
 | |
|     if (this.isCollapsed()) {
 | |
|       const anchor = this.anchor;
 | |
|       let anchorNode: TextNode | ElementNode | null = anchor.getNode();
 | |
|       if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Handle the deletion around decorators.
 | |
|       const focus = this.focus;
 | |
|       const possibleNode = $getAdjacentNode(focus, isBackward);
 | |
|       if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
 | |
|         // Make it possible to move selection from range selection to
 | |
|         // node selection on the node.
 | |
|         if (
 | |
|           possibleNode.isKeyboardSelectable() &&
 | |
|           $isElementNode(anchorNode) &&
 | |
|           anchorNode.getChildrenSize() === 0
 | |
|         ) {
 | |
|           anchorNode.remove();
 | |
|           const nodeSelection = $createNodeSelection();
 | |
|           nodeSelection.add(possibleNode.__key);
 | |
|           $setSelection(nodeSelection);
 | |
|         } else {
 | |
|           possibleNode.remove();
 | |
|           const editor = getActiveEditor();
 | |
|           editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
 | |
|         }
 | |
|         return;
 | |
|       } else if (
 | |
|         !isBackward &&
 | |
|         $isElementNode(possibleNode) &&
 | |
|         $isElementNode(anchorNode) &&
 | |
|         anchorNode.isEmpty()
 | |
|       ) {
 | |
|         anchorNode.remove();
 | |
|         possibleNode.selectStart();
 | |
|         return;
 | |
|       }
 | |
|       this.modify('extend', isBackward, 'character');
 | |
| 
 | |
|       if (!this.isCollapsed()) {
 | |
|         const focusNode = focus.type === 'text' ? focus.getNode() : null;
 | |
|         anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
 | |
| 
 | |
|         if (focusNode !== null && focusNode.isSegmented()) {
 | |
|           const offset = focus.offset;
 | |
|           const textContentSize = focusNode.getTextContentSize();
 | |
|           if (
 | |
|             focusNode.is(anchorNode) ||
 | |
|             (isBackward && offset !== textContentSize) ||
 | |
|             (!isBackward && offset !== 0)
 | |
|           ) {
 | |
|             $removeSegment(focusNode, isBackward, offset);
 | |
|             return;
 | |
|           }
 | |
|         } else if (anchorNode !== null && anchorNode.isSegmented()) {
 | |
|           const offset = anchor.offset;
 | |
|           const textContentSize = anchorNode.getTextContentSize();
 | |
|           if (
 | |
|             anchorNode.is(focusNode) ||
 | |
|             (isBackward && offset !== 0) ||
 | |
|             (!isBackward && offset !== textContentSize)
 | |
|           ) {
 | |
|             $removeSegment(anchorNode, isBackward, offset);
 | |
|             return;
 | |
|           }
 | |
|         }
 | |
|         $updateCaretSelectionForUnicodeCharacter(this, isBackward);
 | |
|       } else if (isBackward && anchor.offset === 0) {
 | |
|         // Special handling around rich text nodes
 | |
|         const element =
 | |
|           anchor.type === 'element'
 | |
|             ? anchor.getNode()
 | |
|             : anchor.getNode().getParentOrThrow();
 | |
|         if (element.collapseAtStart(this)) {
 | |
|           return;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     this.removeText();
 | |
|     if (
 | |
|       isBackward &&
 | |
|       !wasCollapsed &&
 | |
|       this.isCollapsed() &&
 | |
|       this.anchor.type === 'element' &&
 | |
|       this.anchor.offset === 0
 | |
|     ) {
 | |
|       const anchorNode = this.anchor.getNode();
 | |
|       if (
 | |
|         anchorNode.isEmpty() &&
 | |
|         $isRootNode(anchorNode.getParent()) &&
 | |
|         anchorNode.getIndexWithinParent() === 0
 | |
|       ) {
 | |
|         anchorNode.collapseAtStart(this);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Performs one logical line deletion operation on the EditorState based on the current Selection.
 | |
|    * Handles different node types.
 | |
|    *
 | |
|    * @param isBackward whether or not the selection is backwards.
 | |
|    */
 | |
|   deleteLine(isBackward: boolean): void {
 | |
|     if (this.isCollapsed()) {
 | |
|       // Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections
 | |
|       // but doesn't properly handle selections which end on elements, a space character is added
 | |
|       // for such selections transforming their anchor's type to 'text'
 | |
|       const anchorIsElement = this.anchor.type === 'element';
 | |
|       if (anchorIsElement) {
 | |
|         this.insertText(' ');
 | |
|       }
 | |
| 
 | |
|       this.modify('extend', isBackward, 'lineboundary');
 | |
| 
 | |
|       // If selection is extended to cover text edge then extend it one character more
 | |
|       // to delete its parent element. Otherwise text content will be deleted but empty
 | |
|       // parent node will remain
 | |
|       const endPoint = isBackward ? this.focus : this.anchor;
 | |
|       if (endPoint.offset === 0) {
 | |
|         this.modify('extend', isBackward, 'character');
 | |
|       }
 | |
| 
 | |
|       // Adjusts selection to include an extra character added for element anchors to remove it
 | |
|       if (anchorIsElement) {
 | |
|         const startPoint = isBackward ? this.anchor : this.focus;
 | |
|         startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type);
 | |
|       }
 | |
|     }
 | |
|     this.removeText();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Performs one logical word deletion operation on the EditorState based on the current Selection.
 | |
|    * Handles different node types.
 | |
|    *
 | |
|    * @param isBackward whether or not the selection is backwards.
 | |
|    */
 | |
|   deleteWord(isBackward: boolean): void {
 | |
|     if (this.isCollapsed()) {
 | |
|       const anchor = this.anchor;
 | |
|       const anchorNode: TextNode | ElementNode | null = anchor.getNode();
 | |
|       if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
 | |
|         return;
 | |
|       }
 | |
|       this.modify('extend', isBackward, 'word');
 | |
|     }
 | |
|     this.removeText();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns whether the Selection is "backwards", meaning the focus
 | |
|    * logically precedes the anchor in the EditorState.
 | |
|    * @returns true if the Selection is backwards, false otherwise.
 | |
|    */
 | |
|   isBackward(): boolean {
 | |
|     return this.focus.isBefore(this.anchor);
 | |
|   }
 | |
| 
 | |
|   getStartEndPoints(): null | [PointType, PointType] {
 | |
|     return [this.anchor, this.focus];
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function $isNodeSelection(x: unknown): x is NodeSelection {
 | |
|   return x instanceof NodeSelection;
 | |
| }
 | |
| 
 | |
| function getCharacterOffset(point: PointType): number {
 | |
|   const offset = point.offset;
 | |
|   if (point.type === 'text') {
 | |
|     return offset;
 | |
|   }
 | |
| 
 | |
|   const parent = point.getNode();
 | |
|   return offset === parent.getChildrenSize()
 | |
|     ? parent.getTextContent().length
 | |
|     : 0;
 | |
| }
 | |
| 
 | |
| export function $getCharacterOffsets(
 | |
|   selection: BaseSelection,
 | |
| ): [number, number] {
 | |
|   const anchorAndFocus = selection.getStartEndPoints();
 | |
|   if (anchorAndFocus === null) {
 | |
|     return [0, 0];
 | |
|   }
 | |
|   const [anchor, focus] = anchorAndFocus;
 | |
|   if (
 | |
|     anchor.type === 'element' &&
 | |
|     focus.type === 'element' &&
 | |
|     anchor.key === focus.key &&
 | |
|     anchor.offset === focus.offset
 | |
|   ) {
 | |
|     return [0, 0];
 | |
|   }
 | |
|   return [getCharacterOffset(anchor), getCharacterOffset(focus)];
 | |
| }
 | |
| 
 | |
| function $swapPoints(selection: RangeSelection): void {
 | |
|   const focus = selection.focus;
 | |
|   const anchor = selection.anchor;
 | |
|   const anchorKey = anchor.key;
 | |
|   const anchorOffset = anchor.offset;
 | |
|   const anchorType = anchor.type;
 | |
| 
 | |
|   $setPointValues(anchor, focus.key, focus.offset, focus.type);
 | |
|   $setPointValues(focus, anchorKey, anchorOffset, anchorType);
 | |
|   selection._cachedNodes = null;
 | |
| }
 | |
| 
 | |
| function moveNativeSelection(
 | |
|   domSelection: Selection,
 | |
|   alter: 'move' | 'extend',
 | |
|   direction: 'backward' | 'forward' | 'left' | 'right',
 | |
|   granularity: 'character' | 'word' | 'lineboundary',
 | |
| ): void {
 | |
|   // Selection.modify() method applies a change to the current selection or cursor position,
 | |
|   // but is still non-standard in some browsers.
 | |
|   domSelection.modify(alter, direction, granularity);
 | |
| }
 | |
| 
 | |
| function $updateCaretSelectionForUnicodeCharacter(
 | |
|   selection: RangeSelection,
 | |
|   isBackward: boolean,
 | |
| ): void {
 | |
|   const anchor = selection.anchor;
 | |
|   const focus = selection.focus;
 | |
|   const anchorNode = anchor.getNode();
 | |
|   const focusNode = focus.getNode();
 | |
| 
 | |
|   if (
 | |
|     anchorNode === focusNode &&
 | |
|     anchor.type === 'text' &&
 | |
|     focus.type === 'text'
 | |
|   ) {
 | |
|     // Handling of multibyte characters
 | |
|     const anchorOffset = anchor.offset;
 | |
|     const focusOffset = focus.offset;
 | |
|     const isBefore = anchorOffset < focusOffset;
 | |
|     const startOffset = isBefore ? anchorOffset : focusOffset;
 | |
|     const endOffset = isBefore ? focusOffset : anchorOffset;
 | |
|     const characterOffset = endOffset - 1;
 | |
| 
 | |
|     if (startOffset !== characterOffset) {
 | |
|       const text = anchorNode.getTextContent().slice(startOffset, endOffset);
 | |
|       if (!doesContainGrapheme(text)) {
 | |
|         if (isBackward) {
 | |
|           focus.offset = characterOffset;
 | |
|         } else {
 | |
|           anchor.offset = characterOffset;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   } else {
 | |
|     // TODO Handling of multibyte characters
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $removeSegment(
 | |
|   node: TextNode,
 | |
|   isBackward: boolean,
 | |
|   offset: number,
 | |
| ): void {
 | |
|   const textNode = node;
 | |
|   const textContent = textNode.getTextContent();
 | |
|   const split = textContent.split(/(?=\s)/g);
 | |
|   const splitLength = split.length;
 | |
|   let segmentOffset = 0;
 | |
|   let restoreOffset: number | undefined = 0;
 | |
| 
 | |
|   for (let i = 0; i < splitLength; i++) {
 | |
|     const text = split[i];
 | |
|     const isLast = i === splitLength - 1;
 | |
|     restoreOffset = segmentOffset;
 | |
|     segmentOffset += text.length;
 | |
| 
 | |
|     if (
 | |
|       (isBackward && segmentOffset === offset) ||
 | |
|       segmentOffset > offset ||
 | |
|       isLast
 | |
|     ) {
 | |
|       split.splice(i, 1);
 | |
|       if (isLast) {
 | |
|         restoreOffset = undefined;
 | |
|       }
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
|   const nextTextContent = split.join('').trim();
 | |
| 
 | |
|   if (nextTextContent === '') {
 | |
|     textNode.remove();
 | |
|   } else {
 | |
|     textNode.setTextContent(nextTextContent);
 | |
|     textNode.select(restoreOffset, restoreOffset);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function shouldResolveAncestor(
 | |
|   resolvedElement: ElementNode,
 | |
|   resolvedOffset: number,
 | |
|   lastPoint: null | PointType,
 | |
| ): boolean {
 | |
|   const parent = resolvedElement.getParent();
 | |
|   return (
 | |
|     lastPoint === null ||
 | |
|     parent === null ||
 | |
|     !parent.canBeEmpty() ||
 | |
|     parent !== lastPoint.getNode()
 | |
|   );
 | |
| }
 | |
| 
 | |
| function $internalResolveSelectionPoint(
 | |
|   dom: Node,
 | |
|   offset: number,
 | |
|   lastPoint: null | PointType,
 | |
|   editor: LexicalEditor,
 | |
| ): null | PointType {
 | |
|   let resolvedOffset = offset;
 | |
|   let resolvedNode: TextNode | LexicalNode | null;
 | |
|   // If we have selection on an element, we will
 | |
|   // need to figure out (using the offset) what text
 | |
|   // node should be selected.
 | |
| 
 | |
|   if (dom.nodeType === DOM_ELEMENT_TYPE) {
 | |
|     // Resolve element to a ElementNode, or TextNode, or null
 | |
|     let moveSelectionToEnd = false;
 | |
|     // Given we're moving selection to another node, selection is
 | |
|     // definitely dirty.
 | |
|     // We use the anchor to find which child node to select
 | |
|     const childNodes = dom.childNodes;
 | |
|     const childNodesLength = childNodes.length;
 | |
|     const blockCursorElement = editor._blockCursorElement;
 | |
|     // If the anchor is the same as length, then this means we
 | |
|     // need to select the very last text node.
 | |
|     if (resolvedOffset === childNodesLength) {
 | |
|       moveSelectionToEnd = true;
 | |
|       resolvedOffset = childNodesLength - 1;
 | |
|     }
 | |
|     let childDOM = childNodes[resolvedOffset];
 | |
|     let hasBlockCursor = false;
 | |
|     if (childDOM === blockCursorElement) {
 | |
|       childDOM = childNodes[resolvedOffset + 1];
 | |
|       hasBlockCursor = true;
 | |
|     } else if (blockCursorElement !== null) {
 | |
|       const blockCursorElementParent = blockCursorElement.parentNode;
 | |
|       if (dom === blockCursorElementParent) {
 | |
|         const blockCursorOffset = Array.prototype.indexOf.call(
 | |
|           blockCursorElementParent.children,
 | |
|           blockCursorElement,
 | |
|         );
 | |
|         if (offset > blockCursorOffset) {
 | |
|           resolvedOffset--;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     resolvedNode = $getNodeFromDOM(childDOM);
 | |
| 
 | |
|     if ($isTextNode(resolvedNode)) {
 | |
|       resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd);
 | |
|     } else {
 | |
|       let resolvedElement = $getNodeFromDOM(dom);
 | |
|       // Ensure resolvedElement is actually a element.
 | |
|       if (resolvedElement === null) {
 | |
|         return null;
 | |
|       }
 | |
|       if ($isElementNode(resolvedElement)) {
 | |
|         resolvedOffset = Math.min(
 | |
|           resolvedElement.getChildrenSize(),
 | |
|           resolvedOffset,
 | |
|         );
 | |
|         let child = resolvedElement.getChildAtIndex(resolvedOffset);
 | |
|         if (
 | |
|           $isElementNode(child) &&
 | |
|           shouldResolveAncestor(child, resolvedOffset, lastPoint)
 | |
|         ) {
 | |
|           const descendant = moveSelectionToEnd
 | |
|             ? child.getLastDescendant()
 | |
|             : child.getFirstDescendant();
 | |
|           if (descendant === null) {
 | |
|             resolvedElement = child;
 | |
|           } else {
 | |
|             child = descendant;
 | |
|             resolvedElement = $isElementNode(child)
 | |
|               ? child
 | |
|               : child.getParentOrThrow();
 | |
|           }
 | |
|           resolvedOffset = 0;
 | |
|         }
 | |
|         if ($isTextNode(child)) {
 | |
|           resolvedNode = child;
 | |
|           resolvedElement = null;
 | |
|           resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd);
 | |
|         } else if (
 | |
|           child !== resolvedElement &&
 | |
|           moveSelectionToEnd &&
 | |
|           !hasBlockCursor
 | |
|         ) {
 | |
|           resolvedOffset++;
 | |
|         }
 | |
|       } else {
 | |
|         const index = resolvedElement.getIndexWithinParent();
 | |
|         // When selecting decorators, there can be some selection issues when using resolvedOffset,
 | |
|         // and instead we should be checking if we're using the offset
 | |
|         if (
 | |
|           offset === 0 &&
 | |
|           $isDecoratorNode(resolvedElement) &&
 | |
|           $getNodeFromDOM(dom) === resolvedElement
 | |
|         ) {
 | |
|           resolvedOffset = index;
 | |
|         } else {
 | |
|           resolvedOffset = index + 1;
 | |
|         }
 | |
|         resolvedElement = resolvedElement.getParentOrThrow();
 | |
|       }
 | |
|       if ($isElementNode(resolvedElement)) {
 | |
|         return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
 | |
|       }
 | |
|     }
 | |
|   } else {
 | |
|     // TextNode or null
 | |
|     resolvedNode = $getNodeFromDOM(dom);
 | |
|   }
 | |
|   if (!$isTextNode(resolvedNode)) {
 | |
|     return null;
 | |
|   }
 | |
|   return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
 | |
| }
 | |
| 
 | |
| function resolveSelectionPointOnBoundary(
 | |
|   point: TextPointType,
 | |
|   isBackward: boolean,
 | |
|   isCollapsed: boolean,
 | |
| ): void {
 | |
|   const offset = point.offset;
 | |
|   const node = point.getNode();
 | |
| 
 | |
|   if (offset === 0) {
 | |
|     const prevSibling = node.getPreviousSibling();
 | |
|     const parent = node.getParent();
 | |
| 
 | |
|     if (!isBackward) {
 | |
|       if (
 | |
|         $isElementNode(prevSibling) &&
 | |
|         !isCollapsed &&
 | |
|         prevSibling.isInline()
 | |
|       ) {
 | |
|         point.key = prevSibling.__key;
 | |
|         point.offset = prevSibling.getChildrenSize();
 | |
|         // @ts-expect-error: intentional
 | |
|         point.type = 'element';
 | |
|       } else if ($isTextNode(prevSibling)) {
 | |
|         point.key = prevSibling.__key;
 | |
|         point.offset = prevSibling.getTextContent().length;
 | |
|       }
 | |
|     } else if (
 | |
|       (isCollapsed || !isBackward) &&
 | |
|       prevSibling === null &&
 | |
|       $isElementNode(parent) &&
 | |
|       parent.isInline()
 | |
|     ) {
 | |
|       const parentSibling = parent.getPreviousSibling();
 | |
|       if ($isTextNode(parentSibling)) {
 | |
|         point.key = parentSibling.__key;
 | |
|         point.offset = parentSibling.getTextContent().length;
 | |
|       }
 | |
|     }
 | |
|   } else if (offset === node.getTextContent().length) {
 | |
|     const nextSibling = node.getNextSibling();
 | |
|     const parent = node.getParent();
 | |
| 
 | |
|     if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
 | |
|       point.key = nextSibling.__key;
 | |
|       point.offset = 0;
 | |
|       // @ts-expect-error: intentional
 | |
|       point.type = 'element';
 | |
|     } else if (
 | |
|       (isCollapsed || isBackward) &&
 | |
|       nextSibling === null &&
 | |
|       $isElementNode(parent) &&
 | |
|       parent.isInline() &&
 | |
|       !parent.canInsertTextAfter()
 | |
|     ) {
 | |
|       const parentSibling = parent.getNextSibling();
 | |
|       if ($isTextNode(parentSibling)) {
 | |
|         point.key = parentSibling.__key;
 | |
|         point.offset = 0;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $normalizeSelectionPointsForBoundaries(
 | |
|   anchor: PointType,
 | |
|   focus: PointType,
 | |
|   lastSelection: null | BaseSelection,
 | |
| ): void {
 | |
|   if (anchor.type === 'text' && focus.type === 'text') {
 | |
|     const isBackward = anchor.isBefore(focus);
 | |
|     const isCollapsed = anchor.is(focus);
 | |
| 
 | |
|     // Attempt to normalize the offset to the previous sibling if we're at the
 | |
|     // start of a text node and the sibling is a text node or inline element.
 | |
|     resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
 | |
|     resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
 | |
| 
 | |
|     if (isCollapsed) {
 | |
|       focus.key = anchor.key;
 | |
|       focus.offset = anchor.offset;
 | |
|       focus.type = anchor.type;
 | |
|     }
 | |
|     const editor = getActiveEditor();
 | |
| 
 | |
|     if (
 | |
|       editor.isComposing() &&
 | |
|       editor._compositionKey !== anchor.key &&
 | |
|       $isRangeSelection(lastSelection)
 | |
|     ) {
 | |
|       const lastAnchor = lastSelection.anchor;
 | |
|       const lastFocus = lastSelection.focus;
 | |
|       $setPointValues(
 | |
|         anchor,
 | |
|         lastAnchor.key,
 | |
|         lastAnchor.offset,
 | |
|         lastAnchor.type,
 | |
|       );
 | |
|       $setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $internalResolveSelectionPoints(
 | |
|   anchorDOM: null | Node,
 | |
|   anchorOffset: number,
 | |
|   focusDOM: null | Node,
 | |
|   focusOffset: number,
 | |
|   editor: LexicalEditor,
 | |
|   lastSelection: null | BaseSelection,
 | |
| ): null | [PointType, PointType] {
 | |
|   if (
 | |
|     anchorDOM === null ||
 | |
|     focusDOM === null ||
 | |
|     !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
 | |
|   ) {
 | |
|     return null;
 | |
|   }
 | |
|   const resolvedAnchorPoint = $internalResolveSelectionPoint(
 | |
|     anchorDOM,
 | |
|     anchorOffset,
 | |
|     $isRangeSelection(lastSelection) ? lastSelection.anchor : null,
 | |
|     editor,
 | |
|   );
 | |
|   if (resolvedAnchorPoint === null) {
 | |
|     return null;
 | |
|   }
 | |
|   const resolvedFocusPoint = $internalResolveSelectionPoint(
 | |
|     focusDOM,
 | |
|     focusOffset,
 | |
|     $isRangeSelection(lastSelection) ? lastSelection.focus : null,
 | |
|     editor,
 | |
|   );
 | |
|   if (resolvedFocusPoint === null) {
 | |
|     return null;
 | |
|   }
 | |
|   if (
 | |
|     resolvedAnchorPoint.type === 'element' &&
 | |
|     resolvedFocusPoint.type === 'element'
 | |
|   ) {
 | |
|     const anchorNode = $getNodeFromDOM(anchorDOM);
 | |
|     const focusNode = $getNodeFromDOM(focusDOM);
 | |
|     // Ensure if we're selecting the content of a decorator that we
 | |
|     // return null for this point, as it's not in the controlled scope
 | |
|     // of Lexical.
 | |
|     if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
 | |
|       return null;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Handle normalization of selection when it is at the boundaries.
 | |
|   $normalizeSelectionPointsForBoundaries(
 | |
|     resolvedAnchorPoint,
 | |
|     resolvedFocusPoint,
 | |
|     lastSelection,
 | |
|   );
 | |
| 
 | |
|   return [resolvedAnchorPoint, resolvedFocusPoint];
 | |
| }
 | |
| 
 | |
| export function $isBlockElementNode(
 | |
|   node: LexicalNode | null | undefined,
 | |
| ): node is ElementNode {
 | |
|   return $isElementNode(node) && !node.isInline();
 | |
| }
 | |
| 
 | |
| // This is used to make a selection when the existing
 | |
| // selection is null, i.e. forcing selection on the editor
 | |
| // when it current exists outside the editor.
 | |
| 
 | |
| export function $internalMakeRangeSelection(
 | |
|   anchorKey: NodeKey,
 | |
|   anchorOffset: number,
 | |
|   focusKey: NodeKey,
 | |
|   focusOffset: number,
 | |
|   anchorType: 'text' | 'element',
 | |
|   focusType: 'text' | 'element',
 | |
| ): RangeSelection {
 | |
|   const editorState = getActiveEditorState();
 | |
|   const selection = new RangeSelection(
 | |
|     $createPoint(anchorKey, anchorOffset, anchorType),
 | |
|     $createPoint(focusKey, focusOffset, focusType),
 | |
|     0,
 | |
|     '',
 | |
|   );
 | |
|   selection.dirty = true;
 | |
|   editorState._selection = selection;
 | |
|   return selection;
 | |
| }
 | |
| 
 | |
| export function $createRangeSelection(): RangeSelection {
 | |
|   const anchor = $createPoint('root', 0, 'element');
 | |
|   const focus = $createPoint('root', 0, 'element');
 | |
|   return new RangeSelection(anchor, focus, 0, '');
 | |
| }
 | |
| 
 | |
| export function $createNodeSelection(): NodeSelection {
 | |
|   return new NodeSelection(new Set());
 | |
| }
 | |
| 
 | |
| export function $internalCreateSelection(
 | |
|   editor: LexicalEditor,
 | |
| ): null | BaseSelection {
 | |
|   const currentEditorState = editor.getEditorState();
 | |
|   const lastSelection = currentEditorState._selection;
 | |
|   const domSelection = getDOMSelection(editor._window);
 | |
| 
 | |
|   if ($isRangeSelection(lastSelection) || lastSelection == null) {
 | |
|     return $internalCreateRangeSelection(
 | |
|       lastSelection,
 | |
|       domSelection,
 | |
|       editor,
 | |
|       null,
 | |
|     );
 | |
|   }
 | |
|   return lastSelection.clone();
 | |
| }
 | |
| 
 | |
| export function $createRangeSelectionFromDom(
 | |
|   domSelection: Selection | null,
 | |
|   editor: LexicalEditor,
 | |
| ): null | RangeSelection {
 | |
|   return $internalCreateRangeSelection(null, domSelection, editor, null);
 | |
| }
 | |
| 
 | |
| export function $internalCreateRangeSelection(
 | |
|   lastSelection: null | BaseSelection,
 | |
|   domSelection: Selection | null,
 | |
|   editor: LexicalEditor,
 | |
|   event: UIEvent | Event | null,
 | |
| ): null | RangeSelection {
 | |
|   const windowObj = editor._window;
 | |
|   if (windowObj === null) {
 | |
|     return null;
 | |
|   }
 | |
|   // When we create a selection, we try to use the previous
 | |
|   // selection where possible, unless an actual user selection
 | |
|   // change has occurred. When we do need to create a new selection
 | |
|   // we validate we can have text nodes for both anchor and focus
 | |
|   // nodes. If that holds true, we then return that selection
 | |
|   // as a mutable object that we use for the editor state for this
 | |
|   // update cycle. If a selection gets changed, and requires a
 | |
|   // update to native DOM selection, it gets marked as "dirty".
 | |
|   // If the selection changes, but matches with the existing
 | |
|   // DOM selection, then we only need to sync it. Otherwise,
 | |
|   // we generally bail out of doing an update to selection during
 | |
|   // reconciliation unless there are dirty nodes that need
 | |
|   // reconciling.
 | |
| 
 | |
|   const windowEvent = event || windowObj.event;
 | |
|   const eventType = windowEvent ? windowEvent.type : undefined;
 | |
|   const isSelectionChange = eventType === 'selectionchange';
 | |
|   const useDOMSelection =
 | |
|     !getIsProcessingMutations() &&
 | |
|     (isSelectionChange ||
 | |
|       eventType === 'beforeinput' ||
 | |
|       eventType === 'compositionstart' ||
 | |
|       eventType === 'compositionend' ||
 | |
|       (eventType === 'click' &&
 | |
|         windowEvent &&
 | |
|         (windowEvent as InputEvent).detail === 3) ||
 | |
|       eventType === 'drop' ||
 | |
|       eventType === undefined);
 | |
|   let anchorDOM, focusDOM, anchorOffset, focusOffset;
 | |
| 
 | |
|   if (!$isRangeSelection(lastSelection) || useDOMSelection) {
 | |
|     if (domSelection === null) {
 | |
|       return null;
 | |
|     }
 | |
|     anchorDOM = domSelection.anchorNode;
 | |
|     focusDOM = domSelection.focusNode;
 | |
|     anchorOffset = domSelection.anchorOffset;
 | |
|     focusOffset = domSelection.focusOffset;
 | |
|     if (
 | |
|       isSelectionChange &&
 | |
|       $isRangeSelection(lastSelection) &&
 | |
|       !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
 | |
|     ) {
 | |
|       return lastSelection.clone();
 | |
|     }
 | |
|   } else {
 | |
|     return lastSelection.clone();
 | |
|   }
 | |
|   // Let's resolve the text nodes from the offsets and DOM nodes we have from
 | |
|   // native selection.
 | |
|   const resolvedSelectionPoints = $internalResolveSelectionPoints(
 | |
|     anchorDOM,
 | |
|     anchorOffset,
 | |
|     focusDOM,
 | |
|     focusOffset,
 | |
|     editor,
 | |
|     lastSelection,
 | |
|   );
 | |
|   if (resolvedSelectionPoints === null) {
 | |
|     return null;
 | |
|   }
 | |
|   const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints;
 | |
|   return new RangeSelection(
 | |
|     resolvedAnchorPoint,
 | |
|     resolvedFocusPoint,
 | |
|     !$isRangeSelection(lastSelection) ? 0 : lastSelection.format,
 | |
|     !$isRangeSelection(lastSelection) ? '' : lastSelection.style,
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function $getSelection(): null | BaseSelection {
 | |
|   const editorState = getActiveEditorState();
 | |
|   return editorState._selection;
 | |
| }
 | |
| 
 | |
| export function $getPreviousSelection(): null | BaseSelection {
 | |
|   const editor = getActiveEditor();
 | |
|   return editor._editorState._selection;
 | |
| }
 | |
| 
 | |
| export function $updateElementSelectionOnCreateDeleteNode(
 | |
|   selection: RangeSelection,
 | |
|   parentNode: LexicalNode,
 | |
|   nodeOffset: number,
 | |
|   times = 1,
 | |
| ): void {
 | |
|   const anchor = selection.anchor;
 | |
|   const focus = selection.focus;
 | |
|   const anchorNode = anchor.getNode();
 | |
|   const focusNode = focus.getNode();
 | |
|   if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
 | |
|     return;
 | |
|   }
 | |
|   const parentKey = parentNode.__key;
 | |
|   // Single node. We shift selection but never redimension it
 | |
|   if (selection.isCollapsed()) {
 | |
|     const selectionOffset = anchor.offset;
 | |
|     if (
 | |
|       (nodeOffset <= selectionOffset && times > 0) ||
 | |
|       (nodeOffset < selectionOffset && times < 0)
 | |
|     ) {
 | |
|       const newSelectionOffset = Math.max(0, selectionOffset + times);
 | |
|       anchor.set(parentKey, newSelectionOffset, 'element');
 | |
|       focus.set(parentKey, newSelectionOffset, 'element');
 | |
|       // The new selection might point to text nodes, try to resolve them
 | |
|       $updateSelectionResolveTextNodes(selection);
 | |
|     }
 | |
|   } else {
 | |
|     // Multiple nodes selected. We shift or redimension selection
 | |
|     const isBackward = selection.isBackward();
 | |
|     const firstPoint = isBackward ? focus : anchor;
 | |
|     const firstPointNode = firstPoint.getNode();
 | |
|     const lastPoint = isBackward ? anchor : focus;
 | |
|     const lastPointNode = lastPoint.getNode();
 | |
|     if (parentNode.is(firstPointNode)) {
 | |
|       const firstPointOffset = firstPoint.offset;
 | |
|       if (
 | |
|         (nodeOffset <= firstPointOffset && times > 0) ||
 | |
|         (nodeOffset < firstPointOffset && times < 0)
 | |
|       ) {
 | |
|         firstPoint.set(
 | |
|           parentKey,
 | |
|           Math.max(0, firstPointOffset + times),
 | |
|           'element',
 | |
|         );
 | |
|       }
 | |
|     }
 | |
|     if (parentNode.is(lastPointNode)) {
 | |
|       const lastPointOffset = lastPoint.offset;
 | |
|       if (
 | |
|         (nodeOffset <= lastPointOffset && times > 0) ||
 | |
|         (nodeOffset < lastPointOffset && times < 0)
 | |
|       ) {
 | |
|         lastPoint.set(
 | |
|           parentKey,
 | |
|           Math.max(0, lastPointOffset + times),
 | |
|           'element',
 | |
|         );
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   // The new selection might point to text nodes, try to resolve them
 | |
|   $updateSelectionResolveTextNodes(selection);
 | |
| }
 | |
| 
 | |
| function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
 | |
|   const anchor = selection.anchor;
 | |
|   const anchorOffset = anchor.offset;
 | |
|   const focus = selection.focus;
 | |
|   const focusOffset = focus.offset;
 | |
|   const anchorNode = anchor.getNode();
 | |
|   const focusNode = focus.getNode();
 | |
|   if (selection.isCollapsed()) {
 | |
|     if (!$isElementNode(anchorNode)) {
 | |
|       return;
 | |
|     }
 | |
|     const childSize = anchorNode.getChildrenSize();
 | |
|     const anchorOffsetAtEnd = anchorOffset >= childSize;
 | |
|     const child = anchorOffsetAtEnd
 | |
|       ? anchorNode.getChildAtIndex(childSize - 1)
 | |
|       : anchorNode.getChildAtIndex(anchorOffset);
 | |
|     if ($isTextNode(child)) {
 | |
|       let newOffset = 0;
 | |
|       if (anchorOffsetAtEnd) {
 | |
|         newOffset = child.getTextContentSize();
 | |
|       }
 | |
|       anchor.set(child.__key, newOffset, 'text');
 | |
|       focus.set(child.__key, newOffset, 'text');
 | |
|     }
 | |
|     return;
 | |
|   }
 | |
|   if ($isElementNode(anchorNode)) {
 | |
|     const childSize = anchorNode.getChildrenSize();
 | |
|     const anchorOffsetAtEnd = anchorOffset >= childSize;
 | |
|     const child = anchorOffsetAtEnd
 | |
|       ? anchorNode.getChildAtIndex(childSize - 1)
 | |
|       : anchorNode.getChildAtIndex(anchorOffset);
 | |
|     if ($isTextNode(child)) {
 | |
|       let newOffset = 0;
 | |
|       if (anchorOffsetAtEnd) {
 | |
|         newOffset = child.getTextContentSize();
 | |
|       }
 | |
|       anchor.set(child.__key, newOffset, 'text');
 | |
|     }
 | |
|   }
 | |
|   if ($isElementNode(focusNode)) {
 | |
|     const childSize = focusNode.getChildrenSize();
 | |
|     const focusOffsetAtEnd = focusOffset >= childSize;
 | |
|     const child = focusOffsetAtEnd
 | |
|       ? focusNode.getChildAtIndex(childSize - 1)
 | |
|       : focusNode.getChildAtIndex(focusOffset);
 | |
|     if ($isTextNode(child)) {
 | |
|       let newOffset = 0;
 | |
|       if (focusOffsetAtEnd) {
 | |
|         newOffset = child.getTextContentSize();
 | |
|       }
 | |
|       focus.set(child.__key, newOffset, 'text');
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function applySelectionTransforms(
 | |
|   nextEditorState: EditorState,
 | |
|   editor: LexicalEditor,
 | |
| ): void {
 | |
|   const prevEditorState = editor.getEditorState();
 | |
|   const prevSelection = prevEditorState._selection;
 | |
|   const nextSelection = nextEditorState._selection;
 | |
|   if ($isRangeSelection(nextSelection)) {
 | |
|     const anchor = nextSelection.anchor;
 | |
|     const focus = nextSelection.focus;
 | |
|     let anchorNode;
 | |
| 
 | |
|     if (anchor.type === 'text') {
 | |
|       anchorNode = anchor.getNode();
 | |
|       anchorNode.selectionTransform(prevSelection, nextSelection);
 | |
|     }
 | |
|     if (focus.type === 'text') {
 | |
|       const focusNode = focus.getNode();
 | |
|       if (anchorNode !== focusNode) {
 | |
|         focusNode.selectionTransform(prevSelection, nextSelection);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function moveSelectionPointToSibling(
 | |
|   point: PointType,
 | |
|   node: LexicalNode,
 | |
|   parent: ElementNode,
 | |
|   prevSibling: LexicalNode | null,
 | |
|   nextSibling: LexicalNode | null,
 | |
| ): void {
 | |
|   let siblingKey = null;
 | |
|   let offset = 0;
 | |
|   let type: 'text' | 'element' | null = null;
 | |
|   if (prevSibling !== null) {
 | |
|     siblingKey = prevSibling.__key;
 | |
|     if ($isTextNode(prevSibling)) {
 | |
|       offset = prevSibling.getTextContentSize();
 | |
|       type = 'text';
 | |
|     } else if ($isElementNode(prevSibling)) {
 | |
|       offset = prevSibling.getChildrenSize();
 | |
|       type = 'element';
 | |
|     }
 | |
|   } else {
 | |
|     if (nextSibling !== null) {
 | |
|       siblingKey = nextSibling.__key;
 | |
|       if ($isTextNode(nextSibling)) {
 | |
|         type = 'text';
 | |
|       } else if ($isElementNode(nextSibling)) {
 | |
|         type = 'element';
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   if (siblingKey !== null && type !== null) {
 | |
|     point.set(siblingKey, offset, type);
 | |
|   } else {
 | |
|     offset = node.getIndexWithinParent();
 | |
|     if (offset === -1) {
 | |
|       // Move selection to end of parent
 | |
|       offset = parent.getChildrenSize();
 | |
|     }
 | |
|     point.set(parent.__key, offset, 'element');
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function adjustPointOffsetForMergedSibling(
 | |
|   point: PointType,
 | |
|   isBefore: boolean,
 | |
|   key: NodeKey,
 | |
|   target: TextNode,
 | |
|   textLength: number,
 | |
| ): void {
 | |
|   if (point.type === 'text') {
 | |
|     point.key = key;
 | |
|     if (!isBefore) {
 | |
|       point.offset += textLength;
 | |
|     }
 | |
|   } else if (point.offset > target.getIndexWithinParent()) {
 | |
|     point.offset -= 1;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function updateDOMSelection(
 | |
|   prevSelection: BaseSelection | null,
 | |
|   nextSelection: BaseSelection | null,
 | |
|   editor: LexicalEditor,
 | |
|   domSelection: Selection,
 | |
|   tags: Set<string>,
 | |
|   rootElement: HTMLElement,
 | |
|   nodeCount: number,
 | |
| ): void {
 | |
|   const anchorDOMNode = domSelection.anchorNode;
 | |
|   const focusDOMNode = domSelection.focusNode;
 | |
|   const anchorOffset = domSelection.anchorOffset;
 | |
|   const focusOffset = domSelection.focusOffset;
 | |
|   const activeElement = document.activeElement;
 | |
| 
 | |
|   // TODO: make this not hard-coded, and add another config option
 | |
|   // that makes this configurable.
 | |
|   if (
 | |
|     (tags.has('collaboration') && activeElement !== rootElement) ||
 | |
|     (activeElement !== null &&
 | |
|       isSelectionCapturedInDecoratorInput(activeElement))
 | |
|   ) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!$isRangeSelection(nextSelection)) {
 | |
|     // We don't remove selection if the prevSelection is null because
 | |
|     // of editor.setRootElement(). If this occurs on init when the
 | |
|     // editor is already focused, then this can cause the editor to
 | |
|     // lose focus.
 | |
|     if (
 | |
|       prevSelection !== null &&
 | |
|       isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode)
 | |
|     ) {
 | |
|       domSelection.removeAllRanges();
 | |
|     }
 | |
| 
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const anchor = nextSelection.anchor;
 | |
|   const focus = nextSelection.focus;
 | |
|   const anchorKey = anchor.key;
 | |
|   const focusKey = focus.key;
 | |
|   const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
 | |
|   const focusDOM = getElementByKeyOrThrow(editor, focusKey);
 | |
|   const nextAnchorOffset = anchor.offset;
 | |
|   const nextFocusOffset = focus.offset;
 | |
|   const nextFormat = nextSelection.format;
 | |
|   const nextStyle = nextSelection.style;
 | |
|   const isCollapsed = nextSelection.isCollapsed();
 | |
|   let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
 | |
|   let nextFocusNode: HTMLElement | Text | null = focusDOM;
 | |
|   let anchorFormatOrStyleChanged = false;
 | |
| 
 | |
|   if (anchor.type === 'text') {
 | |
|     nextAnchorNode = getDOMTextNode(anchorDOM);
 | |
|     const anchorNode = anchor.getNode();
 | |
|     anchorFormatOrStyleChanged =
 | |
|       anchorNode.getFormat() !== nextFormat ||
 | |
|       anchorNode.getStyle() !== nextStyle;
 | |
|   } else if (
 | |
|     $isRangeSelection(prevSelection) &&
 | |
|     prevSelection.anchor.type === 'text'
 | |
|   ) {
 | |
|     anchorFormatOrStyleChanged = true;
 | |
|   }
 | |
| 
 | |
|   if (focus.type === 'text') {
 | |
|     nextFocusNode = getDOMTextNode(focusDOM);
 | |
|   }
 | |
| 
 | |
|   // If we can't get an underlying text node for selection, then
 | |
|   // we should avoid setting selection to something incorrect.
 | |
|   if (nextAnchorNode === null || nextFocusNode === null) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (
 | |
|     isCollapsed &&
 | |
|     (prevSelection === null ||
 | |
|       anchorFormatOrStyleChanged ||
 | |
|       ($isRangeSelection(prevSelection) &&
 | |
|         (prevSelection.format !== nextFormat ||
 | |
|           prevSelection.style !== nextStyle)))
 | |
|   ) {
 | |
|     markCollapsedSelectionFormat(
 | |
|       nextFormat,
 | |
|       nextStyle,
 | |
|       nextAnchorOffset,
 | |
|       anchorKey,
 | |
|       performance.now(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   // Diff against the native DOM selection to ensure we don't do
 | |
|   // an unnecessary selection update. We also skip this check if
 | |
|   // we're moving selection to within an element, as this can
 | |
|   // sometimes be problematic around scrolling.
 | |
|   if (
 | |
|     anchorOffset === nextAnchorOffset &&
 | |
|     focusOffset === nextFocusOffset &&
 | |
|     anchorDOMNode === nextAnchorNode &&
 | |
|     focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482
 | |
|     !(domSelection.type === 'Range' && isCollapsed)
 | |
|   ) {
 | |
|     // If the root element does not have focus, ensure it has focus
 | |
|     if (activeElement === null || !rootElement.contains(activeElement)) {
 | |
|       rootElement.focus({
 | |
|         preventScroll: true,
 | |
|       });
 | |
|     }
 | |
|     if (anchor.type !== 'element') {
 | |
|       return;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Apply the updated selection to the DOM. Note: this will trigger
 | |
|   // a "selectionchange" event, although it will be asynchronous.
 | |
|   try {
 | |
|     domSelection.setBaseAndExtent(
 | |
|       nextAnchorNode,
 | |
|       nextAnchorOffset,
 | |
|       nextFocusNode,
 | |
|       nextFocusOffset,
 | |
|     );
 | |
|   } catch (error) {
 | |
|     // If we encounter an error, continue. This can sometimes
 | |
|     // occur with FF and there's no good reason as to why it
 | |
|     // should happen.
 | |
|     if (__DEV__) {
 | |
|       console.warn(error);
 | |
|     }
 | |
|   }
 | |
|   if (
 | |
|     !tags.has('skip-scroll-into-view') &&
 | |
|     nextSelection.isCollapsed() &&
 | |
|     rootElement !== null &&
 | |
|     rootElement === document.activeElement
 | |
|   ) {
 | |
|     const selectionTarget: null | Range | HTMLElement | Text =
 | |
|       nextSelection instanceof RangeSelection &&
 | |
|       nextSelection.anchor.type === 'element'
 | |
|         ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
 | |
|           null
 | |
|         : domSelection.rangeCount > 0
 | |
|         ? domSelection.getRangeAt(0)
 | |
|         : null;
 | |
|     if (selectionTarget !== null) {
 | |
|       let selectionRect: DOMRect;
 | |
|       if (selectionTarget instanceof Text) {
 | |
|         const range = document.createRange();
 | |
|         range.selectNode(selectionTarget);
 | |
|         selectionRect = range.getBoundingClientRect();
 | |
|       } else {
 | |
|         selectionRect = selectionTarget.getBoundingClientRect();
 | |
|       }
 | |
|       scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   markSelectionChangeFromDOMUpdate();
 | |
| }
 | |
| 
 | |
| export function $insertNodes(nodes: Array<LexicalNode>) {
 | |
|   let selection = $getSelection() || $getPreviousSelection();
 | |
| 
 | |
|   if (selection === null) {
 | |
|     selection = $getRoot().selectEnd();
 | |
|   }
 | |
|   selection.insertNodes(nodes);
 | |
| }
 | |
| 
 | |
| export function $getTextContent(): string {
 | |
|   const selection = $getSelection();
 | |
|   if (selection === null) {
 | |
|     return '';
 | |
|   }
 | |
|   return selection.getTextContent();
 | |
| }
 | |
| 
 | |
| function $removeTextAndSplitBlock(selection: RangeSelection): number {
 | |
|   let selection_ = selection;
 | |
|   if (!selection.isCollapsed()) {
 | |
|     selection_.removeText();
 | |
|   }
 | |
|   // A new selection can originate as a result of node replacement, in which case is registered via
 | |
|   // $setSelection
 | |
|   const newSelection = $getSelection();
 | |
|   if ($isRangeSelection(newSelection)) {
 | |
|     selection_ = newSelection;
 | |
|   }
 | |
| 
 | |
|   invariant(
 | |
|     $isRangeSelection(selection_),
 | |
|     'Unexpected dirty selection to be null',
 | |
|   );
 | |
| 
 | |
|   const anchor = selection_.anchor;
 | |
|   let node = anchor.getNode();
 | |
|   let offset = anchor.offset;
 | |
| 
 | |
|   while (!INTERNAL_$isBlock(node)) {
 | |
|     [node, offset] = $splitNodeAtPoint(node, offset);
 | |
|   }
 | |
| 
 | |
|   return offset;
 | |
| }
 | |
| 
 | |
| function $splitNodeAtPoint(
 | |
|   node: LexicalNode,
 | |
|   offset: number,
 | |
| ): [parent: ElementNode, offset: number] {
 | |
|   const parent = node.getParent();
 | |
|   if (!parent) {
 | |
|     const paragraph = $createParagraphNode();
 | |
|     $getRoot().append(paragraph);
 | |
|     paragraph.select();
 | |
|     return [$getRoot(), 0];
 | |
|   }
 | |
| 
 | |
|   if ($isTextNode(node)) {
 | |
|     const split = node.splitText(offset);
 | |
|     if (split.length === 0) {
 | |
|       return [parent, node.getIndexWithinParent()];
 | |
|     }
 | |
|     const x = offset === 0 ? 0 : 1;
 | |
|     const index = split[0].getIndexWithinParent() + x;
 | |
| 
 | |
|     return [parent, index];
 | |
|   }
 | |
| 
 | |
|   if (!$isElementNode(node) || offset === 0) {
 | |
|     return [parent, node.getIndexWithinParent()];
 | |
|   }
 | |
| 
 | |
|   const firstToAppend = node.getChildAtIndex(offset);
 | |
|   if (firstToAppend) {
 | |
|     const insertPoint = new RangeSelection(
 | |
|       $createPoint(node.__key, offset, 'element'),
 | |
|       $createPoint(node.__key, offset, 'element'),
 | |
|       0,
 | |
|       '',
 | |
|     );
 | |
|     const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
 | |
|     if (newElement) {
 | |
|       newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
 | |
|     }
 | |
|   }
 | |
|   return [parent, node.getIndexWithinParent() + 1];
 | |
| }
 | |
| 
 | |
| function $wrapInlineNodes(nodes: LexicalNode[]) {
 | |
|   // We temporarily insert the topLevelNodes into an arbitrary ElementNode,
 | |
|   // since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
 | |
|   const virtualRoot = $createParagraphNode();
 | |
| 
 | |
|   let currentBlock = null;
 | |
|   for (let i = 0; i < nodes.length; i++) {
 | |
|     const node = nodes[i];
 | |
| 
 | |
|     const isLineBreakNode = $isLineBreakNode(node);
 | |
| 
 | |
|     if (
 | |
|       isLineBreakNode ||
 | |
|       ($isDecoratorNode(node) && node.isInline()) ||
 | |
|       ($isElementNode(node) && node.isInline()) ||
 | |
|       $isTextNode(node) ||
 | |
|       node.isParentRequired()
 | |
|     ) {
 | |
|       if (currentBlock === null) {
 | |
|         currentBlock = node.createParentElementNode();
 | |
|         virtualRoot.append(currentBlock);
 | |
|         // In the case of LineBreakNode, we just need to
 | |
|         // add an empty ParagraphNode to the topLevelBlocks.
 | |
|         if (isLineBreakNode) {
 | |
|           continue;
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (currentBlock !== null) {
 | |
|         currentBlock.append(node);
 | |
|       }
 | |
|     } else {
 | |
|       virtualRoot.append(node);
 | |
|       currentBlock = null;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return virtualRoot;
 | |
| }
 |