607 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			607 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			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 {
 | |
|   BaseSelection,
 | |
|   DOMConversionMap,
 | |
|   DOMConversionOutput,
 | |
|   EditorConfig,
 | |
|   LexicalCommand,
 | |
|   LexicalNode,
 | |
|   NodeKey,
 | |
|   RangeSelection,
 | |
|   SerializedElementNode,
 | |
| } from 'lexical';
 | |
| 
 | |
| import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
 | |
| import {
 | |
|   $applyNodeReplacement,
 | |
|   $getSelection,
 | |
|   $isElementNode,
 | |
|   $isRangeSelection,
 | |
|   createCommand,
 | |
|   ElementNode,
 | |
|   Spread,
 | |
| } from 'lexical';
 | |
| 
 | |
| export type LinkAttributes = {
 | |
|   rel?: null | string;
 | |
|   target?: null | string;
 | |
|   title?: null | string;
 | |
| };
 | |
| 
 | |
| export type AutoLinkAttributes = Partial<
 | |
|   Spread<LinkAttributes, {isUnlinked?: boolean}>
 | |
| >;
 | |
| 
 | |
| export type SerializedLinkNode = Spread<
 | |
|   {
 | |
|     url: string;
 | |
|   },
 | |
|   Spread<LinkAttributes, SerializedElementNode>
 | |
| >;
 | |
| 
 | |
| type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
 | |
| 
 | |
| const SUPPORTED_URL_PROTOCOLS = new Set([
 | |
|   'http:',
 | |
|   'https:',
 | |
|   'mailto:',
 | |
|   'sms:',
 | |
|   'tel:',
 | |
| ]);
 | |
| 
 | |
| /** @noInheritDoc */
 | |
