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.setDirection(serializedNode.direction);
169 sanitizeUrl(url: string): string {
171 const parsedUrl = new URL(url);
172 // eslint-disable-next-line no-script-url
173 if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
174 return 'about:blank';
182 exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
184 ...super.exportJSON(),
186 target: this.getTarget(),
187 title: this.getTitle(),
195 return this.getLatest().__url;
198 setURL(url: string): void {
199 const writable = this.getWritable();
200 writable.__url = url;
203 getTarget(): null | string {
204 return this.getLatest().__target;
207 setTarget(target: null | string): void {
208 const writable = this.getWritable();
209 writable.__target = target;
212 getRel(): null | string {
213 return this.getLatest().__rel;
216 setRel(rel: null | string): void {
217 const writable = this.getWritable();
218 writable.__rel = rel;
221 getTitle(): null | string {
222 return this.getLatest().__title;
225 setTitle(title: null | string): void {
226 const writable = this.getWritable();
227 writable.__title = title;
232 restoreSelection = true,
233 ): null | ElementNode {
234 const linkNode = $createLinkNode(this.__url, {
236 target: this.__target,
239 this.insertAfter(linkNode, restoreSelection);
243 canInsertTextBefore(): false {
247 canInsertTextAfter(): false {
251 canBeEmpty(): false {
261 selection: BaseSelection,
262 destination: 'clone' | 'html',
264 if (!$isRangeSelection(selection)) {
268 const anchorNode = selection.anchor.getNode();
269 const focusNode = selection.focus.getNode();
272 this.isParentOf(anchorNode) &&
273 this.isParentOf(focusNode) &&
274 selection.getTextContent().length > 0
278 isEmailURI(): boolean {
279 return this.__url.startsWith('mailto:');
282 isWebSiteURI(): boolean {
284 this.__url.startsWith('https://') || this.__url.startsWith('http://')
289 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
291 if (isHTMLAnchorElement(domNode)) {
292 const content = domNode.textContent;
293 if ((content !== null && content !== '') || domNode.children.length > 0) {
294 node = $createLinkNode(domNode.getAttribute('href') || '', {
295 rel: domNode.getAttribute('rel'),
296 target: domNode.getAttribute('target'),
297 title: domNode.getAttribute('title'),
305 * Takes a URL and creates a LinkNode.
306 * @param url - The URL the LinkNode should direct to.
307 * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
308 * @returns The LinkNode.
310 export function $createLinkNode(
312 attributes?: LinkAttributes,
314 return $applyNodeReplacement(new LinkNode(url, attributes));
318 * Determines if node is a LinkNode.
319 * @param node - The node to be checked.
320 * @returns true if node is a LinkNode, false otherwise.
322 export function $isLinkNode(
323 node: LexicalNode | null | undefined,
324 ): node is LinkNode {
325 return node instanceof LinkNode;
328 export type SerializedAutoLinkNode = Spread<
335 // Custom node type to override `canInsertTextAfter` that will
336 // allow typing within the link
337 export class AutoLinkNode extends LinkNode {
339 /** Indicates whether the autolink was ever unlinked. **/
340 __isUnlinked: boolean;
342 constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
343 super(url, attributes, key);
345 attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
346 ? attributes.isUnlinked
350 static getType(): string {
354 static clone(node: AutoLinkNode): AutoLinkNode {
355 return new AutoLinkNode(
358 isUnlinked: node.__isUnlinked,
360 target: node.__target,
367 getIsUnlinked(): boolean {
368 return this.__isUnlinked;
371 setIsUnlinked(value: boolean) {
372 const self = this.getWritable();
373 self.__isUnlinked = value;
377 createDOM(config: EditorConfig): LinkHTMLElementType {
378 if (this.__isUnlinked) {
379 return document.createElement('span');
381 return super.createDOM(config);
386 prevNode: AutoLinkNode,
387 anchor: LinkHTMLElementType,
388 config: EditorConfig,
391 super.updateDOM(prevNode, anchor, config) ||
392 prevNode.__isUnlinked !== this.__isUnlinked
396 static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
397 const node = $createAutoLinkNode(serializedNode.url, {
398 isUnlinked: serializedNode.isUnlinked,
399 rel: serializedNode.rel,
400 target: serializedNode.target,
401 title: serializedNode.title,
403 node.setDirection(serializedNode.direction);
407 static importDOM(): null {
408 // TODO: Should link node should handle the import over autolink?
412 exportJSON(): SerializedAutoLinkNode {
414 ...super.exportJSON(),
415 isUnlinked: this.__isUnlinked,
422 selection: RangeSelection,
423 restoreSelection = true,
424 ): null | ElementNode {
425 const element = this.getParentOrThrow().insertNewAfter(
429 if ($isElementNode(element)) {
430 const linkNode = $createAutoLinkNode(this.__url, {
431 isUnlinked: this.__isUnlinked,
433 target: this.__target,
436 element.append(linkNode);
444 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
445 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
446 * @param url - The URL the LinkNode should direct to.
447 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
448 * @returns The LinkNode.
450 export function $createAutoLinkNode(
452 attributes?: AutoLinkAttributes,
454 return $applyNodeReplacement(new AutoLinkNode(url, attributes));
458 * Determines if node is an AutoLinkNode.
459 * @param node - The node to be checked.
460 * @returns true if node is an AutoLinkNode, false otherwise.
462 export function $isAutoLinkNode(
463 node: LexicalNode | null | undefined,
464 ): node is AutoLinkNode {
465 return node instanceof AutoLinkNode;
468 export const TOGGLE_LINK_COMMAND: LexicalCommand<
469 string | ({url: string} & LinkAttributes) | null
470 > = createCommand('TOGGLE_LINK_COMMAND');
473 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
474 * but saves any children and brings them up to the parent node.
475 * @param url - The URL the link directs to.
476 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
478 export function $toggleLink(
480 attributes: LinkAttributes = {},
482 const {target, title} = attributes;
483 const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
484 const selection = $getSelection();
486 if (!$isRangeSelection(selection)) {
489 const nodes = selection.extract();
493 nodes.forEach((node) => {
494 const parent = node.getParent();
496 if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
497 const children = parent.getChildren();
499 for (let i = 0; i < children.length; i++) {
500 parent.insertBefore(children[i]);
507 // Add or merge LinkNodes
508 if (nodes.length === 1) {
509 const firstNode = nodes[0];
510 // if the first node is a LinkNode or if its
511 // parent is a LinkNode, we update the URL, target and rel.
512 const linkNode = $getAncestor(firstNode, $isLinkNode);
513 if (linkNode !== null) {
514 linkNode.setURL(url);
515 if (target !== undefined) {
516 linkNode.setTarget(target);
519 linkNode.setRel(rel);
521 if (title !== undefined) {
522 linkNode.setTitle(title);
528 let prevParent: ElementNode | LinkNode | null = null;
529 let linkNode: LinkNode | null = null;
531 nodes.forEach((node) => {
532 const parent = node.getParent();
535 parent === linkNode ||
537 ($isElementNode(node) && !node.isInline())
542 if ($isLinkNode(parent)) {
545 if (target !== undefined) {
546 parent.setTarget(target);
549 linkNode.setRel(rel);
551 if (title !== undefined) {
552 linkNode.setTitle(title);
557 if (!parent.is(prevParent)) {
559 linkNode = $createLinkNode(url, {rel, target, title});
561 if ($isLinkNode(parent)) {
562 if (node.getPreviousSibling() === null) {
563 parent.insertBefore(linkNode);
565 parent.insertAfter(linkNode);
568 node.insertBefore(linkNode);
572 if ($isLinkNode(node)) {
573 if (node.is(linkNode)) {
576 if (linkNode !== null) {
577 const children = node.getChildren();
579 for (let i = 0; i < children.length; i++) {
580 linkNode.append(children[i]);
588 if (linkNode !== null) {
589 linkNode.append(node);
594 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
595 export const toggleLink = $toggleLink;
597 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
599 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
602 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
603 parent = parent.getParentOrThrow();
605 return predicate(parent) ? parent : null;