UNPKG

@terrible-lexical/react

Version:

This package provides Lexical components and hooks for React applications.

315 lines (275 loc) 9.77 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 {LinkAttributes} from '@terrible-lexical/link/src'; import {$createAutoLinkNode, $isAutoLinkNode, $isLinkNode, AutoLinkNode,} from '@terrible-lexical/link/src'; import type {ElementNode, LexicalEditor, LexicalNode} from 'terrible-lexical'; import {$createTextNode, $isElementNode, $isLineBreakNode, $isTextNode, TextNode,} from 'terrible-lexical'; import {useLexicalComposerContext} from '@terrible-lexical/react/src/LexicalComposerContext'; import {mergeRegister} from '@terrible-lexical/utils/src'; import {useEffect} from 'react'; import invariant from '@terrible-lexical/shared/src/invariant'; type ChangeHandler = (url: string | null, prevUrl: string | null) => void; type LinkMatcherResult = { attributes?: LinkAttributes; index: number; length: number; text: string; url: string; }; export type LinkMatcher = (text: string) => LinkMatcherResult | null; export function createLinkMatcherWithRegExp( regExp: RegExp, urlTransformer: (text: string) => string = (text) => text, ) { return (text: string) => { const match = regExp.exec(text); if (match === null) return null; return { index: match.index, length: match[0].length, text: match[0], url: urlTransformer(text), }; }; } function findFirstMatch( text: string, matchers: Array<LinkMatcher>, ): LinkMatcherResult | null { for (let i = 0; i < matchers.length; i++) { const match = matchers[i](text); if (match) { return match; } } return null; } const PUNCTUATION_OR_SPACE = /[.,;\s]/; function isSeparator(char: string): boolean { return PUNCTUATION_OR_SPACE.test(char); } function endsWithSeparator(textContent: string): boolean { return isSeparator(textContent[textContent.length - 1]); } function startsWithSeparator(textContent: string): boolean { return isSeparator(textContent[0]); } function isPreviousNodeValid(node: LexicalNode): boolean { let previousNode = node.getPreviousSibling(); if ($isElementNode(previousNode)) { previousNode = previousNode.getLastDescendant(); } return ( previousNode === null || $isLineBreakNode(previousNode) || ($isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent())) ); } function isNextNodeValid(node: LexicalNode): boolean { let nextNode = node.getNextSibling(); if ($isElementNode(nextNode)) { nextNode = nextNode.getFirstDescendant(); } return ( nextNode === null || $isLineBreakNode(nextNode) || ($isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent())) ); } function isContentAroundIsValid( matchStart: number, matchEnd: number, text: string, node: TextNode, ): boolean { const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(node); if (!contentBeforeIsValid) { return false; } const contentAfterIsValid = matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(node); return contentAfterIsValid; } function handleLinkCreation( node: TextNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler, ): void { const nodeText = node.getTextContent(); let text = nodeText; let invalidMatchEnd = 0; let remainingTextNode = node; let match; while ((match = findFirstMatch(text, matchers)) && match !== null) { const matchStart = match.index; const matchLength = match.length; const matchEnd = matchStart + matchLength; const isValid = isContentAroundIsValid( invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd, nodeText, node, ); if (isValid) { let linkTextNode; if (invalidMatchEnd + matchStart === 0) { [linkTextNode, remainingTextNode] = remainingTextNode.splitText( invalidMatchEnd + matchLength, ); } else { [, linkTextNode, remainingTextNode] = remainingTextNode.splitText( invalidMatchEnd + matchStart, invalidMatchEnd + matchStart + matchLength, ); } const linkNode = $createAutoLinkNode(match.url, match.attributes); const textNode = $createTextNode(match.text); textNode.setFormat(linkTextNode.getFormat()); textNode.setDetail(linkTextNode.getDetail()); linkNode.append(textNode); linkTextNode.replace(linkNode); onChange(match.url, null); invalidMatchEnd = 0; } else { invalidMatchEnd += matchEnd; } text = text.substring(matchEnd); } } function handleLinkEdit( linkNode: AutoLinkNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler, ): void { // Check children are simple text const children = linkNode.getChildren(); const childrenLength = children.length; for (let i = 0; i < childrenLength; i++) { const child = children[i]; if (!$isTextNode(child) || !child.isSimpleText()) { replaceWithChildren(linkNode); onChange(null, linkNode.getURL()); return; } } // Check text content fully matches const text = linkNode.getTextContent(); const match = findFirstMatch(text, matchers); if (match === null || match.text !== text) { replaceWithChildren(linkNode); onChange(null, linkNode.getURL()); return; } // Check neighbors if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) { replaceWithChildren(linkNode); onChange(null, linkNode.getURL()); return; } const url = linkNode.getURL(); if (url !== match.url) { linkNode.setURL(match.url); onChange(match.url, url); } if (match.attributes) { const rel = linkNode.getRel(); if (rel !== match.attributes.rel) { linkNode.setRel(match.attributes.rel || null); onChange(match.attributes.rel || null, rel); } const target = linkNode.getTarget(); if (target !== match.attributes.target) { linkNode.setTarget(match.attributes.target || null); onChange(match.attributes.target || null, target); } } } // Bad neighbours are edits in neighbor nodes that make AutoLinks incompatible. // Given the creation preconditions, these can only be simple text nodes. function handleBadNeighbors( textNode: TextNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler, ): void { const previousSibling = textNode.getPreviousSibling(); const nextSibling = textNode.getNextSibling(); const text = textNode.getTextContent(); if ($isAutoLinkNode(previousSibling) && !startsWithSeparator(text)) { previousSibling.append(textNode); handleLinkEdit(previousSibling, matchers, onChange); onChange(null, previousSibling.getURL()); } if ($isAutoLinkNode(nextSibling) && !endsWithSeparator(text)) { replaceWithChildren(nextSibling); handleLinkEdit(nextSibling, matchers, onChange); onChange(null, nextSibling.getURL()); } } function replaceWithChildren(node: ElementNode): Array<LexicalNode> { const children = node.getChildren(); const childrenLength = children.length; for (let j = childrenLength - 1; j >= 0; j--) { node.insertAfter(children[j]); } node.remove(); return children.map((child) => child.getLatest()); } function useAutoLink( editor: LexicalEditor, matchers: Array<LinkMatcher>, onChange?: ChangeHandler, ): void { useEffect(() => { if (!editor.hasNodes([AutoLinkNode])) { invariant( false, 'LexicalAutoLinkPlugin: AutoLinkNode not registered on editor', ); } const onChangeWrapped = (url: string | null, prevUrl: string | null) => { if (onChange) { onChange(url, prevUrl); } }; return mergeRegister( editor.registerNodeTransform(TextNode, (textNode: TextNode) => { const parent = textNode.getParentOrThrow(); const previous = textNode.getPreviousSibling(); if ($isAutoLinkNode(parent)) { handleLinkEdit(parent, matchers, onChangeWrapped); } else if (!$isLinkNode(parent)) { if ( textNode.isSimpleText() && (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous)) ) { handleLinkCreation(textNode, matchers, onChangeWrapped); } handleBadNeighbors(textNode, matchers, onChangeWrapped); } }), ); }, [editor, matchers, onChange]); } export function AutoLinkPlugin({ matchers, onChange, }: { matchers: Array<LinkMatcher>; onChange?: ChangeHandler; }): JSX.Element | null { const [editor] = useLexicalComposerContext(); useAutoLink(editor, matchers, onChange); return null; }