UNPKG

@lexical/text

Version:

This package contains utilities and helpers for handling Lexical text.

346 lines (327 loc) 11.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. * */ /** * Returns the root's text content. * @returns The root's text content. */ function $rootTextContent() { const root = lexical.$getRoot(); return root.getTextContent(); } /** * 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. * */ /** * Determines if the root has any text content and can trim any whitespace if it does. * @param isEditorComposing - Is the editor in composition mode due to an active Input Method Editor? * @param trim - Should the root text have its whitespaced trimmed? Defaults to true. * @returns true if text content is empty, false if there is text or isEditorComposing is true. */ function $isRootTextContentEmpty(isEditorComposing, trim = true) { if (isEditorComposing) { return false; } let text = $rootTextContent(); if (trim) { text = text.trim(); } return text === ''; } /** * Returns a function that executes {@link $isRootTextContentEmpty} * @param isEditorComposing - Is the editor in composition mode due to an active Input Method Editor? * @param trim - Should the root text have its whitespaced trimmed? Defaults to true. * @returns A function that executes $isRootTextContentEmpty based on arguments. */ function $isRootTextContentEmptyCurry(isEditorComposing, trim) { return () => $isRootTextContentEmpty(isEditorComposing, trim); } /** * 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. * */ /** * Determines if the input should show the placeholder. If anything is in * in the root the placeholder should not be shown. * @param isComposing - Is the editor in composition mode due to an active Input Method Editor? * @returns true if the input should show the placeholder, false otherwise. */ function $canShowPlaceholder(isComposing) { if (!$isRootTextContentEmpty(isComposing, false)) { return false; } const root = lexical.$getRoot(); const children = root.getChildren(); const childrenLength = children.length; if (childrenLength > 1) { return false; } for (let i = 0; i < childrenLength; i++) { const topBlock = children[i]; if (lexical.$isDecoratorNode(topBlock)) { return false; } if (lexical.$isElementNode(topBlock)) { if (!lexical.$isParagraphNode(topBlock)) { return false; } if (topBlock.__indent !== 0) { return false; } const topBlockChildren = topBlock.getChildren(); const topBlockChildrenLength = topBlockChildren.length; for (let s = 0; s < topBlockChildrenLength; s++) { const child = topBlockChildren[i]; if (!lexical.$isTextNode(child)) { return false; } } } } return true; } /** * Returns a function that executes {@link $canShowPlaceholder} * @param isEditorComposing - Is the editor in composition mode due to an active Input Method Editor? * @returns A function that executes $canShowPlaceholder with arguments. */ function $canShowPlaceholderCurry(isEditorComposing) { return () => $canShowPlaceholder(isEditorComposing); } /** * 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. * */ /** * Finds a TextNode with a size larger than targetCharacters and returns * the node along with the remaining length of the text. * @param root - The RootNode. * @param targetCharacters - The number of characters whose TextNode must be larger than. * @returns The TextNode and the intersections offset, or null if no TextNode is found. */ function $findTextIntersectionFromCharacters(root, targetCharacters) { let node = root.getFirstChild(); let currentCharacters = 0; mainLoop: while (node !== null) { if (lexical.$isElementNode(node)) { const child = node.getFirstChild(); if (child !== null) { node = child; continue; } } else if (lexical.$isTextNode(node)) { const characters = node.getTextContentSize(); if (currentCharacters + characters > targetCharacters) { return { node, offset: targetCharacters - currentCharacters }; } currentCharacters += characters; } const sibling = node.getNextSibling(); if (sibling !== null) { node = sibling; continue; } let parent = node.getParent(); while (parent !== null) { const parentSibling = parent.getNextSibling(); if (parentSibling !== null) { node = parentSibling; continue mainLoop; } parent = parent.getParent(); } break; } return 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * Returns a tuple that can be rested (...) into mergeRegister to clean up * node transforms listeners that transforms text into another node, eg. a HashtagNode. * @example * ```ts * useEffect(() => { return mergeRegister( ...registerLexicalTextEntity(editor, getMatch, targetNode, createNode), ); }, [createNode, editor, getMatch, targetNode]); * ``` * Where targetNode is the type of node containing the text you want to transform (like a text input), * then getMatch uses a regex to find a matching text and creates the proper node to include the matching text. * @param editor - The lexical editor. * @param getMatch - Finds a matching string that satisfies a regex expression. * @param targetNode - The node type that contains text to match with. eg. HashtagNode * @param createNode - A function that creates a new node to contain the matched text. eg createHashtagNode * @returns An array containing the plain text and reverse node transform listeners. */ function registerLexicalTextEntity(editor, getMatch, targetNode, createNode) { const isTargetNode = node => { return node instanceof targetNode; }; const $replaceWithSimpleText = node => { const textNode = lexical.$createTextNode(node.getTextContent()); textNode.setFormat(node.getFormat()); node.replace(textNode); }; const getMode = node => { return node.getLatest().__mode; }; const $textNodeTransform = node => { if (!node.isSimpleText()) { return; } let prevSibling = node.getPreviousSibling(); let text = node.getTextContent(); let currentNode = node; let match; if (lexical.$isTextNode(prevSibling)) { const previousText = prevSibling.getTextContent(); const combinedText = previousText + text; const prevMatch = getMatch(combinedText); if (isTargetNode(prevSibling)) { if (prevMatch === null || getMode(prevSibling) !== 0) { $replaceWithSimpleText(prevSibling); return; } else { const diff = prevMatch.end - previousText.length; if (diff > 0) { const concatText = text.slice(0, diff); const newTextContent = previousText + concatText; prevSibling.select(); prevSibling.setTextContent(newTextContent); if (diff === text.length) { node.remove(); } else { const remainingText = text.slice(diff); node.setTextContent(remainingText); } return; } } } else if (prevMatch === null || prevMatch.start < previousText.length) { return; } } let prevMatchLengthToSkip = 0; // eslint-disable-next-line no-constant-condition while (true) { match = getMatch(text); let nextText = match === null ? '' : text.slice(match.end); text = nextText; if (nextText === '') { const nextSibling = currentNode.getNextSibling(); if (lexical.$isTextNode(nextSibling)) { nextText = currentNode.getTextContent() + nextSibling.getTextContent(); const nextMatch = getMatch(nextText); if (nextMatch === null) { if (isTargetNode(nextSibling)) { $replaceWithSimpleText(nextSibling); } else { nextSibling.markDirty(); } return; } else if (nextMatch.start !== 0) { return; } } } if (match === null) { return; } if (match.start === 0 && lexical.$isTextNode(prevSibling) && prevSibling.isTextEntity()) { prevMatchLengthToSkip += match.end; continue; } let nodeToReplace; if (match.start === 0) { [nodeToReplace, currentNode] = currentNode.splitText(match.end); } else { [, nodeToReplace, currentNode] = currentNode.splitText(match.start + prevMatchLengthToSkip, match.end + prevMatchLengthToSkip); } if (!(nodeToReplace !== undefined)) { formatDevErrorMessage(`${'nodeToReplace'} should not be undefined. You may want to check splitOffsets passed to the splitText.`); } const replacementNode = createNode(nodeToReplace); replacementNode.setFormat(nodeToReplace.getFormat()); nodeToReplace.replace(replacementNode); if (currentNode == null) { return; } prevMatchLengthToSkip = 0; prevSibling = replacementNode; } }; const $reverseNodeTransform = node => { const text = node.getTextContent(); const match = getMatch(text); if (match === null || match.start !== 0) { $replaceWithSimpleText(node); return; } if (text.length > match.end) { // This will split out the rest of the text as simple text node.splitText(match.end); return; } const prevSibling = node.getPreviousSibling(); if (lexical.$isTextNode(prevSibling) && prevSibling.isTextEntity()) { $replaceWithSimpleText(prevSibling); $replaceWithSimpleText(node); } const nextSibling = node.getNextSibling(); if (lexical.$isTextNode(nextSibling) && nextSibling.isTextEntity()) { $replaceWithSimpleText(nextSibling); // This may have already been converted in the previous block if (isTargetNode(node)) { $replaceWithSimpleText(node); } } }; const removePlainTextTransform = editor.registerNodeTransform(lexical.TextNode, $textNodeTransform); const removeReverseNodeTransform = editor.registerNodeTransform(targetNode, $reverseNodeTransform); return [removePlainTextTransform, removeReverseNodeTransform]; } exports.$canShowPlaceholder = $canShowPlaceholder; exports.$canShowPlaceholderCurry = $canShowPlaceholderCurry; exports.$findTextIntersectionFromCharacters = $findTextIntersectionFromCharacters; exports.$isRootTextContentEmpty = $isRootTextContentEmpty; exports.$isRootTextContentEmptyCurry = $isRootTextContentEmptyCurry; exports.$rootTextContent = $rootTextContent; exports.registerLexicalTextEntity = registerLexicalTextEntity;