Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
354 lines
13 KiB
Plaintext
354 lines
13 KiB
Plaintext
/**
|
|
* 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 { AutoLinkNode, $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND, $createAutoLinkNode } from '@lexical/link';
|
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
import { mergeRegister } from '@lexical/utils';
|
|
import { TextNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, $isTextNode, $isElementNode, $isLineBreakNode, $createTextNode, $isNodeSelection } from 'lexical';
|
|
import { useEffect } from 'react';
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
function createLinkMatcherWithRegExp(regExp, urlTransformer = text => text) {
|
|
return text => {
|
|
const match = regExp.exec(text);
|
|
if (match === null) {
|
|
return null;
|
|
}
|
|
return {
|
|
index: match.index,
|
|
length: match[0].length,
|
|
text: match[0],
|
|
url: urlTransformer(match[0])
|
|
};
|
|
};
|
|
}
|
|
function findFirstMatch(text, matchers) {
|
|
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) {
|
|
return PUNCTUATION_OR_SPACE.test(char);
|
|
}
|
|
function endsWithSeparator(textContent) {
|
|
return isSeparator(textContent[textContent.length - 1]);
|
|
}
|
|
function startsWithSeparator(textContent) {
|
|
return isSeparator(textContent[0]);
|
|
}
|
|
|
|
/**
|
|
* Check if the text content starts with a fullstop followed by a top-level domain.
|
|
* Meaning if the text content can be a beginning of a top level domain.
|
|
* @param textContent
|
|
* @param isEmail
|
|
* @returns boolean
|
|
*/
|
|
function startsWithTLD(textContent, isEmail) {
|
|
if (isEmail) {
|
|
return /^\.[a-zA-Z]{2,}/.test(textContent);
|
|
} else {
|
|
return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
|
|
}
|
|
}
|
|
function isPreviousNodeValid(node) {
|
|
let previousNode = node.getPreviousSibling();
|
|
if ($isElementNode(previousNode)) {
|
|
previousNode = previousNode.getLastDescendant();
|
|
}
|
|
return previousNode === null || $isLineBreakNode(previousNode) || $isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent());
|
|
}
|
|
function isNextNodeValid(node) {
|
|
let nextNode = node.getNextSibling();
|
|
if ($isElementNode(nextNode)) {
|
|
nextNode = nextNode.getFirstDescendant();
|
|
}
|
|
return nextNode === null || $isLineBreakNode(nextNode) || $isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent());
|
|
}
|
|
function isContentAroundIsValid(matchStart, matchEnd, text, nodes) {
|
|
const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0]);
|
|
if (!contentBeforeIsValid) {
|
|
return false;
|
|
}
|
|
const contentAfterIsValid = matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1]);
|
|
return contentAfterIsValid;
|
|
}
|
|
function extractMatchingNodes(nodes, startIndex, endIndex) {
|
|
const unmodifiedBeforeNodes = [];
|
|
const matchingNodes = [];
|
|
const unmodifiedAfterNodes = [];
|
|
let matchingOffset = 0;
|
|
let currentOffset = 0;
|
|
const currentNodes = [...nodes];
|
|
while (currentNodes.length > 0) {
|
|
const currentNode = currentNodes[0];
|
|
const currentNodeText = currentNode.getTextContent();
|
|
const currentNodeLength = currentNodeText.length;
|
|
const currentNodeStart = currentOffset;
|
|
const currentNodeEnd = currentOffset + currentNodeLength;
|
|
if (currentNodeEnd <= startIndex) {
|
|
unmodifiedBeforeNodes.push(currentNode);
|
|
matchingOffset += currentNodeLength;
|
|
} else if (currentNodeStart >= endIndex) {
|
|
unmodifiedAfterNodes.push(currentNode);
|
|
} else {
|
|
matchingNodes.push(currentNode);
|
|
}
|
|
currentOffset += currentNodeLength;
|
|
currentNodes.shift();
|
|
}
|
|
return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes];
|
|
}
|
|
function $createAutoLinkNode_(nodes, startIndex, endIndex, match) {
|
|
const linkNode = $createAutoLinkNode(match.url, match.attributes);
|
|
if (nodes.length === 1) {
|
|
let remainingTextNode = nodes[0];
|
|
let linkTextNode;
|
|
if (startIndex === 0) {
|
|
[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex);
|
|
} else {
|
|
[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex);
|
|
}
|
|
const textNode = $createTextNode(match.text);
|
|
textNode.setFormat(linkTextNode.getFormat());
|
|
textNode.setDetail(linkTextNode.getDetail());
|
|
textNode.setStyle(linkTextNode.getStyle());
|
|
linkNode.append(textNode);
|
|
linkTextNode.replace(linkNode);
|
|
return remainingTextNode;
|
|
} else if (nodes.length > 1) {
|
|
const firstTextNode = nodes[0];
|
|
let offset = firstTextNode.getTextContent().length;
|
|
let firstLinkTextNode;
|
|
if (startIndex === 0) {
|
|
firstLinkTextNode = firstTextNode;
|
|
} else {
|
|
[, firstLinkTextNode] = firstTextNode.splitText(startIndex);
|
|
}
|
|
const linkNodes = [];
|
|
let remainingTextNode;
|
|
for (let i = 1; i < nodes.length; i++) {
|
|
const currentNode = nodes[i];
|
|
const currentNodeText = currentNode.getTextContent();
|
|
const currentNodeLength = currentNodeText.length;
|
|
const currentNodeStart = offset;
|
|
const currentNodeEnd = offset + currentNodeLength;
|
|
if (currentNodeStart < endIndex) {
|
|
if (currentNodeEnd <= endIndex) {
|
|
linkNodes.push(currentNode);
|
|
} else {
|
|
const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart);
|
|
linkNodes.push(linkTextNode);
|
|
remainingTextNode = endNode;
|
|
}
|
|
}
|
|
offset += currentNodeLength;
|
|
}
|
|
const selection = $getSelection();
|
|
const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined;
|
|
const textNode = $createTextNode(firstLinkTextNode.getTextContent());
|
|
textNode.setFormat(firstLinkTextNode.getFormat());
|
|
textNode.setDetail(firstLinkTextNode.getDetail());
|
|
textNode.setStyle(firstLinkTextNode.getStyle());
|
|
linkNode.append(textNode, ...linkNodes);
|
|
// it does not preserve caret position if caret was at the first text node
|
|
// so we need to restore caret position
|
|
if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
|
|
if ($isRangeSelection(selection)) {
|
|
textNode.select(selection.anchor.offset, selection.focus.offset);
|
|
} else if ($isNodeSelection(selection)) {
|
|
textNode.select(0, textNode.getTextContent().length);
|
|
}
|
|
}
|
|
firstLinkTextNode.replace(linkNode);
|
|
return remainingTextNode;
|
|
}
|
|
return undefined;
|
|
}
|
|
function $handleLinkCreation(nodes, matchers, onChange) {
|
|
let currentNodes = [...nodes];
|
|
const initialText = currentNodes.map(node => node.getTextContent()).join('');
|
|
let text = initialText;
|
|
let match;
|
|
let invalidMatchEnd = 0;
|
|
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, initialText, currentNodes);
|
|
if (isValid) {
|
|
const [matchingOffset,, matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes(currentNodes, invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd);
|
|
const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset;
|
|
const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset;
|
|
const remainingTextNode = $createAutoLinkNode_(matchingNodes, actualMatchStart, actualMatchEnd, match);
|
|
currentNodes = remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes;
|
|
onChange(match.url, null);
|
|
invalidMatchEnd = 0;
|
|
} else {
|
|
invalidMatchEnd += matchEnd;
|
|
}
|
|
text = text.substring(matchEnd);
|
|
}
|
|
}
|
|
function handleLinkEdit(linkNode, matchers, onChange) {
|
|
// 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 neighbors are edits in neighbor nodes that make AutoLinks incompatible.
|
|
// Given the creation preconditions, these can only be simple text nodes.
|
|
function handleBadNeighbors(textNode, matchers, onChange) {
|
|
const previousSibling = textNode.getPreviousSibling();
|
|
const nextSibling = textNode.getNextSibling();
|
|
const text = textNode.getTextContent();
|
|
if ($isAutoLinkNode(previousSibling) && !previousSibling.getIsUnlinked() && (!startsWithSeparator(text) || startsWithTLD(text, previousSibling.isEmailURI()))) {
|
|
previousSibling.append(textNode);
|
|
handleLinkEdit(previousSibling, matchers, onChange);
|
|
onChange(null, previousSibling.getURL());
|
|
}
|
|
if ($isAutoLinkNode(nextSibling) && !nextSibling.getIsUnlinked() && !endsWithSeparator(text)) {
|
|
replaceWithChildren(nextSibling);
|
|
handleLinkEdit(nextSibling, matchers, onChange);
|
|
onChange(null, nextSibling.getURL());
|
|
}
|
|
}
|
|
function replaceWithChildren(node) {
|
|
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 getTextNodesToMatch(textNode) {
|
|
// check if next siblings are simple text nodes till a node contains a space separator
|
|
const textNodesToMatch = [textNode];
|
|
let nextSibling = textNode.getNextSibling();
|
|
while (nextSibling !== null && $isTextNode(nextSibling) && nextSibling.isSimpleText()) {
|
|
textNodesToMatch.push(nextSibling);
|
|
if (/[\s]/.test(nextSibling.getTextContent())) {
|
|
break;
|
|
}
|
|
nextSibling = nextSibling.getNextSibling();
|
|
}
|
|
return textNodesToMatch;
|
|
}
|
|
function useAutoLink(editor, matchers, onChange) {
|
|
useEffect(() => {
|
|
if (!editor.hasNodes([AutoLinkNode])) {
|
|
{
|
|
formatDevErrorMessage(`LexicalAutoLinkPlugin: AutoLinkNode not registered on editor`);
|
|
}
|
|
}
|
|
const onChangeWrapped = (url, prevUrl) => {
|
|
if (onChange) {
|
|
onChange(url, prevUrl);
|
|
}
|
|
};
|
|
return mergeRegister(editor.registerNodeTransform(TextNode, textNode => {
|
|
const parent = textNode.getParentOrThrow();
|
|
const previous = textNode.getPreviousSibling();
|
|
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
|
|
handleLinkEdit(parent, matchers, onChangeWrapped);
|
|
} else if (!$isLinkNode(parent)) {
|
|
if (textNode.isSimpleText() && (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))) {
|
|
const textNodesToMatch = getTextNodesToMatch(textNode);
|
|
$handleLinkCreation(textNodesToMatch, matchers, onChangeWrapped);
|
|
}
|
|
handleBadNeighbors(textNode, matchers, onChangeWrapped);
|
|
}
|
|
}), editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
|
|
const selection = $getSelection();
|
|
if (payload !== null || !$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
const nodes = selection.extract();
|
|
nodes.forEach(node => {
|
|
const parent = node.getParent();
|
|
if ($isAutoLinkNode(parent)) {
|
|
// invert the value
|
|
parent.setIsUnlinked(!parent.getIsUnlinked());
|
|
parent.markDirty();
|
|
}
|
|
});
|
|
return false;
|
|
}, COMMAND_PRIORITY_LOW));
|
|
}, [editor, matchers, onChange]);
|
|
}
|
|
function AutoLinkPlugin({
|
|
matchers,
|
|
onChange
|
|
}) {
|
|
const [editor] = useLexicalComposerContext();
|
|
useAutoLink(editor, matchers, onChange);
|
|
return null;
|
|
}
|
|
|
|
export { AutoLinkPlugin, createLinkMatcherWithRegExp };
|