/** * 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 { BaseSelection, ElementNode, LexicalNode, NodeKey, Point, RangeSelection, TextNode, } from 'lexical'; import {TableSelection} from '@lexical/table'; import { $getAdjacentNode, $getPreviousSelection, $getRoot, $hasAncestor, $isDecoratorNode, $isElementNode, $isLeafNode, $isLineBreakNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot, $isTextNode, $setSelection, } from 'lexical'; import invariant from 'lexical/shared/invariant'; import {getStyleObjectFromCSS} from './utils'; /** * Converts all nodes in the selection that are of one block type to another. * @param selection - The selected blocks to be converted. * @param createElement - The function that creates the node. eg. $createParagraphNode. */ export function $setBlocksType( selection: BaseSelection | null, createElement: () => ElementNode, ): void { if (selection === null) { return; } const anchorAndFocus = selection.getStartEndPoints(); const anchor = anchorAndFocus ? anchorAndFocus[0] : null; if (anchor !== null && anchor.key === 'root') { const element = createElement(); const root = $getRoot(); const firstChild = root.getFirstChild(); if (firstChild) { firstChild.replace(element, true); } else { root.append(element); } return; } const nodes = selection.getNodes(); const firstSelectedBlock = anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false; if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) { nodes.push(firstSelectedBlock); } for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (!INTERNAL_$isBlock(node)) { continue; } invariant($isElementNode(node), 'Expected block node to be an ElementNode'); const targetElement = createElement(); targetElement.setFormat(node.getFormatType()); targetElement.setIndent(node.getIndent()); node.replace(targetElement, true); } } function isPointAttached(point: Point): boolean { return point.getNode().isAttached(); } function $removeParentEmptyElements(startingNode: ElementNode): void { let node: ElementNode | null = startingNode; while (node !== null && !$isRootOrShadowRoot(node)) { const latest = node.getLatest(); const parentNode: ElementNode | null = node.getParent(); if (latest.getChildrenSize() === 0) { node.remove(true); } node = parentNode; } } /** * @deprecated * Wraps all nodes in the selection into another node of the type returned by createElement. * @param selection - The selection of nodes to be wrapped. * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode. * @param wrappingElement - An element to append the wrapped selection and its children to. */ export function $wrapNodes( selection: BaseSelection, createElement: () => ElementNode, wrappingElement: null | ElementNode = null, ): void { const anchorAndFocus = selection.getStartEndPoints(); const anchor = anchorAndFocus ? anchorAndFocus[0] : null; const nodes = selection.getNodes(); const nodesLength = nodes.length; if ( anchor !== null && (nodesLength === 0 || (nodesLength === 1 && anchor.type === 'element' && anchor.getNode().getChildrenSize() === 0)) ) { const target = anchor.type === 'text' ? anchor.getNode().getParentOrThrow() : anchor.getNode(); const children = target.getChildren(); let element = createElement(); element.setFormat(target.getFormatType()); element.setIndent(target.getIndent()); children.forEach((child) => element.append(child)); if (wrappingElement) { element = wrappingElement.append(element); } target.replace(element); return; } let topLevelNode = null; let descendants: LexicalNode[] = []; for (let i = 0; i < nodesLength; i++) { const node = nodes[i]; // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the // user selected multiple Root-like nodes that have to be treated separately as if they are // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each // of each of the cell nodes. if ($isRootOrShadowRoot(node)) { $wrapNodesImpl( selection, descendants, descendants.length, createElement, wrappingElement, ); descendants = []; topLevelNode = node; } else if ( topLevelNode === null || (topLevelNode !== null && $hasAncestor(node, topLevelNode)) ) { descendants.push(node); } else { $wrapNodesImpl( selection, descendants, descendants.length, createElement, wrappingElement, ); descendants = [node]; } } $wrapNodesImpl( selection, descendants, descendants.length, createElement, wrappingElement, ); } /** * Wraps each node into a new ElementNode. * @param selection - The selection of nodes to wrap. * @param nodes - An array of nodes, generally the descendants of the selection. * @param nodesLength - The length of nodes. * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode. * @param wrappingElement - An element to wrap all the nodes into. * @returns */ export function $wrapNodesImpl( selection: BaseSelection, nodes: LexicalNode[], nodesLength: number, createElement: () => ElementNode, wrappingElement: null | ElementNode = null, ): void { if (nodes.length === 0) { return; } const firstNode = nodes[0]; const elementMapping: Map = new Map(); const elements = []; // The below logic is to find the right target for us to // either insertAfter/insertBefore/append the corresponding // elements to. This is made more complicated due to nested // structures. let target = $isElementNode(firstNode) ? firstNode : firstNode.getParentOrThrow(); if (target.isInline()) { target = target.getParentOrThrow(); } let targetIsPrevSibling = false; while (target !== null) { const prevSibling = target.getPreviousSibling(); if (prevSibling !== null) { target = prevSibling; targetIsPrevSibling = true; break; } target = target.getParentOrThrow(); if ($isRootOrShadowRoot(target)) { break; } } const emptyElements = new Set(); // Find any top level empty elements for (let i = 0; i < nodesLength; i++) { const node = nodes[i]; if ($isElementNode(node) && node.getChildrenSize() === 0) { emptyElements.add(node.getKey()); } } const movedNodes: Set = new Set(); // Move out all leaf nodes into our elements array. // If we find a top level empty element, also move make // an element for that. for (let i = 0; i < nodesLength; i++) { const node = nodes[i]; let parent = node.getParent(); if (parent !== null && parent.isInline()) { parent = parent.getParent(); } if ( parent !== null && $isLeafNode(node) && !movedNodes.has(node.getKey()) ) { const parentKey = parent.getKey(); if (elementMapping.get(parentKey) === undefined) { const targetElement = createElement(); targetElement.setFormat(parent.getFormatType()); targetElement.setIndent(parent.getIndent()); elements.push(targetElement); elementMapping.set(parentKey, targetElement); // Move node and its siblings to the new // element. parent.getChildren().forEach((child) => { targetElement.append(child); movedNodes.add(child.getKey()); if ($isElementNode(child)) { // Skip nested leaf nodes if the parent has already been moved child.getChildrenKeys().forEach((key) => movedNodes.add(key)); } }); $removeParentEmptyElements(parent); } } else if (emptyElements.has(node.getKey())) { invariant( $isElementNode(node), 'Expected node in emptyElements to be an ElementNode', ); const targetElement = createElement(); targetElement.setFormat(node.getFormatType()); targetElement.setIndent(node.getIndent()); elements.push(targetElement); node.remove(true); } } if (wrappingElement !== null) { for (let i = 0; i < elements.length; i++) { const element = elements[i]; wrappingElement.append(element); } } let lastElement = null; // If our target is Root-like, let's see if we can re-adjust // so that the target is the first child instead. if ($isRootOrShadowRoot(target)) { if (targetIsPrevSibling) { if (wrappingElement !== null) { target.insertAfter(wrappingElement); } else { for (let i = elements.length - 1; i >= 0; i--) { const element = elements[i]; target.insertAfter(element); } } } else { const firstChild = target.getFirstChild(); if ($isElementNode(firstChild)) { target = firstChild; } if (firstChild === null) { if (wrappingElement) { target.append(wrappingElement); } else { for (let i = 0; i < elements.length; i++) { const element = elements[i]; target.append(element); lastElement = element; } } } else { if (wrappingElement !== null) { firstChild.insertBefore(wrappingElement); } else { for (let i = 0; i < elements.length; i++) { const element = elements[i]; firstChild.insertBefore(element); lastElement = element; } } } } } else { if (wrappingElement) { target.insertAfter(wrappingElement); } else { for (let i = elements.length - 1; i >= 0; i--) { const element = elements[i]; target.insertAfter(element); lastElement = element; } } } const prevSelection = $getPreviousSelection(); if ( $isRangeSelection(prevSelection) && isPointAttached(prevSelection.anchor) && isPointAttached(prevSelection.focus) ) { $setSelection(prevSelection.clone()); } else if (lastElement !== null) { lastElement.selectEnd(); } else { selection.dirty = true; } } /** * Determines if the default character selection should be overridden. Used with DecoratorNodes * @param selection - The selection whose default character selection may need to be overridden. * @param isBackward - Is the selection backwards (the focus comes before the anchor)? * @returns true if it should be overridden, false if not. */ export function $shouldOverrideDefaultCharacterSelection( selection: RangeSelection, isBackward: boolean, ): boolean { const possibleNode = $getAdjacentNode(selection.focus, isBackward); return ( ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) || ($isElementNode(possibleNode) && !possibleNode.isInline() && !possibleNode.canBeEmpty()) ); } /** * Moves the selection according to the arguments. * @param selection - The selected text or nodes. * @param isHoldingShift - Is the shift key being held down during the operation. * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)? * @param granularity - The distance to adjust the current selection. */ export function $moveCaretSelection( selection: RangeSelection, isHoldingShift: boolean, isBackward: boolean, granularity: 'character' | 'word' | 'lineboundary', ): void { selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity); } /** * Tests a parent element for right to left direction. * @param selection - The selection whose parent is to be tested. * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise. */ export function $isParentElementRTL(selection: RangeSelection): boolean { const anchorNode = selection.anchor.getNode(); const parent = $isRootNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow(); return parent.getDirection() === 'rtl'; } /** * Moves selection by character according to arguments. * @param selection - The selection of the characters to move. * @param isHoldingShift - Is the shift key being held down during the operation. * @param isBackward - Is the selection backward (the focus comes before the anchor)? */ export function $moveCharacter( selection: RangeSelection, isHoldingShift: boolean, isBackward: boolean, ): void { const isRTL = $isParentElementRTL(selection); $moveCaretSelection( selection, isHoldingShift, isBackward ? !isRTL : isRTL, 'character', ); } /** * Expands the current Selection to cover all of the content in the editor. * @param selection - The current selection. */ export function $selectAll(selection: RangeSelection): void { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const topParent = anchorNode.getTopLevelElementOrThrow(); const root = topParent.getParentOrThrow(); let firstNode = root.getFirstDescendant(); let lastNode = root.getLastDescendant(); let firstType: 'element' | 'text' = 'element'; let lastType: 'element' | 'text' = 'element'; let lastOffset = 0; if ($isTextNode(firstNode)) { firstType = 'text'; } else if (!$isElementNode(firstNode) && firstNode !== null) { firstNode = firstNode.getParentOrThrow(); } if ($isTextNode(lastNode)) { lastType = 'text'; lastOffset = lastNode.getTextContentSize(); } else if (!$isElementNode(lastNode) && lastNode !== null) { lastNode = lastNode.getParentOrThrow(); } if (firstNode && lastNode) { anchor.set(firstNode.getKey(), 0, firstType); focus.set(lastNode.getKey(), lastOffset, lastType); } } /** * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue. * @param node - The node whose style value to get. * @param styleProperty - The CSS style property. * @param defaultValue - The default value for the property. * @returns The value of the property for node. */ function $getNodeStyleValueForProperty( node: TextNode, styleProperty: string, defaultValue: string, ): string { const css = node.getStyle(); const styleObject = getStyleObjectFromCSS(css); if (styleObject !== null) { return styleObject[styleProperty] || defaultValue; } return defaultValue; } /** * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue. * If all TextNodes do not have the same value, it returns an empty string. * @param selection - The selection of TextNodes whose value to find. * @param styleProperty - The CSS style property. * @param defaultValue - The default value for the property, defaults to an empty string. * @returns The value of the property for the selected TextNodes. */ export function $getSelectionStyleValueForProperty( selection: RangeSelection | TableSelection, styleProperty: string, defaultValue = '', ): string { let styleValue: string | null = null; const nodes = selection.getNodes(); const anchor = selection.anchor; const focus = selection.focus; const isBackward = selection.isBackward(); const endOffset = isBackward ? focus.offset : anchor.offset; const endNode = isBackward ? focus.getNode() : anchor.getNode(); if ( $isRangeSelection(selection) && selection.isCollapsed() && selection.style !== '' ) { const css = selection.style; const styleObject = getStyleObjectFromCSS(css); if (styleObject !== null && styleProperty in styleObject) { return styleObject[styleProperty]; } } for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; // if no actual characters in the end node are selected, we don't // include it in the selection for purposes of determining style // value if (i !== 0 && endOffset === 0 && node.is(endNode)) { continue; } if ($isTextNode(node)) { const nodeStyleValue = $getNodeStyleValueForProperty( node, styleProperty, defaultValue, ); if (styleValue === null) { styleValue = nodeStyleValue; } else if (styleValue !== nodeStyleValue) { // multiple text nodes are in the selection and they don't all // have the same style. styleValue = ''; break; } } } return styleValue === null ? defaultValue : styleValue; } /** * This function is for internal use of the library. * Please do not use it as it may change in the future. */ export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode { if ($isDecoratorNode(node)) { return false; } if (!$isElementNode(node) || $isRootOrShadowRoot(node)) { return false; } const firstChild = node.getFirstChild(); const isLeafElement = firstChild === null || $isLineBreakNode(firstChild) || $isTextNode(firstChild) || firstChild.isInline(); return !node.isInline() && node.canBeEmpty() !== false && isLeafElement; } export function $getAncestor( node: LexicalNode, predicate: (ancestor: LexicalNode) => ancestor is NodeType, ) { let parent = node; while (parent !== null && parent.getParent() !== null && !predicate(parent)) { parent = parent.getParentOrThrow(); } return predicate(parent) ? parent : null; }