/** * 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'); /** @module @lexical/link */ 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'); element.href = this.sanitizeUrl(this.__url); if (this.__target !== null) { element.target = this.__target; } if (this.__rel !== null) { element.rel = this.__rel; } if (this.__title !== null) { element.title = this.__title; } utils.addClassNamesToElement(element, config.theme.link); return element; } updateDOM(prevNode, anchor, config) { const url = this.__url; const target = this.__target; const rel = this.__rel; const title = this.__title; if (url !== prevNode.__url) { anchor.href = url; } if (target !== prevNode.__target) { if (target) { anchor.target = target; } else { anchor.removeAttribute('target'); } } if (rel !== prevNode.__rel) { if (rel) { anchor.rel = rel; } else { anchor.removeAttribute('rel'); } } if (title !== prevNode.__title) { if (title) { anchor.title = title; } else { anchor.removeAttribute('title'); } } return false; } static importDOM() { return { a: node => ({ conversion: convertAnchorElement, priority: 1 }) }; } static importJSON(serializedNode) { const node = $createLinkNode(serializedNode.url, { rel: serializedNode.rel, target: serializedNode.target, title: serializedNode.title }); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } sanitizeUrl(url) { try { const parsedUrl = new URL(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(), type: 'link', url: this.getURL(), version: 1 }; } getURL() { return this.getLatest().__url; } setURL(url) { const writable = this.getWritable(); writable.__url = url; } getTarget() { return this.getLatest().__target; } setTarget(target) { const writable = this.getWritable(); writable.__target = target; } getRel() { return this.getLatest().__rel; } setRel(rel) { const writable = this.getWritable(); writable.__rel = rel; } getTitle() { return this.getLatest().__title; } setTitle(title) { const writable = this.getWritable(); writable.__title = title; } 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; } } 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 { static getType() { return 'autolink'; } static clone(node) { return new AutoLinkNode(node.__url, { rel: node.__rel, target: node.__target, title: node.__title }, node.__key); } static importJSON(serializedNode) { const node = $createAutoLinkNode(serializedNode.url, { rel: serializedNode.rel, target: serializedNode.target, title: serializedNode.title }); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } static importDOM() { // TODO: Should link node should handle the import over autolink? return null; } exportJSON() { return { ...super.exportJSON(), type: 'autolink', version: 1 }; } insertNewAfter(selection, restoreSelection = true) { const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection); if (lexical.$isElementNode(element)) { const linkNode = $createAutoLinkNode(this.__url, { 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'); /** * 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 (!lexical.$isRangeSelection(selection)) { return; } const nodes = selection.extract(); if (url === null) { // Remove LinkNodes nodes.forEach(node => { const parent = node.getParent(); if ($isLinkNode(parent)) { const children = parent.getChildren(); for (let i = 0; i < children.length; i++) { parent.insertBefore(children[i]); } parent.remove(); } }); } else { // 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) { linkNode.setURL(url); if (target !== undefined) { linkNode.setTarget(target); } if (rel !== null) { linkNode.setRel(rel); } if (title !== undefined) { linkNode.setTitle(title); } return; } } let prevParent = null; let linkNode = null; nodes.forEach(node => { const parent = node.getParent(); if (parent === linkNode || parent === null || lexical.$isElementNode(node) && !node.isInline()) { return; } if ($isLinkNode(parent)) { linkNode = parent; parent.setURL(url); if (target !== undefined) { parent.setTarget(target); } if (rel !== null) { linkNode.setRel(rel); } if (title !== undefined) { linkNode.setTitle(title); } return; } if (!parent.is(prevParent)) { prevParent = parent; linkNode = $createLinkNode(url, { rel, target, title }); if ($isLinkNode(parent)) { if (node.getPreviousSibling() === null) { parent.insertBefore(linkNode); } else { parent.insertAfter(linkNode); } } else { node.insertBefore(linkNode); } } if ($isLinkNode(node)) { if (node.is(linkNode)) { return; } if (linkNode !== null) { const children = node.getChildren(); for (let i = 0; i < children.length; i++) { linkNode.append(children[i]); } } node.remove(); return; } if (linkNode !== null) { linkNode.append(node); } }); } } function $getAncestor(node, predicate) { let parent = node; while (parent !== null && parent.getParent() !== null && !predicate(parent)) { parent = parent.getParentOrThrow(); } return predicate(parent) ? parent : null; } exports.$createAutoLinkNode = $createAutoLinkNode; exports.$createLinkNode = $createLinkNode; exports.$isAutoLinkNode = $isAutoLinkNode; exports.$isLinkNode = $isLinkNode; exports.AutoLinkNode = AutoLinkNode; exports.LinkNode = LinkNode; exports.TOGGLE_LINK_COMMAND = TOGGLE_LINK_COMMAND; exports.toggleLink = toggleLink;