UNPKG

@lexical/selection

Version:

This package contains utilities and helpers for handling Lexical selection.

1,051 lines (1,002 loc) 39.7 kB
/** * 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. * */ 'use strict'; var lexical = require('lexical'); /** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * 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. * */ const CSS_TO_STYLES = new Map(); /** * 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. * */ function getDOMTextNode(element) { let node = element; while (node != null) { if (node.nodeType === Node.TEXT_NODE) { return node; } node = node.firstChild; } return null; } function getDOMIndexWithinParent(node) { const parent = node.parentNode; if (parent == null) { throw new Error('Should never happen'); } return [parent, Array.from(parent.childNodes).indexOf(node)]; } /** * Creates a selection range for the DOM. * @param editor - The lexical editor. * @param anchorNode - The anchor node of a selection. * @param _anchorOffset - The amount of space offset from the anchor to the focus. * @param focusNode - The current focus. * @param _focusOffset - The amount of space offset from the focus to the anchor. * @returns The range of selection for the DOM that was created. */ function createDOMRange(editor, anchorNode, _anchorOffset, focusNode, _focusOffset) { const anchorKey = anchorNode.getKey(); const focusKey = focusNode.getKey(); const range = document.createRange(); let anchorDOM = editor.getElementByKey(anchorKey); let focusDOM = editor.getElementByKey(focusKey); let anchorOffset = _anchorOffset; let focusOffset = _focusOffset; if (lexical.$isTextNode(anchorNode)) { anchorDOM = getDOMTextNode(anchorDOM); } if (lexical.$isTextNode(focusNode)) { focusDOM = getDOMTextNode(focusDOM); } if (anchorNode === undefined || focusNode === undefined || anchorDOM === null || focusDOM === null) { return null; } if (anchorDOM.nodeName === 'BR') { [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM); } if (focusDOM.nodeName === 'BR') { [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM); } const firstChild = anchorDOM.firstChild; if (anchorDOM === focusDOM && firstChild != null && firstChild.nodeName === 'BR' && anchorOffset === 0 && focusOffset === 0) { focusOffset = 1; } try { range.setStart(anchorDOM, anchorOffset); range.setEnd(focusDOM, focusOffset); } catch (e) { return null; } if (range.collapsed && (anchorOffset !== focusOffset || anchorKey !== focusKey)) { // Range is backwards, we need to reverse it range.setStart(focusDOM, focusOffset); range.setEnd(anchorDOM, anchorOffset); } return range; } /** * Creates DOMRects, generally used to help the editor find a specific location on the screen. * @param editor - The lexical editor * @param range - A fragment of a document that can contain nodes and parts of text nodes. * @returns The selectionRects as an array. */ function createRectsFromDOMRange(editor, range) { const rootElement = editor.getRootElement(); if (rootElement === null) { return []; } const rootRect = rootElement.getBoundingClientRect(); const computedStyle = getComputedStyle(rootElement); const rootPadding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); const selectionRects = Array.from(range.getClientRects()); let selectionRectsLength = selectionRects.length; //sort rects from top left to bottom right. selectionRects.sort((a, b) => { const top = a.top - b.top; // Some rects match position closely, but not perfectly, // so we give a 3px tolerance. if (Math.abs(top) <= 3) { return a.left - b.left; } return top; }); let prevRect; for (let i = 0; i < selectionRectsLength; i++) { const selectionRect = selectionRects[i]; // Exclude rects that overlap preceding Rects in the sorted list. const isOverlappingRect = prevRect && prevRect.top <= selectionRect.top && prevRect.top + prevRect.height > selectionRect.top && prevRect.left + prevRect.width > selectionRect.left; // Exclude selections that span the entire element const selectionSpansElement = selectionRect.width + rootPadding === rootRect.width; if (isOverlappingRect || selectionSpansElement) { selectionRects.splice(i--, 1); selectionRectsLength--; continue; } prevRect = selectionRect; } return selectionRects; } /** * Creates an object containing all the styles and their values provided in the CSS string. * @param css - The CSS string of styles and their values. * @returns The styleObject containing all the styles and their values. */ function getStyleObjectFromRawCSS(css) { const styleObject = {}; if (!css) { return styleObject; } const styles = css.split(';'); for (const style of styles) { if (style !== '') { const [key, value] = style.split(/:([^]+)/); // split on first colon if (key && value) { styleObject[key.trim()] = value.trim(); } } } return styleObject; } /** * Given a CSS string, returns an object from the style cache. * @param css - The CSS property as a string. * @returns The value of the given CSS property. */ function getStyleObjectFromCSS(css) { let value = CSS_TO_STYLES.get(css); if (value === undefined) { value = getStyleObjectFromRawCSS(css); CSS_TO_STYLES.set(css, value); } { // Freeze the value in DEV to prevent accidental mutations Object.freeze(value); } return value; } /** * Gets the CSS styles from the style object. * @param styles - The style object containing the styles to get. * @returns A string containing the CSS styles and their values. */ function getCSSFromStyleObject(styles) { let css = ''; for (const style in styles) { if (style) { css += `${style}: ${styles[style]};`; } } return css; } /** * Gets the computed DOM styles of the element. * @param node - The node to check the styles for. * @returns the computed styles of the element or null if there is no DOM element or no default view for the document. */ function $getComputedStyleForElement(element) { const editor = lexical.$getEditor(); const domElement = editor.getElementByKey(element.getKey()); if (domElement === null) { return null; } const view = domElement.ownerDocument.defaultView; if (view === null) { return null; } return view.getComputedStyle(domElement); } /** * Gets the computed DOM styles of the parent of the node. * @param node - The node to check its parent's styles for. * @returns the computed styles of the node or null if there is no DOM element or no default view for the document. */ function $getComputedStyleForParent(node) { const parent = lexical.$isRootNode(node) ? node : node.getParentOrThrow(); return $getComputedStyleForElement(parent); } /** * Determines whether a node's parent is RTL. * @param node - The node to check whether it is RTL. * @returns whether the node is RTL. */ function $isParentRTL(node) { const styles = $getComputedStyleForParent(node); return styles !== null && styles.direction === 'rtl'; } /** * Generally used to append text content to HTML and JSON. Grabs the text content and "slices" * it to be generated into the new TextNode. * @param selection - The selection containing the node whose TextNode is to be edited. * @param textNode - The TextNode to be edited. * @returns The updated TextNode. */ function $sliceSelectedTextNodeContent(selection, textNode) { const anchorAndFocus = selection.getStartEndPoints(); if (textNode.isSelected(selection) && !lexical.$isTokenOrSegmented(textNode) && anchorAndFocus !== null) { const [anchor, focus] = anchorAndFocus; const isBackward = selection.isBackward(); const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); const isAnchor = textNode.is(anchorNode); const isFocus = textNode.is(focusNode); if (isAnchor || isFocus) { const [anchorOffset, focusOffset] = lexical.$getCharacterOffsets(selection); const isSame = anchorNode.is(focusNode); const isFirst = textNode.is(isBackward ? focusNode : anchorNode); const isLast = textNode.is(isBackward ? anchorNode : focusNode); let startOffset = 0; let endOffset = undefined; if (isSame) { startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset; endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset; } else if (isFirst) { const offset = isBackward ? focusOffset : anchorOffset; startOffset = offset; endOffset = undefined; } else if (isLast) { const offset = isBackward ? anchorOffset : focusOffset; startOffset = 0; endOffset = offset; } // NOTE: This mutates __text directly because the primary use case is to // modify a $cloneWithProperties node that should never be added // to the EditorState so we must not call getWritable via setTextContent textNode.__text = textNode.__text.slice(startOffset, endOffset); } } return textNode; } /** * Determines if the current selection is at the end of the node. * @param point - The point of the selection to test. * @returns true if the provided point offset is in the last possible position, false otherwise. */ function $isAtNodeEnd(point) { if (point.type === 'text') { return point.offset === point.getNode().getTextContentSize(); } const node = point.getNode(); if (!lexical.$isElementNode(node)) { formatDevErrorMessage(`isAtNodeEnd: node must be a TextNode or ElementNode`); } return point.offset === node.getChildrenSize(); } /** * Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text * that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes * the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode. * @param editor - The lexical editor. * @param anchor - The anchor of the current selection, where the selection should be pointing. * @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength; */ function $trimTextContentFromAnchor(editor, anchor, delCount) { // Work from the current selection anchor point let currentNode = anchor.getNode(); let remaining = delCount; if (lexical.$isElementNode(currentNode)) { const descendantNode = currentNode.getDescendantByIndex(anchor.offset); if (descendantNode !== null) { currentNode = descendantNode; } } while (remaining > 0 && currentNode !== null) { if (lexical.$isElementNode(currentNode)) { const lastDescendant = currentNode.getLastDescendant(); if (lastDescendant !== null) { currentNode = lastDescendant; } } let nextNode = currentNode.getPreviousSibling(); let additionalElementWhitespace = 0; if (nextNode === null) { let parent = currentNode.getParentOrThrow(); let parentSibling = parent.getPreviousSibling(); while (parentSibling === null) { parent = parent.getParent(); if (parent === null) { nextNode = null; break; } parentSibling = parent.getPreviousSibling(); } if (parent !== null) { additionalElementWhitespace = parent.isInline() ? 0 : 2; nextNode = parentSibling; } } let text = currentNode.getTextContent(); // If the text is empty, we need to consider adding in two line breaks to match // the content if we were to get it from its parent. if (text === '' && lexical.$isElementNode(currentNode) && !currentNode.isInline()) { // TODO: should this be handled in core? text = '\n\n'; } const currentNodeSize = text.length; if (!lexical.$isTextNode(currentNode) || remaining >= currentNodeSize) { const parent = currentNode.getParent(); currentNode.remove(); if (parent != null && parent.getChildrenSize() === 0 && !lexical.$isRootNode(parent)) { parent.remove(); } remaining -= currentNodeSize + additionalElementWhitespace; currentNode = nextNode; } else { const key = currentNode.getKey(); // See if we can just revert it to what was in the last editor state const prevTextContent = editor.getEditorState().read(() => { const prevNode = lexical.$getNodeByKey(key); if (lexical.$isTextNode(prevNode) && prevNode.isSimpleText()) { return prevNode.getTextContent(); } return null; }); const offset = currentNodeSize - remaining; const slicedText = text.slice(0, offset); if (prevTextContent !== null && prevTextContent !== text) { const prevSelection = lexical.$getPreviousSelection(); let target = currentNode; if (!currentNode.isSimpleText()) { const textNode = lexical.$createTextNode(prevTextContent); currentNode.replace(textNode); target = textNode; } else { currentNode.setTextContent(prevTextContent); } if (lexical.$isRangeSelection(prevSelection) && prevSelection.isCollapsed()) { const prevOffset = prevSelection.anchor.offset; target.select(prevOffset, prevOffset); } } else if (currentNode.isSimpleText()) { // Split text const isSelected = anchor.key === key; let anchorOffset = anchor.offset; // Move offset to end if it's less than the remaining number, otherwise // we'll have a negative splitStart. if (anchorOffset < remaining) { anchorOffset = currentNodeSize; } const splitStart = isSelected ? anchorOffset - remaining : 0; const splitEnd = isSelected ? anchorOffset : offset; if (isSelected && splitStart === 0) { const [excessNode] = currentNode.splitText(splitStart, splitEnd); excessNode.remove(); } else { const [, excessNode] = currentNode.splitText(splitStart, splitEnd); excessNode.remove(); } } else { const textNode = lexical.$createTextNode(slicedText); currentNode.replace(textNode); } remaining = 0; } } } /** * Gets the TextNode's style object and adds the styles to the CSS. * @param node - The TextNode to add styles to. */ function $addNodeStyle(node) { const CSSText = node.getStyle(); const styles = getStyleObjectFromRawCSS(CSSText); CSS_TO_STYLES.set(CSSText, styles); } /** * Applies the provided styles to the given TextNode, ElementNode, or * collapsed RangeSelection. * * @param target - The TextNode, ElementNode, or collapsed RangeSelection to apply the styles to * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value. */ function $patchStyle(target, patch) { if (!(lexical.$isRangeSelection(target) ? target.isCollapsed() : lexical.$isTextNode(target) || lexical.$isElementNode(target))) { formatDevErrorMessage(`$patchStyle must only be called with a TextNode, ElementNode, or collapsed RangeSelection`); } const prevStyles = getStyleObjectFromCSS(lexical.$isRangeSelection(target) ? target.style : lexical.$isTextNode(target) ? target.getStyle() : target.getTextStyle()); const newStyles = Object.entries(patch).reduce((styles, [key, value]) => { if (typeof value === 'function') { styles[key] = value(prevStyles[key], target); } else if (value === null) { delete styles[key]; } else { styles[key] = value; } return styles; }, { ...prevStyles }); const newCSSText = getCSSFromStyleObject(newStyles); if (lexical.$isRangeSelection(target) || lexical.$isTextNode(target)) { target.setStyle(newCSSText); } else { target.setTextStyle(newCSSText); } CSS_TO_STYLES.set(newCSSText, newStyles); } /** * Applies the provided styles to the TextNodes in the provided Selection. * Will update partially selected TextNodes by splitting the TextNode and applying * the styles to the appropriate one. * @param selection - The selected node(s) to update. * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value. */ function $patchStyleText(selection, patch) { if (lexical.$isRangeSelection(selection) && selection.isCollapsed()) { $patchStyle(selection, patch); const emptyNode = selection.anchor.getNode(); if (lexical.$isElementNode(emptyNode) && emptyNode.isEmpty()) { $patchStyle(emptyNode, patch); } } $forEachSelectedTextNode(textNode => { $patchStyle(textNode, patch); }); } function $forEachSelectedTextNode(fn) { const selection = lexical.$getSelection(); if (!selection) { return; } const slicedTextNodes = new Map(); const getSliceIndices = node => slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()]; if (lexical.$isRangeSelection(selection)) { for (const slice of lexical.$caretRangeFromSelection(selection).getTextSlices()) { if (slice) { slicedTextNodes.set(slice.caret.origin.getKey(), slice.getSliceIndices()); } } } const selectedNodes = selection.getNodes(); for (const selectedNode of selectedNodes) { if (!(lexical.$isTextNode(selectedNode) && selectedNode.canHaveFormat())) { continue; } const [startOffset, endOffset] = getSliceIndices(selectedNode); // No actual text is selected, so do nothing. if (endOffset === startOffset) { continue; } // The entire node is selected or a token/segment, so just format it if (lexical.$isTokenOrSegmented(selectedNode) || startOffset === 0 && endOffset === selectedNode.getTextContentSize()) { fn(selectedNode); } else { // The node is partially selected, so split it into two or three nodes // and style the selected one. const splitNodes = selectedNode.splitText(startOffset, endOffset); const replacement = splitNodes[startOffset === 0 ? 0 : 1]; fn(replacement); } } // Prior to NodeCaret #7046 this would have been a side-effect // so we do this for test compatibility. // TODO: we may want to consider simplifying by removing this if (lexical.$isRangeSelection(selection) && selection.anchor.type === 'text' && selection.focus.type === 'text' && selection.anchor.key === selection.focus.key) { $ensureForwardRangeSelection(selection); } } /** * Ensure that the given RangeSelection is not backwards. If it * is backwards, then the anchor and focus points will be swapped * in-place. Ensuring that the selection is a writable RangeSelection * is the responsibility of the caller (e.g. in a read-only context * you will want to clone $getSelection() before using this). * * @param selection a writable RangeSelection */ function $ensureForwardRangeSelection(selection) { if (selection.isBackward()) { const { anchor, focus } = selection; // stash for the in-place swap const { key, offset, type } = anchor; anchor.set(focus.key, focus.offset, focus.type); focus.set(key, offset, type); } } function $copyBlockFormatIndent(srcNode, destNode) { const format = srcNode.getFormatType(); const indent = srcNode.getIndent(); if (format !== destNode.getFormatType()) { destNode.setFormat(format); } if (indent !== destNode.getIndent()) { destNode.setIndent(indent); } } /** * 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. * @param $afterCreateElement - The function that updates the new node based on the previous one ($copyBlockFormatIndent by default) */ function $setBlocksType(selection, $createElement, $afterCreateElement = $copyBlockFormatIndent) { if (selection === null) { return; } // Selections tend to not include their containing blocks so we effectively // expand it here const anchorAndFocus = selection.getStartEndPoints(); const blockMap = new Map(); let newSelection = null; if (anchorAndFocus) { const [anchor, focus] = anchorAndFocus; newSelection = lexical.$createRangeSelection(); newSelection.anchor.set(anchor.key, anchor.offset, anchor.type); newSelection.focus.set(focus.key, focus.offset, focus.type); const anchorBlock = $getAncestor(anchor.getNode(), lexical.INTERNAL_$isBlock); const focusBlock = $getAncestor(focus.getNode(), lexical.INTERNAL_$isBlock); if (lexical.$isElementNode(anchorBlock)) { blockMap.set(anchorBlock.getKey(), anchorBlock); } if (lexical.$isElementNode(focusBlock)) { blockMap.set(focusBlock.getKey(), focusBlock); } } for (const node of selection.getNodes()) { if (lexical.$isElementNode(node) && lexical.INTERNAL_$isBlock(node)) { blockMap.set(node.getKey(), node); } else if (anchorAndFocus === null) { const ancestorBlock = $getAncestor(node, lexical.INTERNAL_$isBlock); if (lexical.$isElementNode(ancestorBlock)) { blockMap.set(ancestorBlock.getKey(), ancestorBlock); } } } for (const [key, prevNode] of blockMap) { const element = $createElement(); $afterCreateElement(prevNode, element); prevNode.replace(element, true); if (newSelection) { if (key === newSelection.anchor.key) { newSelection.anchor.set(element.getKey(), newSelection.anchor.offset, newSelection.anchor.type); } if (key === newSelection.focus.key) { newSelection.focus.set(element.getKey(), newSelection.focus.offset, newSelection.focus.type); } } } if (newSelection && selection.is(lexical.$getSelection())) { lexical.$setSelection(newSelection); } } function isPointAttached(point) { return point.getNode().isAttached(); } function $removeParentEmptyElements(startingNode) { let node = startingNode; while (node !== null && !lexical.$isRootOrShadowRoot(node)) { const latest = node.getLatest(); const parentNode = node.getParent(); if (latest.getChildrenSize() === 0) { node.remove(true); } node = parentNode; } } /** * @deprecated In favor of $setBlockTypes * 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. */ function $wrapNodes(selection, createElement, wrappingElement = null) { 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 = []; 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 (lexical.$isRootOrShadowRoot(node)) { $wrapNodesImpl(selection, descendants, descendants.length, createElement, wrappingElement); descendants = []; topLevelNode = node; } else if (topLevelNode === null || topLevelNode !== null && lexical.$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 */ function $wrapNodesImpl(selection, nodes, nodesLength, createElement, wrappingElement = null) { if (nodes.length === 0) { return; } const firstNode = nodes[0]; const elementMapping = 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 = lexical.$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 (lexical.$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 (lexical.$isElementNode(node) && node.getChildrenSize() === 0) { emptyElements.add(node.getKey()); } } const movedNodes = 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 && lexical.$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 (lexical.$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())) { if (!lexical.$isElementNode(node)) { formatDevErrorMessage(`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 (lexical.$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 (lexical.$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 = lexical.$getPreviousSelection(); if (lexical.$isRangeSelection(prevSelection) && isPointAttached(prevSelection.anchor) && isPointAttached(prevSelection.focus)) { lexical.$setSelection(prevSelection.clone()); } else if (lastElement !== null) { lastElement.selectEnd(); } else { selection.dirty = true; } } /** * Tests if the selection's parent element has vertical writing mode. * @param selection - The selection whose parent to test. * @returns true if the selection's parent has vertical writing mode (writing-mode: vertical-rl), false otherwise. */ function $isEditorVerticalOrientation(selection) { const computedStyle = $getComputedStyle(selection); return computedStyle !== null && computedStyle.writingMode === 'vertical-rl'; } /** * Gets the computed DOM styles of the parent of the selection's anchor node. * @param selection - The selection to check the styles for. * @returns the computed styles of the node or null if there is no DOM element or no default view for the document. */ function $getComputedStyle(selection) { const anchorNode = selection.anchor.getNode(); if (lexical.$isElementNode(anchorNode)) { return $getComputedStyleForElement(anchorNode); } return $getComputedStyleForParent(anchorNode); } /** * 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. */ function $shouldOverrideDefaultCharacterSelection(selection, isBackward) { const isVertical = $isEditorVerticalOrientation(selection); // In vertical writing mode, we adjust the direction for correct caret movement let adjustedIsBackward = isVertical ? !isBackward : isBackward; // In right-to-left writing mode, we invert the direction for correct caret movement if ($isParentElementRTL(selection)) { adjustedIsBackward = !adjustedIsBackward; } const focusCaret = lexical.$caretFromPoint(selection.focus, adjustedIsBackward ? 'previous' : 'next'); if (lexical.$isExtendableTextPointCaret(focusCaret)) { return false; } for (const nextCaret of lexical.$extendCaretToRange(focusCaret)) { if (lexical.$isChildCaret(nextCaret)) { return !nextCaret.origin.isInline(); } else if (lexical.$isElementNode(nextCaret.origin)) { continue; } else if (lexical.$isDecoratorNode(nextCaret.origin)) { return true; } break; } return false; } /** * 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. */ function $moveCaretSelection(selection, isHoldingShift, isBackward, granularity) { 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. */ function $isParentElementRTL(selection) { const computedStyle = $getComputedStyle(selection); return computedStyle !== null && computedStyle.direction === '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)? */ function $moveCharacter(selection, isHoldingShift, isBackward) { const isRTL = $isParentElementRTL(selection); const isVertical = $isEditorVerticalOrientation(selection); // In vertical-rl writing mode, arrow key directions need to be flipped // to match the visual flow of text (top to bottom, right to left) let adjustedIsBackward; if (isVertical) { // In vertical-rl mode, we need to completely invert the direction // Left arrow (backward) should move down (forward) // Right arrow (forward) should move up (backward) adjustedIsBackward = !isBackward; } else if (isRTL) { // In horizontal RTL mode, use the standard RTL behavior adjustedIsBackward = !isBackward; } else { // Standard LTR horizontal text adjustedIsBackward = isBackward; } // Apply the direction adjustment to move the caret $moveCaretSelection(selection, isHoldingShift, adjustedIsBackward, 'character'); } /** * 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, styleProperty, defaultValue) { 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. */ function $getSelectionStyleValueForProperty(selection, styleProperty, defaultValue = '') { let styleValue = 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 (lexical.$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 (lexical.$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; } function $getAncestor(node, predicate) { let parent = node; while (parent !== null && parent.getParent() !== null && !predicate(parent)) { parent = parent.getParentOrThrow(); } return predicate(parent) ? parent : null; } /** * 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. * */ /** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */ const trimTextContentFromAnchor = $trimTextContentFromAnchor; exports.$cloneWithProperties = lexical.$cloneWithProperties; exports.$selectAll = lexical.$selectAll; exports.$addNodeStyle = $addNodeStyle; exports.$copyBlockFormatIndent = $copyBlockFormatIndent; exports.$ensureForwardRangeSelection = $ensureForwardRangeSelection; exports.$forEachSelectedTextNode = $forEachSelectedTextNode; exports.$getComputedStyleForElement = $getComputedStyleForElement; exports.$getComputedStyleForParent = $getComputedStyleForParent; exports.$getSelectionStyleValueForProperty = $getSelectionStyleValueForProperty; exports.$isAtNodeEnd = $isAtNodeEnd; exports.$isParentElementRTL = $isParentElementRTL; exports.$isParentRTL = $isParentRTL; exports.$moveCaretSelection = $moveCaretSelection; exports.$moveCharacter = $moveCharacter; exports.$patchStyleText = $patchStyleText; exports.$setBlocksType = $setBlocksType; exports.$shouldOverrideDefaultCharacterSelection = $shouldOverrideDefaultCharacterSelection; exports.$sliceSelectedTextNodeContent = $sliceSelectedTextNodeContent; exports.$trimTextContentFromAnchor = $trimTextContentFromAnchor; exports.$wrapNodes = $wrapNodes; exports.createDOMRange = createDOMRange; exports.createRectsFromDOMRange = createRectsFromDOMRange; exports.getCSSFromStyleObject = getCSSFromStyleObject; exports.getStyleObjectFromCSS = getStyleObjectFromCSS; exports.trimTextContentFromAnchor = trimTextContentFromAnchor;