| export class LinkNode extends ElementNode {
 | |
|   /** @internal */
 | |
|   __url: string;
 | |
|   /** @internal */
 | |
|   __target: null | string;
 | |
|   /** @internal */
 | |
|   __rel: null | string;
 | |
|   /** @internal */
 | |
|   __title: null | string;
 | |
| 
 | |
|   static getType(): string {
 | |
|     return 'link';
 | |
|   }
 | |
| 
 | |
|   static clone(node: LinkNode): LinkNode {
 | |
|     return new LinkNode(
 | |
|       node.__url,
 | |
|       {rel: node.__rel, target: node.__target, title: node.__title},
 | |
|       node.__key,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
 | |
|     super(key);
 | |
|     const {target = null, rel = null, title = null} = attributes;
 | |
|     this.__url = url;
 | |
|     this.__target = target;
 | |
|     this.__rel = rel;
 | |
|     this.__title = title;
 | |
|   }
 | |
| 
 | |
|   createDOM(config: EditorConfig): LinkHTMLElementType {
 | |
|     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;
 | |
|     }
 | |
|     addClassNamesToElement(element, config.theme.link);
 | |
|     return element;
 | |
|   }
 | |
| 
 | |
|   updateDOM(
 | |
|     prevNode: LinkNode,
 | |
|     anchor: LinkHTMLElementType,
 | |
|     config: EditorConfig,
 | |
|   ): boolean {
 | |
|     if (anchor instanceof HTMLAnchorElement) {
 | |
|       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(): DOMConversionMap | null {
 | |
|     return {
 | |
|       a: (node: Node) => ({
 | |
|         conversion: $convertAnchorElement,
 | |
|         priority: 1,
 | |
|       }),
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   static importJSON(
 | |
|     serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
 | |
|   ): LinkNode {
 | |
|     const node = $createLinkNode(serializedNode.url, {
 | |
|       rel: serializedNode.rel,
 | |
|       target: serializedNode.target,
 | |
|       title: serializedNode.title,
 | |
|     });
 | |
|     node.setDirection(serializedNode.direction);
 | |
|     return node;
 | |
|   }
 | |
| 
 | |
|   sanitizeUrl(url: string): string {
 | |
|     try {
 | |
|       const parsedUrl = new URL(url);
 | |
|       // eslint-disable-next-line no-script-url
 | |
|       if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
 | |
|         return 'about:blank';
 | |
|       }
 | |
|     } catch {
 | |
|       return url;
 | |
|     }
 | |
|     return url;
 | |
|   }
 | |
| 
 | |
|   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
 | |
|     return {
 | |
|       ...super.exportJSON(),
 | |
|       rel: this.getRel(),
 | |
|       target: this.getTarget(),
 | |
|       title: this.getTitle(),
 | |
|       type: 'link',
 | |
|       url: this.getURL(),
 | |
|       version: 1,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   getURL(): string {
 | |
|     return this.getLatest().__url;
 | |
|   }
 | |
| 
 | |
|   setURL(url: string): void {
 | |
|     const writable = this.getWritable();
 | |
|     writable.__url = url;
 | |
|   }
 | |
| 
 | |
|   getTarget(): null | string {
 | |
|     return this.getLatest().__target;
 | |
|   }
 | |
| 
 | |
|   setTarget(target: null | string): void {
 | |
|     const writable = this.getWritable();
 | |
|     writable.__target = target;
 | |
|   }
 | |
| 
 | |
|   getRel(): null | string {
 | |
|     return this.getLatest().__rel;
 | |
|   }
 | |
| 
 | |
|   setRel(rel: null | string): void {
 | |
|     const writable = this.getWritable();
 | |
|     writable.__rel = rel;
 | |
|   }
 | |
| 
 | |
|   getTitle(): null | string {
 | |
|     return this.getLatest().__title;
 | |
|   }
 | |
| 
 | |
|   setTitle(title: null | string): void {
 | |
|     const writable = this.getWritable();
 | |
|     writable.__title = title;
 | |
|   }
 | |
| 
 | |
|   insertNewAfter(
 | |
|     _: RangeSelection,
 | |
|     restoreSelection = true,
 | |
|   ): null | ElementNode {
 | |
|     const linkNode = $createLinkNode(this.__url, {
 | |
|       rel: this.__rel,
 | |
|       target: this.__target,
 | |
|       title: this.__title,
 | |
|     });
 | |
|     this.insertAfter(linkNode, restoreSelection);
 | |
|     return linkNode;
 | |
|   }
 | |
| 
 | |
|   canInsertTextBefore(): false {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   canInsertTextAfter(): false {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   canBeEmpty(): false {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   isInline(): true {
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   extractWithChild(
 | |
|     child: LexicalNode,
 | |
|     selection: BaseSelection,
 | |
|     destination: 'clone' | 'html',
 | |
|   ): boolean {
 | |
|     if (!$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(): boolean {
 | |
|     return this.__url.startsWith('mailto:');
 | |
|   }
 | |
| 
 | |
|   isWebSiteURI(): boolean {
 | |
|     return (
 | |
|       this.__url.startsWith('https://') || this.__url.startsWith('http://')
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| function $convertAnchorElement(domNode: Node): DOMConversionOutput {
 | |
|   let node = null;
 | |
|   if (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.
 | |
|  */
 | |
| export function $createLinkNode(
 | |
|   url: string,
 | |
|   attributes?: LinkAttributes,
 | |
| ): LinkNode {
 | |
|   return $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.
 | |
|  */
 | |
| export function $isLinkNode(
 | |
|   node: LexicalNode | null | undefined,
 | |
| ): node is LinkNode {
 | |
|   return node instanceof LinkNode;
 | |
| }
 | |
| 
 | |
| export type SerializedAutoLinkNode = Spread<
 | |
|   {
 | |
|     isUnlinked: boolean;
 | |
|   },
 | |
|   SerializedLinkNode
 | |
| >;
 | |
| 
 | |
| // Custom node type to override `canInsertTextAfter` that will
 | |
| // allow typing within the link
 | |
| export class AutoLinkNode extends LinkNode {
 | |
|   /** @internal */
 | |
|   /** Indicates whether the autolink was ever unlinked. **/
 | |
|   __isUnlinked: boolean;
 | |
| 
 | |
|   constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
 | |
|     super(url, attributes, key);
 | |
|     this.__isUnlinked =
 | |
|       attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
 | |
|         ? attributes.isUnlinked
 | |
|         : false;
 | |
|   }
 | |
| 
 | |
|   static getType(): string {
 | |
|     return 'autolink';
 | |
|   }
 | |
| 
 | |
|   static clone(node: AutoLinkNode): AutoLinkNode {
 | |
|     return new AutoLinkNode(
 | |
|       node.__url,
 | |
|       {
 | |
|         isUnlinked: node.__isUnlinked,
 | |
|         rel: node.__rel,
 | |
|         target: node.__target,
 | |
|         title: node.__title,
 | |
|       },
 | |
|       node.__key,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   getIsUnlinked(): boolean {
 | |
|     return this.__isUnlinked;
 | |
|   }
 | |
| 
 | |
|   setIsUnlinked(value: boolean) {
 | |
|     const self = this.getWritable();
 | |
|     self.__isUnlinked = value;
 | |
|     return self;
 | |
|   }
 | |
| 
 | |
|   createDOM(config: EditorConfig): LinkHTMLElementType {
 | |
|     if (this.__isUnlinked) {
 | |
|       return document.createElement('span');
 | |
|     } else {
 | |
|       return super.createDOM(config);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   updateDOM(
 | |
|     prevNode: AutoLinkNode,
 | |
|     anchor: LinkHTMLElementType,
 | |
|     config: EditorConfig,
 | |
|   ): boolean {
 | |
|     return (
 | |
|       super.updateDOM(prevNode, anchor, config) ||
 | |
|       prevNode.__isUnlinked !== this.__isUnlinked
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
 | |
|     const node = $createAutoLinkNode(serializedNode.url, {
 | |
|       isUnlinked: serializedNode.isUnlinked,
 | |
|       rel: serializedNode.rel,
 | |
|       target: serializedNode.target,
 | |
|       title: serializedNode.title,
 | |
|     });
 | |
|     node.setDirection(serializedNode.direction);
 | |
|     return node;
 | |
|   }
 | |
| 
 | |
|   static importDOM(): null {
 | |
|     // TODO: Should link node should handle the import over autolink?
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   exportJSON(): SerializedAutoLinkNode {
 | |
|     return {
 | |
|       ...super.exportJSON(),
 | |
|       isUnlinked: this.__isUnlinked,
 | |
|       type: 'autolink',
 | |
|       version: 1,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   insertNewAfter(
 | |
|     selection: RangeSelection,
 | |
|     restoreSelection = true,
 | |
|   ): null | ElementNode {
 | |
|     const element = this.getParentOrThrow().insertNewAfter(
 | |
|       selection,
 | |
|       restoreSelection,
 | |
|     );
 | |
|     if ($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.
 | |
|  */
 | |
| export function $createAutoLinkNode(
 | |
|   url: string,
 | |
|   attributes?: AutoLinkAttributes,
 | |
| ): AutoLinkNode {
 | |
|   return $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.
 | |
|  */
 | |
| export function $isAutoLinkNode(
 | |
|   node: LexicalNode | null | undefined,
 | |
| ): node is AutoLinkNode {
 | |
|   return node instanceof AutoLinkNode;
 | |
| }
 | |
| 
 | |
| export const TOGGLE_LINK_COMMAND: LexicalCommand<
 | |
|   string | ({url: string} & LinkAttributes) | null
 | |
| > = 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 \\}
 | |
|  */
 | |
| export function $toggleLink(
 | |
|   url: null | string,
 | |
|   attributes: LinkAttributes = {},
 | |
| ): void {
 | |
|   const {target, title} = attributes;
 | |
|   const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
 | |
|   const selection = $getSelection();
 | |
| 
 | |
|   if (!$isRangeSelection(selection)) {
 | |
|     return;
 | |
|   }
 | |
|   const nodes = selection.extract();
 | |
| 
 | |
|   if (url === null) {
 | |
|     // Remove LinkNodes
 | |
|     nodes.forEach((node) => {
 | |
|       const parent = node.getParent();
 | |
| 
 | |
|       if (!$isAutoLinkNode(parent) && $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: ElementNode | LinkNode | null = null;
 | |
|     let linkNode: LinkNode | null = null;
 | |
| 
 | |
|     nodes.forEach((node) => {
 | |
|       const parent = node.getParent();
 | |
| 
 | |
|       if (
 | |
|         parent === linkNode ||
 | |
|         parent === null ||
 | |
|         ($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);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| }
 | |
| /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
 | |
| export const toggleLink = $toggleLink;
 | |
| 
 | |
| function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
 | |
|   node: LexicalNode,
 | |
|   predicate: (ancestor: LexicalNode) => ancestor is NodeType,
 | |
| ) {
 | |
|   let parent = node;
 | |
|   while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
 | |
|     parent = parent.getParentOrThrow();
 | |
|   }
 | |
|   return predicate(parent) ? parent : null;
 | |
| }
 |