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;
52 export class LinkNode extends ElementNode {
56 __target: null | string;
60 __title: null | string;
62 static getType(): string {
66 static clone(node: LinkNode): LinkNode {
69 {rel: node.__rel, target: node.__target, title: node.__title},
74 constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
76 const {target = null, rel = null, title = null} = attributes;
78 this.__target = target;
83 createDOM(config: EditorConfig): LinkHTMLElementType {
84 const element = document.createElement('a');
85 element.href = this.__url;
86 if (this.__target !== null) {
87 element.target = this.__target;
89 if (this.__rel !== null) {
90 element.rel = this.__rel;
92 if (this.__title !== null) {
93 element.title = this.__title;
95 addClassNamesToElement(element, config.theme.link);
101 anchor: LinkHTMLElementType,
102 config: EditorConfig,
104 if (anchor instanceof HTMLAnchorElement) {
105 const url = this.__url;
106 const target = this.__target;
107 const rel = this.__rel;
108 const title = this.__title;
109 if (url !== prevNode.__url) {
113 if (target !== prevNode.__target) {
115 anchor.target = target;
117 anchor.removeAttribute('target');
121 if (rel !== prevNode.__rel) {
125 anchor.removeAttribute('rel');
129 if (title !== prevNode.__title) {
131 anchor.title = title;
133 anchor.removeAttribute('title');
140 static importDOM(): DOMConversionMap | null {
142 a: (node: Node) => ({
143 conversion: $convertAnchorElement,
150 serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
152 const node = $createLinkNode(serializedNode.url, {
153 rel: serializedNode.rel,
154 target: serializedNode.target,
155 title: serializedNode.title,
157 node.setDirection(serializedNode.direction);
161 exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
163 ...super.exportJSON(),
165 target: this.getTarget(),
166 title: this.getTitle(),
174 return this.getLatest().__url;
177 setURL(url: string): void {
178 const writable = this.getWritable();
179 writable.__url = url;
182 getTarget(): null | string {
183 return this.getLatest().__target;
186 setTarget(target: null | string): void {
187 const writable = this.getWritable();
188 writable.__target = target;
191 getRel(): null | string {
192 return this.getLatest().__rel;
195 setRel(rel: null | string): void {
196 const writable = this.getWritable();
197 writable.__rel = rel;
200 getTitle(): null | string {
201 return this.getLatest().__title;
204 setTitle(title: null | string): void {
205 const writable = this.getWritable();
206 writable.__title = title;
211 restoreSelection = true,
212 ): null | ElementNode {
213 const linkNode = $createLinkNode(this.__url, {
215 target: this.__target,
218 this.insertAfter(linkNode, restoreSelection);
222 canInsertTextBefore(): false {
226 canInsertTextAfter(): false {
230 canBeEmpty(): false {
240 selection: BaseSelection,
241 destination: 'clone' | 'html',
243 if (!$isRangeSelection(selection)) {
247 const anchorNode = selection.anchor.getNode();
248 const focusNode = selection.focus.getNode();
251 this.isParentOf(anchorNode) &&
252 this.isParentOf(focusNode) &&
253 selection.getTextContent().length > 0
257 isEmailURI(): boolean {
258 return this.__url.startsWith('mailto:');
261 isWebSiteURI(): boolean {
263 this.__url.startsWith('https://') || this.__url.startsWith('http://')
268 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
270 if (isHTMLAnchorElement(domNode)) {
271 const content = domNode.textContent;
272 if ((content !== null && content !== '') || domNode.children.length > 0) {
273 node = $createLinkNode(domNode.getAttribute('href') || '', {
274 rel: domNode.getAttribute('rel'),
275 target: domNode.getAttribute('target'),
276 title: domNode.getAttribute('title'),
284 * Takes a URL and creates a LinkNode.
285 * @param url - The URL the LinkNode should direct to.
286 * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
287 * @returns The LinkNode.
289 export function $createLinkNode(
291 attributes?: LinkAttributes,
293 return $applyNodeReplacement(new LinkNode(url, attributes));
297 * Determines if node is a LinkNode.
298 * @param node - The node to be checked.
299 * @returns true if node is a LinkNode, false otherwise.
301 export function $isLinkNode(
302 node: LexicalNode | null | undefined,
303 ): node is LinkNode {
304 return node instanceof LinkNode;
307 export type SerializedAutoLinkNode = Spread<
314 // Custom node type to override `canInsertTextAfter` that will
315 // allow typing within the link
316 export class AutoLinkNode extends LinkNode {
318 /** Indicates whether the autolink was ever unlinked. **/
319 __isUnlinked: boolean;
321 constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
322 super(url, attributes, key);
324 attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
325 ? attributes.isUnlinked
329 static getType(): string {
333 static clone(node: AutoLinkNode): AutoLinkNode {
334 return new AutoLinkNode(
337 isUnlinked: node.__isUnlinked,
339 target: node.__target,
346 getIsUnlinked(): boolean {
347 return this.__isUnlinked;
350 setIsUnlinked(value: boolean) {
351 const self = this.getWritable();
352 self.__isUnlinked = value;
356 createDOM(config: EditorConfig): LinkHTMLElementType {
357 if (this.__isUnlinked) {
358 return document.createElement('span');
360 return super.createDOM(config);
365 prevNode: AutoLinkNode,
366 anchor: LinkHTMLElementType,
367 config: EditorConfig,
370 super.updateDOM(prevNode, anchor, config) ||
371 prevNode.__isUnlinked !== this.__isUnlinked
375 static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
376 const node = $createAutoLinkNode(serializedNode.url, {
377 isUnlinked: serializedNode.isUnlinked,
378 rel: serializedNode.rel,
379 target: serializedNode.target,
380 title: serializedNode.title,
382 node.setDirection(serializedNode.direction);
386 static importDOM(): null {
387 // TODO: Should link node should handle the import over autolink?
391 exportJSON(): SerializedAutoLinkNode {
393 ...super.exportJSON(),
394 isUnlinked: this.__isUnlinked,
401 selection: RangeSelection,
402 restoreSelection = true,
403 ): null | ElementNode {
404 const element = this.getParentOrThrow().insertNewAfter(
408 if ($isElementNode(element)) {
409 const linkNode = $createAutoLinkNode(this.__url, {
410 isUnlinked: this.__isUnlinked,
412 target: this.__target,
415 element.append(linkNode);
423 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
424 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
425 * @param url - The URL the LinkNode should direct to.
426 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
427 * @returns The LinkNode.
429 export function $createAutoLinkNode(
431 attributes?: AutoLinkAttributes,
433 return $applyNodeReplacement(new AutoLinkNode(url, attributes));
437 * Determines if node is an AutoLinkNode.
438 * @param node - The node to be checked.
439 * @returns true if node is an AutoLinkNode, false otherwise.
441 export function $isAutoLinkNode(
442 node: LexicalNode | null | undefined,
443 ): node is AutoLinkNode {
444 return node instanceof AutoLinkNode;
447 export const TOGGLE_LINK_COMMAND: LexicalCommand<
448 string | ({url: string} & LinkAttributes) | null
449 > = createCommand('TOGGLE_LINK_COMMAND');
452 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
453 * but saves any children and brings them up to the parent node.
454 * @param url - The URL the link directs to.
455 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
457 export function $toggleLink(
459 attributes: LinkAttributes = {},
461 const {target, title} = attributes;
462 const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
463 const selection = $getSelection();
465 if (!$isRangeSelection(selection)) {
468 const nodes = selection.extract();
472 nodes.forEach((node) => {
473 const parent = node.getParent();
475 if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
476 const children = parent.getChildren();
478 for (let i = 0; i < children.length; i++) {
479 parent.insertBefore(children[i]);
486 // Add or merge LinkNodes
487 if (nodes.length === 1) {
488 const firstNode = nodes[0];
489 // if the first node is a LinkNode or if its
490 // parent is a LinkNode, we update the URL, target and rel.
491 const linkNode = $getAncestor(firstNode, $isLinkNode);
492 if (linkNode !== null) {
493 linkNode.setURL(url);
494 if (target !== undefined) {
495 linkNode.setTarget(target);
498 linkNode.setRel(rel);
500 if (title !== undefined) {
501 linkNode.setTitle(title);
507 let prevParent: ElementNode | LinkNode | null = null;
508 let linkNode: LinkNode | null = null;
510 nodes.forEach((node) => {
511 const parent = node.getParent();
514 parent === linkNode ||
516 ($isElementNode(node) && !node.isInline())
521 if ($isLinkNode(parent)) {
524 if (target !== undefined) {
525 parent.setTarget(target);
528 linkNode.setRel(rel);
530 if (title !== undefined) {
531 linkNode.setTitle(title);
536 if (!parent.is(prevParent)) {
538 linkNode = $createLinkNode(url, {rel, target, title});
540 if ($isLinkNode(parent)) {
541 if (node.getPreviousSibling() === null) {
542 parent.insertBefore(linkNode);
544 parent.insertAfter(linkNode);
547 node.insertBefore(linkNode);
551 if ($isLinkNode(node)) {
552 if (node.is(linkNode)) {
555 if (linkNode !== null) {
556 const children = node.getChildren();
558 for (let i = 0; i < children.length; i++) {
559 linkNode.append(children[i]);
567 if (linkNode !== null) {
568 linkNode.append(node);
573 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
574 export const toggleLink = $toggleLink;
576 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
578 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
581 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
582 parent = parent.getParentOrThrow();
584 return predicate(parent) ? parent : null;