@terrible-lexical/react
Version:
This package provides Lexical components and hooks for React applications.
315 lines (275 loc) • 9.77 kB
text/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 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;
}