2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
18 SerializedElementNode,
21 import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
23 $applyNodeReplacement,
32 export type LinkAttributes = {
34 target?: null | string;
35 title?: null | string;
38 export type AutoLinkAttributes = Partial<
39 Spread<LinkAttributes, {isUnlinked?: boolean}>
42 export type SerializedLinkNode = Spread<
46 Spread<LinkAttributes, SerializedElementNode>
49 type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
51 const SUPPORTED_URL_PROTOCOLS = new Set([
60 export class LinkNode extends ElementNode {
64 __target: null | string;
68 __title: null | string;
70 static getType(): string {
74 static clone(node: LinkNode): LinkNode {
77 {rel: node.__rel, target: node.__target, title: node.__title},
82 constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
84 const {target = null, rel = null, title = null} = attributes;
86 this.__target = target;
91 createDOM(config: EditorConfig): LinkHTMLElementType {
92 const element = document.createElement('a');
93 element.href = this.sanitizeUrl(this.__url);
94 if (this.__target !== null) {
95 element.target = this.__target;
97 if (this.__rel !== null) {
98 element.rel = this.__rel;
100 if (this.__title !== null) {
101 element.title = this.__title;
103 addClassNamesToElement(element, config.theme.link);
109 anchor: LinkHTMLElementType,
110 config: EditorConfig,
112 if (anchor instanceof HTMLAnchorElement) {
113 const url = this.__url;
114 const target = this.__target;
115 const rel = this.__rel;
116 const title = this.__title;
117 if (url !== prevNode.__url) {
121 if (target !== prevNode.__target) {
123 anchor.target = target;
125 anchor.removeAttribute('target');
129 if (rel !== prevNode.__rel) {
133 anchor.removeAttribute('rel');
137 if (title !== prevNode.__title) {
139 anchor.title = title;
141 anchor.removeAttribute('title');
148 static importDOM(): DOMConversionMap | null {
150 a: (node: Node) => ({
151 conversion: $convertAnchorElement,
158 serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
160 const node = $createLinkNode(serializedNode.url, {
161 rel: serializedNode.rel,
162 target: serializedNode.target,
163 title: serializedNode.title,
165 node.setFormat(serializedNode.format);
166 node.setIndent(serializedNode.indent);
167 node.setDirection(serializedNode.direction);
171 sanitizeUrl(url: string): string {
173 const parsedUrl = new URL(url);
174 // eslint-disable-next-line no-script-url
175 if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
176 return 'about:blank';
184 exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
186 ...super.exportJSON(),
188 target: this.getTarget(),
189 title: this.getTitle(),
197 return this.getLatest().__url;
200 setURL(url: string): void {
201 const writable = this.getWritable();
202 writable.__url = url;
205 getTarget(): null | string {
206 return this.getLatest().__target;
209 setTarget(target: null | string): void {
210 const writable = this.getWritable();
211 writable.__target = target;
214 getRel(): null | string {
215 return this.getLatest().__rel;
218 setRel(rel: null | string): void {
219 const writable = this.getWritable();
220 writable.__rel = rel;
223 getTitle(): null | string {
224 return this.getLatest().__title;
227 setTitle(title: null | string): void {
228 const writable = this.getWritable();
229 writable.__title = title;
234 restoreSelection = true,
235 ): null | ElementNode {
236 const linkNode = $createLinkNode(this.__url, {
238 target: this.__target,
241 this.insertAfter(linkNode, restoreSelection);
245 canInsertTextBefore(): false {
249 canInsertTextAfter(): false {
253 canBeEmpty(): false {
263 selection: BaseSelection,
264 destination: 'clone' | 'html',
266 if (!$isRangeSelection(selection)) {
270 const anchorNode = selection.anchor.getNode();
271 const focusNode = selection.focus.getNode();
274 this.isParentOf(anchorNode) &&
275 this.isParentOf(focusNode) &&
276 selection.getTextContent().length > 0
280 isEmailURI(): boolean {
281 return this.__url.startsWith('mailto:');
284 isWebSiteURI(): boolean {
286 this.__url.startsWith('https://') || this.__url.startsWith('http://')
291 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
293 if (isHTMLAnchorElement(domNode)) {
294 const content = domNode.textContent;
295 if ((content !== null && content !== '') || domNode.children.length > 0) {
296 node = $createLinkNode(domNode.getAttribute('href') || '', {
297 rel: domNode.getAttribute('rel'),
298 target: domNode.getAttribute('target'),
299 title: domNode.getAttribute('title'),
307 * Takes a URL and creates a LinkNode.
308 * @param url - The URL the LinkNode should direct to.
309 * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
310 * @returns The LinkNode.
312 export function $createLinkNode(
314 attributes?: LinkAttributes,
316 return $applyNodeReplacement(new LinkNode(url, attributes));
320 * Determines if node is a LinkNode.
321 * @param node - The node to be checked.
322 * @returns true if node is a LinkNode, false otherwise.
324 export function $isLinkNode(
325 node: LexicalNode | null | undefined,
326 ): node is LinkNode {
327 return node instanceof LinkNode;
330 export type SerializedAutoLinkNode = Spread<
337 // Custom node type to override `canInsertTextAfter` that will
338 // allow typing within the link
339 export class AutoLinkNode extends LinkNode {
341 /** Indicates whether the autolink was ever unlinked. **/
342 __isUnlinked: boolean;
344 constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
345 super(url, attributes, key);
347 attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
348 ? attributes.isUnlinked
352 static getType(): string {
356 static clone(node: AutoLinkNode): AutoLinkNode {
357 return new AutoLinkNode(
360 isUnlinked: node.__isUnlinked,
362 target: node.__target,
369 getIsUnlinked(): boolean {
370 return this.__isUnlinked;
373 setIsUnlinked(value: boolean) {
374 const self = this.getWritable();
375 self.__isUnlinked = value;
379 createDOM(config: EditorConfig): LinkHTMLElementType {
380 if (this.__isUnlinked) {
381 return document.createElement('span');
383 return super.createDOM(config);
388 prevNode: AutoLinkNode,
389 anchor: LinkHTMLElementType,
390 config: EditorConfig,
393 super.updateDOM(prevNode, anchor, config) ||
394 prevNode.__isUnlinked !== this.__isUnlinked
398 static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
399 const node = $createAutoLinkNode(serializedNode.url, {
400 isUnlinked: serializedNode.isUnlinked,
401 rel: serializedNode.rel,
402 target: serializedNode.target,
403 title: serializedNode.title,
405 node.setFormat(serializedNode.format);
406 node.setIndent(serializedNode.indent);
407 node.setDirection(serializedNode.direction);
411 static importDOM(): null {
412 // TODO: Should link node should handle the import over autolink?
416 exportJSON(): SerializedAutoLinkNode {
418 ...super.exportJSON(),
419 isUnlinked: this.__isUnlinked,
426 selection: RangeSelection,
427 restoreSelection = true,
428 ): null | ElementNode {
429 const element = this.getParentOrThrow().insertNewAfter(
433 if ($isElementNode(element)) {
434 const linkNode = $createAutoLinkNode(this.__url, {
435 isUnlinked: this.__isUnlinked,
437 target: this.__target,
440 element.append(linkNode);
448 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
449 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
450 * @param url - The URL the LinkNode should direct to.
451 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
452 * @returns The LinkNode.
454 export function $createAutoLinkNode(
456 attributes?: AutoLinkAttributes,
458 return $applyNodeReplacement(new AutoLinkNode(url, attributes));
462 * Determines if node is an AutoLinkNode.
463 * @param node - The node to be checked.
464 * @returns true if node is an AutoLinkNode, false otherwise.
466 export function $isAutoLinkNode(
467 node: LexicalNode | null | undefined,
468 ): node is AutoLinkNode {
469 return node instanceof AutoLinkNode;
472 export const TOGGLE_LINK_COMMAND: LexicalCommand<
473 string | ({url: string} & LinkAttributes) | null
474 > = createCommand('TOGGLE_LINK_COMMAND');
477 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
478 * but saves any children and brings them up to the parent node.
479 * @param url - The URL the link directs to.
480 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
482 export function $toggleLink(
484 attributes: LinkAttributes = {},
486 const {target, title} = attributes;
487 const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
488 const selection = $getSelection();
490 if (!$isRangeSelection(selection)) {
493 const nodes = selection.extract();
497 nodes.forEach((node) => {
498 const parent = node.getParent();
500 if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
501 const children = parent.getChildren();
503 for (let i = 0; i < children.length; i++) {
504 parent.insertBefore(children[i]);
511 // Add or merge LinkNodes
512 if (nodes.length === 1) {
513 const firstNode = nodes[0];
514 // if the first node is a LinkNode or if its
515 // parent is a LinkNode, we update the URL, target and rel.
516 const linkNode = $getAncestor(firstNode, $isLinkNode);
517 if (linkNode !== null) {
518 linkNode.setURL(url);
519 if (target !== undefined) {
520 linkNode.setTarget(target);
523 linkNode.setRel(rel);
525 if (title !== undefined) {
526 linkNode.setTitle(title);
532 let prevParent: ElementNode | LinkNode | null = null;
533 let linkNode: LinkNode | null = null;
535 nodes.forEach((node) => {
536 const parent = node.getParent();
539 parent === linkNode ||
541 ($isElementNode(node) && !node.isInline())
546 if ($isLinkNode(parent)) {
549 if (target !== undefined) {
550 parent.setTarget(target);
553 linkNode.setRel(rel);
555 if (title !== undefined) {
556 linkNode.setTitle(title);
561 if (!parent.is(prevParent)) {
563 linkNode = $createLinkNode(url, {rel, target, title});
565 if ($isLinkNode(parent)) {
566 if (node.getPreviousSibling() === null) {
567 parent.insertBefore(linkNode);
569 parent.insertAfter(linkNode);
572 node.insertBefore(linkNode);
576 if ($isLinkNode(node)) {
577 if (node.is(linkNode)) {
580 if (linkNode !== null) {
581 const children = node.getChildren();
583 for (let i = 0; i < children.length; i++) {
584 linkNode.append(children[i]);
592 if (linkNode !== null) {
593 linkNode.append(node);
598 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
599 export const toggleLink = $toggleLink;
601 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
603 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
606 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
607 parent = parent.getParentOrThrow();
609 return predicate(parent) ? parent : null;