]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/link/index.ts
fe2b9757048b0d5c866cda4c73bc207269dc27b3
[bookstack] / resources / js / wysiwyg / lexical / link / index.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {
10   BaseSelection,
11   DOMConversionMap,
12   DOMConversionOutput,
13   EditorConfig,
14   LexicalCommand,
15   LexicalNode,
16   NodeKey,
17   RangeSelection,
18   SerializedElementNode,
19 } from 'lexical';
20
21 import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
22 import {
23   $applyNodeReplacement,
24   $getSelection,
25   $isElementNode,
26   $isRangeSelection,
27   createCommand,
28   ElementNode,
29   Spread,
30 } from 'lexical';
31
32 export type LinkAttributes = {
33   rel?: null | string;
34   target?: null | string;
35   title?: null | string;
36 };
37
38 export type AutoLinkAttributes = Partial<
39   Spread<LinkAttributes, {isUnlinked?: boolean}>
40 >;
41
42 export type SerializedLinkNode = Spread<
43   {
44     url: string;
45   },
46   Spread<LinkAttributes, SerializedElementNode>
47 >;
48
49 type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
50
51 const SUPPORTED_URL_PROTOCOLS = new Set([
52   'http:',
53   'https:',
54   'mailto:',
55   'sms:',
56   'tel:',
57 ]);
58
59 /** @noInheritDoc */
60 export class LinkNode extends ElementNode {
61   /** @internal */
62   __url: string;
63   /** @internal */
64   __target: null | string;
65   /** @internal */
66   __rel: null | string;
67   /** @internal */
68   __title: null | string;
69
70   static getType(): string {
71     return 'link';
72   }
73
74   static clone(node: LinkNode): LinkNode {
75     return new LinkNode(
76       node.__url,
77       {rel: node.__rel, target: node.__target, title: node.__title},
78       node.__key,
79     );
80   }
81
82   constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
83     super(key);
84     const {target = null, rel = null, title = null} = attributes;
85     this.__url = url;
86     this.__target = target;
87     this.__rel = rel;
88     this.__title = title;
89   }
90
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;
96     }
97     if (this.__rel !== null) {
98       element.rel = this.__rel;
99     }
100     if (this.__title !== null) {
101       element.title = this.__title;
102     }
103     addClassNamesToElement(element, config.theme.link);
104     return element;
105   }
106
107   updateDOM(
108     prevNode: LinkNode,
109     anchor: LinkHTMLElementType,
110     config: EditorConfig,
111   ): boolean {
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) {
118         anchor.href = url;
119       }
120
121       if (target !== prevNode.__target) {
122         if (target) {
123           anchor.target = target;
124         } else {
125           anchor.removeAttribute('target');
126         }
127       }
128
129       if (rel !== prevNode.__rel) {
130         if (rel) {
131           anchor.rel = rel;
132         } else {
133           anchor.removeAttribute('rel');
134         }
135       }
136
137       if (title !== prevNode.__title) {
138         if (title) {
139           anchor.title = title;
140         } else {
141           anchor.removeAttribute('title');
142         }
143       }
144     }
145     return false;
146   }
147
148   static importDOM(): DOMConversionMap | null {
149     return {
150       a: (node: Node) => ({
151         conversion: $convertAnchorElement,
152         priority: 1,
153       }),
154     };
155   }
156
157   static importJSON(
158     serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
159   ): LinkNode {
160     const node = $createLinkNode(serializedNode.url, {
161       rel: serializedNode.rel,
162       target: serializedNode.target,
163       title: serializedNode.title,
164     });
165     node.setFormat(serializedNode.format);
166     node.setIndent(serializedNode.indent);
167     node.setDirection(serializedNode.direction);
168     return node;
169   }
170
171   sanitizeUrl(url: string): string {
172     try {
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';
177       }
178     } catch {
179       return url;
180     }
181     return url;
182   }
183
184   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
185     return {
186       ...super.exportJSON(),
187       rel: this.getRel(),
188       target: this.getTarget(),
189       title: this.getTitle(),
190       type: 'link',
191       url: this.getURL(),
192       version: 1,
193     };
194   }
195
196   getURL(): string {
197     return this.getLatest().__url;
198   }
199
200   setURL(url: string): void {
201     const writable = this.getWritable();
202     writable.__url = url;
203   }
204
205   getTarget(): null | string {
206     return this.getLatest().__target;
207   }
208
209   setTarget(target: null | string): void {
210     const writable = this.getWritable();
211     writable.__target = target;
212   }
213
214   getRel(): null | string {
215     return this.getLatest().__rel;
216   }
217
218   setRel(rel: null | string): void {
219     const writable = this.getWritable();
220     writable.__rel = rel;
221   }
222
223   getTitle(): null | string {
224     return this.getLatest().__title;
225   }
226
227   setTitle(title: null | string): void {
228     const writable = this.getWritable();
229     writable.__title = title;
230   }
231
232   insertNewAfter(
233     _: RangeSelection,
234     restoreSelection = true,
235   ): null | ElementNode {
236     const linkNode = $createLinkNode(this.__url, {
237       rel: this.__rel,
238       target: this.__target,
239       title: this.__title,
240     });
241     this.insertAfter(linkNode, restoreSelection);
242     return linkNode;
243   }
244
245   canInsertTextBefore(): false {
246     return false;
247   }
248
249   canInsertTextAfter(): false {
250     return false;
251   }
252
253   canBeEmpty(): false {
254     return false;
255   }
256
257   isInline(): true {
258     return true;
259   }
260
261   extractWithChild(
262     child: LexicalNode,
263     selection: BaseSelection,
264     destination: 'clone' | 'html',
265   ): boolean {
266     if (!$isRangeSelection(selection)) {
267       return false;
268     }
269
270     const anchorNode = selection.anchor.getNode();
271     const focusNode = selection.focus.getNode();
272
273     return (
274       this.isParentOf(anchorNode) &&
275       this.isParentOf(focusNode) &&
276       selection.getTextContent().length > 0
277     );
278   }
279
280   isEmailURI(): boolean {
281     return this.__url.startsWith('mailto:');
282   }
283
284   isWebSiteURI(): boolean {
285     return (
286       this.__url.startsWith('https://') || this.__url.startsWith('http://')
287     );
288   }
289 }
290
291 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
292   let node = null;
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'),
300       });
301     }
302   }
303   return {node};
304 }
305
306 /**
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.
311  */
312 export function $createLinkNode(
313   url: string,
314   attributes?: LinkAttributes,
315 ): LinkNode {
316   return $applyNodeReplacement(new LinkNode(url, attributes));
317 }
318
319 /**
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.
323  */
324 export function $isLinkNode(
325   node: LexicalNode | null | undefined,
326 ): node is LinkNode {
327   return node instanceof LinkNode;
328 }
329
330 export type SerializedAutoLinkNode = Spread<
331   {
332     isUnlinked: boolean;
333   },
334   SerializedLinkNode
335 >;
336
337 // Custom node type to override `canInsertTextAfter` that will
338 // allow typing within the link
339 export class AutoLinkNode extends LinkNode {
340   /** @internal */
341   /** Indicates whether the autolink was ever unlinked. **/
342   __isUnlinked: boolean;
343
344   constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
345     super(url, attributes, key);
346     this.__isUnlinked =
347       attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
348         ? attributes.isUnlinked
349         : false;
350   }
351
352   static getType(): string {
353     return 'autolink';
354   }
355
356   static clone(node: AutoLinkNode): AutoLinkNode {
357     return new AutoLinkNode(
358       node.__url,
359       {
360         isUnlinked: node.__isUnlinked,
361         rel: node.__rel,
362         target: node.__target,
363         title: node.__title,
364       },
365       node.__key,
366     );
367   }
368
369   getIsUnlinked(): boolean {
370     return this.__isUnlinked;
371   }
372
373   setIsUnlinked(value: boolean) {
374     const self = this.getWritable();
375     self.__isUnlinked = value;
376     return self;
377   }
378
379   createDOM(config: EditorConfig): LinkHTMLElementType {
380     if (this.__isUnlinked) {
381       return document.createElement('span');
382     } else {
383       return super.createDOM(config);
384     }
385   }
386
387   updateDOM(
388     prevNode: AutoLinkNode,
389     anchor: LinkHTMLElementType,
390     config: EditorConfig,
391   ): boolean {
392     return (
393       super.updateDOM(prevNode, anchor, config) ||
394       prevNode.__isUnlinked !== this.__isUnlinked
395     );
396   }
397
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,
404     });
405     node.setFormat(serializedNode.format);
406     node.setIndent(serializedNode.indent);
407     node.setDirection(serializedNode.direction);
408     return node;
409   }
410
411   static importDOM(): null {
412     // TODO: Should link node should handle the import over autolink?
413     return null;
414   }
415
416   exportJSON(): SerializedAutoLinkNode {
417     return {
418       ...super.exportJSON(),
419       isUnlinked: this.__isUnlinked,
420       type: 'autolink',
421       version: 1,
422     };
423   }
424
425   insertNewAfter(
426     selection: RangeSelection,
427     restoreSelection = true,
428   ): null | ElementNode {
429     const element = this.getParentOrThrow().insertNewAfter(
430       selection,
431       restoreSelection,
432     );
433     if ($isElementNode(element)) {
434       const linkNode = $createAutoLinkNode(this.__url, {
435         isUnlinked: this.__isUnlinked,
436         rel: this.__rel,
437         target: this.__target,
438         title: this.__title,
439       });
440       element.append(linkNode);
441       return linkNode;
442     }
443     return null;
444   }
445 }
446
447 /**
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.
453  */
454 export function $createAutoLinkNode(
455   url: string,
456   attributes?: AutoLinkAttributes,
457 ): AutoLinkNode {
458   return $applyNodeReplacement(new AutoLinkNode(url, attributes));
459 }
460
461 /**
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.
465  */
466 export function $isAutoLinkNode(
467   node: LexicalNode | null | undefined,
468 ): node is AutoLinkNode {
469   return node instanceof AutoLinkNode;
470 }
471
472 export const TOGGLE_LINK_COMMAND: LexicalCommand<
473   string | ({url: string} & LinkAttributes) | null
474 > = createCommand('TOGGLE_LINK_COMMAND');
475
476 /**
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 \\}
481  */
482 export function $toggleLink(
483   url: null | string,
484   attributes: LinkAttributes = {},
485 ): void {
486   const {target, title} = attributes;
487   const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
488   const selection = $getSelection();
489
490   if (!$isRangeSelection(selection)) {
491     return;
492   }
493   const nodes = selection.extract();
494
495   if (url === null) {
496     // Remove LinkNodes
497     nodes.forEach((node) => {
498       const parent = node.getParent();
499
500       if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
501         const children = parent.getChildren();
502
503         for (let i = 0; i < children.length; i++) {
504           parent.insertBefore(children[i]);
505         }
506
507         parent.remove();
508       }
509     });
510   } else {
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);
521         }
522         if (rel !== null) {
523           linkNode.setRel(rel);
524         }
525         if (title !== undefined) {
526           linkNode.setTitle(title);
527         }
528         return;
529       }
530     }
531
532     let prevParent: ElementNode | LinkNode | null = null;
533     let linkNode: LinkNode | null = null;
534
535     nodes.forEach((node) => {
536       const parent = node.getParent();
537
538       if (
539         parent === linkNode ||
540         parent === null ||
541         ($isElementNode(node) && !node.isInline())
542       ) {
543         return;
544       }
545
546       if ($isLinkNode(parent)) {
547         linkNode = parent;
548         parent.setURL(url);
549         if (target !== undefined) {
550           parent.setTarget(target);
551         }
552         if (rel !== null) {
553           linkNode.setRel(rel);
554         }
555         if (title !== undefined) {
556           linkNode.setTitle(title);
557         }
558         return;
559       }
560
561       if (!parent.is(prevParent)) {
562         prevParent = parent;
563         linkNode = $createLinkNode(url, {rel, target, title});
564
565         if ($isLinkNode(parent)) {
566           if (node.getPreviousSibling() === null) {
567             parent.insertBefore(linkNode);
568           } else {
569             parent.insertAfter(linkNode);
570           }
571         } else {
572           node.insertBefore(linkNode);
573         }
574       }
575
576       if ($isLinkNode(node)) {
577         if (node.is(linkNode)) {
578           return;
579         }
580         if (linkNode !== null) {
581           const children = node.getChildren();
582
583           for (let i = 0; i < children.length; i++) {
584             linkNode.append(children[i]);
585           }
586         }
587
588         node.remove();
589         return;
590       }
591
592       if (linkNode !== null) {
593         linkNode.append(node);
594       }
595     });
596   }
597 }
598 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
599 export const toggleLink = $toggleLink;
600
601 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
602   node: LexicalNode,
603   predicate: (ancestor: LexicalNode) => ancestor is NodeType,
604 ) {
605   let parent = node;
606   while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
607     parent = parent.getParentOrThrow();
608   }
609   return predicate(parent) ? parent : null;
610 }