UNPKG

@terrible-lexical/selection

Version:

This package contains utilities and helpers for handling Lexical selection.

440 lines (403 loc) 14.4 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. * */ import type { ElementNode, GridSelection, LexicalEditor, LexicalNode, NodeSelection, Point, RangeSelection, TextNode, } from 'terrible-lexical'; import { $createTextNode, $getNodeByKey, $getPreviousSelection, $isElementNode, $isRangeSelection, $isRootNode, $isTextNode, DEPRECATED_$isGridSelection, } from 'terrible-lexical'; import {CSS_TO_STYLES} from './constants'; import { getCSSFromStyleObject, getStyleObjectFromCSS, getStyleObjectFromRawCSS, } from './utils'; function $updateElementNodeProperties<T extends ElementNode>( target: T, source: ElementNode, ): T { target.__first = source.__first; target.__last = source.__last; target.__size = source.__size; target.__format = source.__format; target.__indent = source.__indent; target.__dir = source.__dir; return target; } function $updateTextNodeProperties<T extends TextNode>( target: T, source: TextNode, ): T { target.__format = source.__format; target.__style = source.__style; target.__mode = source.__mode; target.__detail = source.__detail; return target; } /** * Returns a copy of a node, but generates a new key for the copy. * @param node - The node to be cloned. * @returns The clone of the node. */ export function $cloneWithProperties<T extends LexicalNode>(node: T): T { const constructor = node.constructor; // @ts-expect-error const clone: T = constructor.clone(node); clone.__parent = node.__parent; clone.__next = node.__next; clone.__prev = node.__prev; if ($isElementNode(node) && $isElementNode(clone)) { return $updateElementNodeProperties(clone, node); } if ($isTextNode(node) && $isTextNode(clone)) { return $updateTextNodeProperties(clone, node); } return clone; } /** * 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. */ export function $sliceSelectedTextNodeContent( selection: RangeSelection | GridSelection | NodeSelection, textNode: TextNode, ): LexicalNode { if ( textNode.isSelected() && !textNode.isSegmented() && !textNode.isToken() && ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) ) { const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); const isAnchor = textNode.is(anchorNode); const isFocus = textNode.is(focusNode); if (isAnchor || isFocus) { const isBackward = selection.isBackward(); const [anchorOffset, focusOffset] = selection.getCharacterOffsets(); 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; } textNode.__text = textNode.__text.slice(startOffset, endOffset); return textNode; } } 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. */ export function $isAtNodeEnd(point: Point): boolean { if (point.type === 'text') { return point.offset === point.getNode().getTextContentSize(); } return point.offset === point.getNode().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; */ export function trimTextContentFromAnchor( editor: LexicalEditor, anchor: Point, delCount: number, ): void { // Work from the current selection anchor point let currentNode: LexicalNode | null = anchor.getNode(); let remaining: number = delCount; if ($isElementNode(currentNode)) { const descendantNode = currentNode.getDescendantByIndex(anchor.offset); if (descendantNode !== null) { currentNode = descendantNode; } } while (remaining > 0 && currentNode !== null) { let nextNode: LexicalNode | null = currentNode.getPreviousSibling(); let additionalElementWhitespace = 0; if (nextNode === null) { let parent: LexicalNode | null = currentNode.getParentOrThrow(); let parentSibling: LexicalNode | null = 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; if ($isElementNode(parentSibling)) { nextNode = parentSibling.getLastDescendant(); } else { 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 === '' && $isElementNode(currentNode) && !currentNode.isInline()) { // TODO: should this be handled in core? text = '\n\n'; } const currentNodeSize = text.length; if (!$isTextNode(currentNode) || remaining >= currentNodeSize) { const parent = currentNode.getParent(); currentNode.remove(); if ( parent != null && parent.getChildrenSize() === 0 && !$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: string | null = editor .getEditorState() .read(() => { const prevNode = $getNodeByKey(key); if ($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 = $getPreviousSelection(); let target = currentNode; if (!currentNode.isSimpleText()) { const textNode = $createTextNode(prevTextContent); currentNode.replace(textNode); target = textNode; } else { currentNode.setTextContent(prevTextContent); } if ($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 = $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. */ export function $addNodeStyle(node: TextNode): void { const CSSText = node.getStyle(); const styles = getStyleObjectFromRawCSS(CSSText); CSS_TO_STYLES.set(CSSText, styles); } function $patchStyle( target: TextNode | RangeSelection, patch: Record<string, string | null>, ): void { const prevStyles = getStyleObjectFromCSS( 'getStyle' in target ? target.getStyle() : target.style, ); const newStyles = Object.entries(patch).reduce<Record<string, string>>( (styles, [key, value]) => { if (value === null) { delete styles[key]; } else { styles[key] = value; } return styles; }, {...prevStyles} || {}, ); const newCSSText = getCSSFromStyleObject(newStyles); target.setStyle(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 } */ export function $patchStyleText( selection: RangeSelection, patch: Record<string, string | null>, ): void { const selectedNodes = selection.getNodes(); const selectedNodesLength = selectedNodes.length; const lastIndex = selectedNodesLength - 1; let firstNode = selectedNodes[0]; let lastNode = selectedNodes[lastIndex]; if (selection.isCollapsed()) { $patchStyle(selection, patch); return; } const anchor = selection.anchor; const focus = selection.focus; const firstNodeText = firstNode.getTextContent(); const firstNodeTextLength = firstNodeText.length; const focusOffset = focus.offset; let anchorOffset = anchor.offset; const isBefore = anchor.isBefore(focus); let startOffset = isBefore ? anchorOffset : focusOffset; let endOffset = isBefore ? focusOffset : anchorOffset; const startType = isBefore ? anchor.type : focus.type; const endType = isBefore ? focus.type : anchor.type; const endKey = isBefore ? focus.key : anchor.key; // This is the case where the user only selected the very end of the // first node so we don't want to include it in the formatting change. if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) { const nextSibling = firstNode.getNextSibling(); if ($isTextNode(nextSibling)) { // we basically make the second node the firstNode, changing offsets accordingly anchorOffset = 0; startOffset = 0; firstNode = nextSibling; } } // This is the case where we only selected a single node if (selectedNodes.length === 1) { if ($isTextNode(firstNode)) { startOffset = startType === 'element' ? 0 : anchorOffset > focusOffset ? focusOffset : anchorOffset; endOffset = endType === 'element' ? firstNodeTextLength : anchorOffset > focusOffset ? anchorOffset : focusOffset; // No actual text is selected, so do nothing. if (startOffset === endOffset) { return; } // The entire node is selected, so just format it if (startOffset === 0 && endOffset === firstNodeTextLength) { $patchStyle(firstNode, patch); firstNode.select(startOffset, endOffset); } else { // The node is partially selected, so split it into two nodes // and style the selected one. const splitNodes = firstNode.splitText(startOffset, endOffset); const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; $patchStyle(replacement, patch); replacement.select(0, endOffset - startOffset); } } // multiple nodes selected. } else { if ( $isTextNode(firstNode) && startOffset < firstNode.getTextContentSize() ) { if (startOffset !== 0) { // the entire first node isn't selected, so split it firstNode = firstNode.splitText(startOffset)[1]; startOffset = 0; } $patchStyle(firstNode as TextNode, patch); } if ($isTextNode(lastNode)) { const lastNodeText = lastNode.getTextContent(); const lastNodeTextLength = lastNodeText.length; // The last node might not actually be the end node // // If not, assume the last node is fully-selected unless the end offset is // zero. if (lastNode.__key !== endKey && endOffset !== 0) { endOffset = lastNodeTextLength; } // if the entire last node isn't selected, split it if (endOffset !== lastNodeTextLength) { [lastNode] = lastNode.splitText(endOffset); } if (endOffset !== 0) { $patchStyle(lastNode as TextNode, patch); } } // style all the text nodes in between for (let i = 1; i < lastIndex; i++) { const selectedNode = selectedNodes[i]; const selectedNodeKey = selectedNode.getKey(); if ( $isTextNode(selectedNode) && selectedNodeKey !== firstNode.getKey() && selectedNodeKey !== lastNode.getKey() && !selectedNode.isToken() ) { $patchStyle(selectedNode, patch); } } } }