572 lines
16 KiB
JavaScript
572 lines
16 KiB
JavaScript
/**
|
|
* 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 utils = require('@lexical/utils');
|
|
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.
|
|
*
|
|
*/
|
|
|
|
// Do not require this module directly! Use normal `invariant` calls.
|
|
|
|
function formatDevErrorMessage(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']);
|
|
|
|
/** @noInheritDoc */
|
|
class LinkNode extends lexical.ElementNode {
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
/** @internal */
|
|
|
|
static getType() {
|
|
return 'link';
|
|
}
|
|
static clone(node) {
|
|
return new LinkNode(node.__url, {
|
|
rel: node.__rel,
|
|
target: node.__target,
|
|
title: node.__title
|
|
}, node.__key);
|
|
}
|
|
constructor(url = '', attributes = {}, key) {
|
|
super(key);
|
|
const {
|
|
target = null,
|
|
rel = null,
|
|
title = null
|
|
} = attributes;
|
|
this.__url = url;
|
|
this.__target = target;
|
|
this.__rel = rel;
|
|
this.__title = title;
|
|
}
|
|
createDOM(config) {
|
|
const element = document.createElement('a');
|
|
this.updateLinkDOM(null, element, config);
|
|
utils.addClassNamesToElement(element, config.theme.link);
|
|
return element;
|
|
}
|
|
updateLinkDOM(prevNode, anchor, config) {
|
|
if (utils.isHTMLAnchorElement(anchor)) {
|
|
if (!prevNode || prevNode.__url !== this.__url) {
|
|
anchor.href = this.sanitizeUrl(this.__url);
|
|
}
|
|
for (const attr of ['target', 'rel', 'title']) {
|
|
const key = `__${attr}`;
|
|
const value = this[key];
|
|
if (!prevNode || prevNode[key] !== value) {
|
|
if (value) {
|
|
anchor[attr] = value;
|
|
} else {
|
|
anchor.removeAttribute(attr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
updateDOM(prevNode, anchor, config) {
|
|
this.updateLinkDOM(prevNode, anchor, config);
|
|
return false;
|
|
}
|
|
static importDOM() {
|
|
return {
|
|
a: node => ({
|
|
conversion: $convertAnchorElement,
|
|
priority: 1
|
|
})
|
|
};
|
|
}
|
|
static importJSON(serializedNode) {
|
|
return $createLinkNode().updateFromJSON(serializedNode);
|
|
}
|
|
updateFromJSON(serializedNode) {
|
|
return super.updateFromJSON(serializedNode).setURL(serializedNode.url).setRel(serializedNode.rel || null).setTarget(serializedNode.target || null).setTitle(serializedNode.title || null);
|
|
}
|
|
sanitizeUrl(url) {
|
|
url = formatUrl(url);
|
|
try {
|
|
const parsedUrl = new URL(formatUrl(url));
|
|
// eslint-disable-next-line no-script-url
|
|
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
|
|
return 'about:blank';
|
|
}
|
|
} catch (_unused) {
|
|
return url;
|
|
}
|
|
return url;
|
|
}
|
|
exportJSON() {
|
|
return {
|
|
...super.exportJSON(),
|
|
rel: this.getRel(),
|
|
target: this.getTarget(),
|
|
title: this.getTitle(),
|
|
url: this.getURL()
|
|
};
|
|
}
|
|
getURL() {
|
|
return this.getLatest().__url;
|
|
}
|
|
setURL(url) {
|
|
const writable = this.getWritable();
|
|
writable.__url = url;
|
|
return writable;
|
|
}
|
|
getTarget() {
|
|
return this.getLatest().__target;
|
|
}
|
|
setTarget(target) {
|
|
const writable = this.getWritable();
|
|
writable.__target = target;
|
|
return writable;
|
|
}
|
|
getRel() {
|
|
return this.getLatest().__rel;
|
|
}
|
|
setRel(rel) {
|
|
const writable = this.getWritable();
|
|
writable.__rel = rel;
|
|
return writable;
|
|
}
|
|
getTitle() {
|
|
return this.getLatest().__title;
|
|
}
|
|
setTitle(title) {
|
|
const writable = this.getWritable();
|
|
writable.__title = title;
|
|
return writable;
|
|
}
|
|
insertNewAfter(_, restoreSelection = true) {
|
|
const linkNode = $createLinkNode(this.__url, {
|
|
rel: this.__rel,
|
|
target: this.__target,
|
|
title: this.__title
|
|
});
|
|
this.insertAfter(linkNode, restoreSelection);
|
|
return linkNode;
|
|
}
|
|
canInsertTextBefore() {
|
|
return false;
|
|
}
|
|
canInsertTextAfter() {
|
|
return false;
|
|
}
|
|
canBeEmpty() {
|
|
return false;
|
|
}
|
|
isInline() {
|
|
return true;
|
|
}
|
|
extractWithChild(child, selection, destination) {
|
|
if (!lexical.$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
const anchorNode = selection.anchor.getNode();
|
|
const focusNode = selection.focus.getNode();
|
|
return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && selection.getTextContent().length > 0;
|
|
}
|
|
isEmailURI() {
|
|
return this.__url.startsWith('mailto:');
|
|
}
|
|
isWebSiteURI() {
|
|
return this.__url.startsWith('https://') || this.__url.startsWith('http://');
|
|
}
|
|
}
|
|
function $convertAnchorElement(domNode) {
|
|
let node = null;
|
|
if (utils.isHTMLAnchorElement(domNode)) {
|
|
const content = domNode.textContent;
|
|
if (content !== null && content !== '' || domNode.children.length > 0) {
|
|
node = $createLinkNode(domNode.getAttribute('href') || '', {
|
|
rel: domNode.getAttribute('rel'),
|
|
target: domNode.getAttribute('target'),
|
|
title: domNode.getAttribute('title')
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
node
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Takes a URL and creates a LinkNode.
|
|
* @param url - The URL the LinkNode should direct to.
|
|
* @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
|
|
* @returns The LinkNode.
|
|
*/
|
|
function $createLinkNode(url = '', attributes) {
|
|
return lexical.$applyNodeReplacement(new LinkNode(url, attributes));
|
|
}
|
|
|
|
/**
|
|
* Determines if node is a LinkNode.
|
|
* @param node - The node to be checked.
|
|
* @returns true if node is a LinkNode, false otherwise.
|
|
*/
|
|
function $isLinkNode(node) {
|
|
return node instanceof LinkNode;
|
|
}
|
|
// Custom node type to override `canInsertTextAfter` that will
|
|
// allow typing within the link
|
|
class AutoLinkNode extends LinkNode {
|
|
/** @internal */
|
|
/** Indicates whether the autolink was ever unlinked. **/
|
|
|
|
constructor(url = '', attributes = {}, key) {
|
|
super(url, attributes, key);
|
|
this.__isUnlinked = attributes.isUnlinked !== undefined && attributes.isUnlinked !== null ? attributes.isUnlinked : false;
|
|
}
|
|
static getType() {
|
|
return 'autolink';
|
|
}
|
|
static clone(node) {
|
|
return new AutoLinkNode(node.__url, {
|
|
isUnlinked: node.__isUnlinked,
|
|
rel: node.__rel,
|
|
target: node.__target,
|
|
title: node.__title
|
|
}, node.__key);
|
|
}
|
|
getIsUnlinked() {
|
|
return this.__isUnlinked;
|
|
}
|
|
setIsUnlinked(value) {
|
|
const self = this.getWritable();
|
|
self.__isUnlinked = value;
|
|
return self;
|
|
}
|
|
createDOM(config) {
|
|
if (this.__isUnlinked) {
|
|
return document.createElement('span');
|
|
} else {
|
|
return super.createDOM(config);
|
|
}
|
|
}
|
|
updateDOM(prevNode, anchor, config) {
|
|
return super.updateDOM(prevNode, anchor, config) || prevNode.__isUnlinked !== this.__isUnlinked;
|
|
}
|
|
static importJSON(serializedNode) {
|
|
return $createAutoLinkNode().updateFromJSON(serializedNode);
|
|
}
|
|
updateFromJSON(serializedNode) {
|
|
return super.updateFromJSON(serializedNode).setIsUnlinked(serializedNode.isUnlinked || false);
|
|
}
|
|
static importDOM() {
|
|
// TODO: Should link node should handle the import over autolink?
|
|
return null;
|
|
}
|
|
exportJSON() {
|
|
return {
|
|
...super.exportJSON(),
|
|
isUnlinked: this.__isUnlinked
|
|
};
|
|
}
|
|
insertNewAfter(selection, restoreSelection = true) {
|
|
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection);
|
|
if (lexical.$isElementNode(element)) {
|
|
const linkNode = $createAutoLinkNode(this.__url, {
|
|
isUnlinked: this.__isUnlinked,
|
|
rel: this.__rel,
|
|
target: this.__target,
|
|
title: this.__title
|
|
});
|
|
element.append(linkNode);
|
|
return linkNode;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
|
|
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
|
|
* @param url - The URL the LinkNode should direct to.
|
|
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
|
|
* @returns The LinkNode.
|
|
*/
|
|
function $createAutoLinkNode(url = '', attributes) {
|
|
return lexical.$applyNodeReplacement(new AutoLinkNode(url, attributes));
|
|
}
|
|
|
|
/**
|
|
* Determines if node is an AutoLinkNode.
|
|
* @param node - The node to be checked.
|
|
* @returns true if node is an AutoLinkNode, false otherwise.
|
|
*/
|
|
function $isAutoLinkNode(node) {
|
|
return node instanceof AutoLinkNode;
|
|
}
|
|
const TOGGLE_LINK_COMMAND = lexical.createCommand('TOGGLE_LINK_COMMAND');
|
|
function $getPointNode(point, offset) {
|
|
if (point.type === 'element') {
|
|
const node = point.getNode();
|
|
if (!lexical.$isElementNode(node)) {
|
|
formatDevErrorMessage(`$getPointNode: element point is not an ElementNode`);
|
|
}
|
|
const childNode = node.getChildren()[point.offset + offset];
|
|
return childNode || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Preserve the logical start/end of a RangeSelection in situations where
|
|
* the point is an element that may be reparented in the callback.
|
|
*
|
|
* @param $fn The function to run
|
|
* @returns The result of the callback
|
|
*/
|
|
function $withSelectedNodes($fn) {
|
|
const initialSelection = lexical.$getSelection();
|
|
if (!lexical.$isRangeSelection(initialSelection)) {
|
|
return $fn();
|
|
}
|
|
const normalized = lexical.$normalizeSelection__EXPERIMENTAL(initialSelection);
|
|
const isBackwards = normalized.isBackward();
|
|
const anchorNode = $getPointNode(normalized.anchor, isBackwards ? -1 : 0);
|
|
const focusNode = $getPointNode(normalized.focus, isBackwards ? 0 : -1);
|
|
const rval = $fn();
|
|
if (anchorNode || focusNode) {
|
|
const updatedSelection = lexical.$getSelection();
|
|
if (lexical.$isRangeSelection(updatedSelection)) {
|
|
const finalSelection = updatedSelection.clone();
|
|
if (anchorNode) {
|
|
const anchorParent = anchorNode.getParent();
|
|
if (anchorParent) {
|
|
finalSelection.anchor.set(anchorParent.getKey(), anchorNode.getIndexWithinParent() + (isBackwards ? 1 : 0), 'element');
|
|
}
|
|
}
|
|
if (focusNode) {
|
|
const focusParent = focusNode.getParent();
|
|
if (focusParent) {
|
|
finalSelection.focus.set(focusParent.getKey(), focusNode.getIndexWithinParent() + (isBackwards ? 0 : 1), 'element');
|
|
}
|
|
}
|
|
lexical.$setSelection(lexical.$normalizeSelection__EXPERIMENTAL(finalSelection));
|
|
}
|
|
}
|
|
return rval;
|
|
}
|
|
|
|
/**
|
|
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
|
|
* but saves any children and brings them up to the parent node.
|
|
* @param url - The URL the link directs to.
|
|
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
|
|
*/
|
|
function $toggleLink(url, attributes = {}) {
|
|
const {
|
|
target,
|
|
title
|
|
} = attributes;
|
|
const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
|
|
const selection = lexical.$getSelection();
|
|
if (selection === null || !lexical.$isRangeSelection(selection) && !lexical.$isNodeSelection(selection)) {
|
|
return;
|
|
}
|
|
if (lexical.$isNodeSelection(selection)) {
|
|
const nodes = selection.getNodes();
|
|
if (nodes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Handle all selected nodes
|
|
nodes.forEach(node => {
|
|
if (url === null) {
|
|
// Remove link
|
|
const linkParent = utils.$findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
|
|
if (linkParent) {
|
|
linkParent.insertBefore(node);
|
|
if (linkParent.getChildren().length === 0) {
|
|
linkParent.remove();
|
|
}
|
|
}
|
|
} else {
|
|
// Add/Update link
|
|
const existingLink = utils.$findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
|
|
if (existingLink) {
|
|
existingLink.setURL(url);
|
|
if (target !== undefined) {
|
|
existingLink.setTarget(target);
|
|
}
|
|
if (rel !== undefined) {
|
|
existingLink.setRel(rel);
|
|
}
|
|
} else {
|
|
const linkNode = $createLinkNode(url, {
|
|
rel,
|
|
target
|
|
});
|
|
node.insertBefore(linkNode);
|
|
linkNode.append(node);
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Handle RangeSelection
|
|
const nodes = selection.extract();
|
|
if (url === null) {
|
|
// Remove LinkNodes
|
|
nodes.forEach(node => {
|
|
const parentLink = utils.$findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
|
|
if (parentLink) {
|
|
const children = parentLink.getChildren();
|
|
for (let i = 0; i < children.length; i++) {
|
|
parentLink.insertBefore(children[i]);
|
|
}
|
|
parentLink.remove();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const updatedNodes = new Set();
|
|
const updateLinkNode = linkNode => {
|
|
if (updatedNodes.has(linkNode.getKey())) {
|
|
return;
|
|
}
|
|
updatedNodes.add(linkNode.getKey());
|
|
linkNode.setURL(url);
|
|
if (target !== undefined) {
|
|
linkNode.setTarget(target);
|
|
}
|
|
if (rel !== undefined) {
|
|
linkNode.setRel(rel);
|
|
}
|
|
if (title !== undefined) {
|
|
linkNode.setTitle(title);
|
|
}
|
|
};
|
|
// Add or merge LinkNodes
|
|
if (nodes.length === 1) {
|
|
const firstNode = nodes[0];
|
|
// if the first node is a LinkNode or if its
|
|
// parent is a LinkNode, we update the URL, target and rel.
|
|
const linkNode = $getAncestor(firstNode, $isLinkNode);
|
|
if (linkNode !== null) {
|
|
return updateLinkNode(linkNode);
|
|
}
|
|
}
|
|
$withSelectedNodes(() => {
|
|
let linkNode = null;
|
|
for (const node of nodes) {
|
|
if (!node.isAttached()) {
|
|
continue;
|
|
}
|
|
const parentLinkNode = $getAncestor(node, $isLinkNode);
|
|
if (parentLinkNode) {
|
|
updateLinkNode(parentLinkNode);
|
|
continue;
|
|
}
|
|
if (lexical.$isElementNode(node)) {
|
|
if (!node.isInline()) {
|
|
// Ignore block nodes, if there are any children we will see them
|
|
// later and wrap in a new LinkNode
|
|
continue;
|
|
}
|
|
if ($isLinkNode(node)) {
|
|
// If it's not an autolink node and we don't already have a LinkNode
|
|
// in this block then we can update it and re-use it
|
|
if (!$isAutoLinkNode(node) && (linkNode === null || !linkNode.getParentOrThrow().isParentOf(node))) {
|
|
updateLinkNode(node);
|
|
linkNode = node;
|
|
continue;
|
|
}
|
|
// Unwrap LinkNode, we already have one or it's an AutoLinkNode
|
|
for (const child of node.getChildren()) {
|
|
node.insertBefore(child);
|
|
}
|
|
node.remove();
|
|
continue;
|
|
}
|
|
}
|
|
const prevLinkNode = node.getPreviousSibling();
|
|
if ($isLinkNode(prevLinkNode) && prevLinkNode.is(linkNode)) {
|
|
prevLinkNode.append(node);
|
|
continue;
|
|
}
|
|
linkNode = $createLinkNode(url, {
|
|
rel,
|
|
target,
|
|
title
|
|
});
|
|
node.insertAfter(linkNode);
|
|
linkNode.append(node);
|
|
}
|
|
});
|
|
}
|
|
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
|
|
const toggleLink = $toggleLink;
|
|
function $getAncestor(node, predicate) {
|
|
let parent = node;
|
|
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
|
|
parent = parent.getParentOrThrow();
|
|
}
|
|
return predicate(parent) ? parent : null;
|
|
}
|
|
const PHONE_NUMBER_REGEX = /^\+?[0-9\s()-]{5,}$/;
|
|
|
|
/**
|
|
* Formats a URL string by adding appropriate protocol if missing
|
|
*
|
|
* @param url - URL to format
|
|
* @returns Formatted URL with appropriate protocol
|
|
*/
|
|
function formatUrl(url) {
|
|
// Check if URL already has a protocol
|
|
if (url.match(/^[a-z][a-z0-9+.-]*:/i)) {
|
|
// URL already has a protocol, leave it as is
|
|
return url;
|
|
}
|
|
// Check if it's a relative path (starting with '/', '.', or '#')
|
|
else if (url.match(/^[/#.]/)) {
|
|
// Relative path, leave it as is
|
|
return url;
|
|
}
|
|
|
|
// Check for email address
|
|
else if (url.includes('@')) {
|
|
return `mailto:${url}`;
|
|
}
|
|
|
|
// Check for phone number
|
|
else if (PHONE_NUMBER_REGEX.test(url)) {
|
|
return `tel:${url}`;
|
|
}
|
|
|
|
// For everything else, return with https:// prefix
|
|
return `https://${url}`;
|
|
}
|
|
|
|
exports.$createAutoLinkNode = $createAutoLinkNode;
|
|
exports.$createLinkNode = $createLinkNode;
|
|
exports.$isAutoLinkNode = $isAutoLinkNode;
|
|
exports.$isLinkNode = $isLinkNode;
|
|
exports.$toggleLink = $toggleLink;
|
|
exports.AutoLinkNode = AutoLinkNode;
|
|
exports.LinkNode = LinkNode;
|
|
exports.TOGGLE_LINK_COMMAND = TOGGLE_LINK_COMMAND;
|
|
exports.formatUrl = formatUrl;
|
|
exports.toggleLink = toggleLink;
|