428 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			428 lines
		
	
	
		
			14 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 {
 | |
|   $createTextNode,
 | |
|   $getCharacterOffsets,
 | |
|   $getNodeByKey,
 | |
|   $getPreviousSelection,
 | |
|   $isElementNode,
 | |
|   $isRangeSelection,
 | |
|   $isRootNode,
 | |
|   $isTextNode,
 | |
|   $isTokenOrSegmented,
 | |
|   BaseSelection,
 | |
|   LexicalEditor,
 | |
|   LexicalNode,
 | |
|   Point,
 | |
|   RangeSelection,
 | |
|   TextNode,
 | |
| } from 'lexical';
 | |
| import invariant from 'lexical/shared/invariant';
 | |
| 
 | |
| import {CSS_TO_STYLES} from './constants';
 | |
| import {
 | |
|   getCSSFromStyleObject,
 | |
|   getStyleObjectFromCSS,
 | |
|   getStyleObjectFromRawCSS,
 | |
| } from './utils';
 | |
| 
 | |
| /**
 | |
|  * 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: BaseSelection,
 | |
|   textNode: TextNode,
 | |
| ): LexicalNode {
 | |
|   const anchorAndFocus = selection.getStartEndPoints();
 | |
|   if (
 | |
|     textNode.isSelected(selection) &&
 | |
|     !textNode.isSegmented() &&
 | |
|     !textNode.isToken() &&
 | |
|     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] = $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;
 | |
|       }
 | |
| 
 | |
|       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();
 | |
|   }
 | |
|   const node = point.getNode();
 | |
|   invariant(
 | |
|     $isElementNode(node),
 | |
|     '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;
 | |
|  */
 | |
| 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) {
 | |
|     if ($isElementNode(currentNode)) {
 | |
|       const lastDescendant: null | LexicalNode =
 | |
|         currentNode.getLastDescendant<LexicalNode>();
 | |
|       if (lastDescendant !== null) {
 | |
|         currentNode = lastDescendant;
 | |
|       }
 | |
|     }
 | |
|     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;
 | |
|         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
 | |
|     | ((currentStyleValue: string | null, _target: typeof target) => string)
 | |
|   >,
 | |
| ): void {
 | |
|   const prevStyles = getStyleObjectFromCSS(
 | |
|     'getStyle' in target ? target.getStyle() : target.style,
 | |
|   );
 | |
|   const newStyles = Object.entries(patch).reduce<Record<string, string>>(
 | |
|     (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);
 | |
|   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\\} . Can also accept a function that returns the new property value.
 | |
|  */
 | |
| export function $patchStyleText(
 | |
|   selection: BaseSelection,
 | |
|   patch: Record<
 | |
|     string,
 | |
|     | string
 | |
|     | null
 | |
|     | ((
 | |
|         currentStyleValue: string | null,
 | |
|         target: TextNode | RangeSelection,
 | |
|       ) => string)
 | |
|   >,
 | |
| ): void {
 | |
|   const selectedNodes = selection.getNodes();
 | |
|   const selectedNodesLength = selectedNodes.length;
 | |
|   const anchorAndFocus = selection.getStartEndPoints();
 | |
|   if (anchorAndFocus === null) {
 | |
|     return;
 | |
|   }
 | |
|   const [anchor, focus] = anchorAndFocus;
 | |
| 
 | |
|   const lastIndex = selectedNodesLength - 1;
 | |
|   let firstNode = selectedNodes[0];
 | |
|   let lastNode = selectedNodes[lastIndex];
 | |
| 
 | |
|   if (selection.isCollapsed() && $isRangeSelection(selection)) {
 | |
|     $patchStyle(selection, patch);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   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) && firstNode.canHaveFormat()) {
 | |
|       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 or a token/segment, so just format it
 | |
|       if (
 | |
|         $isTokenOrSegmented(firstNode) ||
 | |
|         (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() &&
 | |
|       firstNode.canHaveFormat()
 | |
|     ) {
 | |
|       if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
 | |
|         // the entire first node isn't selected and it isn't a token or segmented, so split it
 | |
|         firstNode = firstNode.splitText(startOffset)[1];
 | |
|         startOffset = 0;
 | |
|         if (isBefore) {
 | |
|           anchor.set(firstNode.getKey(), startOffset, 'text');
 | |
|         } else {
 | |
|           focus.set(firstNode.getKey(), startOffset, 'text');
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       $patchStyle(firstNode as TextNode, patch);
 | |
|     }
 | |
| 
 | |
|     if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
 | |
|       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 and it isn't a token or segmented, split it
 | |
|       if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
 | |
|         [lastNode] = lastNode.splitText(endOffset);
 | |
|       }
 | |
| 
 | |
|       if (endOffset !== 0 || endType === 'element') {
 | |
|         $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) &&
 | |
|         selectedNode.canHaveFormat() &&
 | |
|         selectedNodeKey !== firstNode.getKey() &&
 | |
|         selectedNodeKey !== lastNode.getKey() &&
 | |
|         !selectedNode.isToken()
 | |
|       ) {
 | |
|         $patchStyle(selectedNode, patch);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 |