]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/link/index.ts
Lexical: Merged custom paragraph node, removed old format/indent refs
[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.setDirection(serializedNode.direction);
166     return node;
167   }
168
169   sanitizeUrl(url: string): string {
170     try {
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';
175       }
176     } catch {
177       return url;
178     }
179     return url;
180   }
181
182   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
183     return {
184       ...super.exportJSON(),
185       rel: this.getRel(),
186       target: this.getTarget(),
187       title: this.getTitle(),
188       type: 'link',
189       url: this.getURL(),
190       version: 1,
191     };
192   }
193
194   getURL(): string {
195     return this.getLatest().__url;
196   }
197
198   setURL(url: string): void {
199     const writable = this.getWritable();
200     writable.__url = url;
201   }
202
203   getTarget(): null | string {
204     return this.getLatest().__target;
205   }
206
207   setTarget(target: null | string): void {
208     const writable = this.getWritable();
209     writable.__target = target;
210   }
211
212   getRel(): null | string {
213     return this.getLatest().__rel;
214   }
215
216   setRel(rel: null | string): void {
217     const writable = this.getWritable();
218     writable.__rel = rel;
219   }
220
221   getTitle(): null | string {
222     return this.getLatest().__title;
223   }
224
225   setTitle(title: null | string): void {
226     const writable = this.getWritable();
227     writable.__title = title;
228   }
229
230   insertNewAfter(
231     _: RangeSelection,
232     restoreSelection = true,
233   ): null | ElementNode {
234     const linkNode = $createLinkNode(this.__url, {
235       rel: this.__rel,
236       target: this.__target,
237       title: this.__title,
238     });
239     this.insertAfter(linkNode, restoreSelection);
240     return linkNode;
241   }
242
243   canInsertTextBefore(): false {
244     return false;
245   }
246
247   canInsertTextAfter(): false {
248     return false;
249   }
250
251   canBeEmpty(): false {
252     return false;
253   }
254
255   isInline(): true {
256     return true;
257   }
258
259   extractWithChild(
260     child: LexicalNode,
261     selection: BaseSelection,
262     destination: 'clone' | 'html',
263   ): boolean {
264     if (!$isRangeSelection(selection)) {
265       return false;
266     }
267
268     const anchorNode = selection.anchor.getNode();
269     const focusNode = selection.focus.getNode();
270
271     return (
272       this.isParentOf(anchorNode) &&
273       this.isParentOf(focusNode) &&
274       selection.getTextContent().length > 0
275     );
276   }
277
278   isEmailURI(): boolean {
279     return this.__url.startsWith('mailto:');
280   }
281
282   isWebSiteURI(): boolean {
283     return (
284       this.__url.startsWith('https://') || this.__url.startsWith('http://')
285     );
286   }
287 }
288
289 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
290   let node = null;
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'),
298       });
299     }
300   }
301   return {node};
302 }
303
304 /**
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.
309  */
310 export function $createLinkNode(
311   url: string,
312   attributes?: LinkAttributes,
313 ): LinkNode {
314   return $applyNodeReplacement(new LinkNode(url, attributes));
315 }
316
317 /**
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.
321  */
322 export function $isLinkNode(
323   node: LexicalNode | null | undefined,
324 ): node is LinkNode {
325   return node instanceof LinkNode;
326 }
327
328 export type SerializedAutoLinkNode = Spread<
329   {
330     isUnlinked: boolean;
331   },
332   SerializedLinkNode
333 >;
334
335 // Custom node type to override `canInsertTextAfter` that will
336 // allow typing within the link
337 export class AutoLinkNode extends LinkNode {
338   /** @internal */
339   /** Indicates whether the autolink was ever unlinked. **/
340   __isUnlinked: boolean;
341
342   constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
343     super(url, attributes, key);
344     this.__isUnlinked =
345       attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
346         ? attributes.isUnlinked
347         : false;
348   }
349
350   static getType(): string {
351     return 'autolink';
352   }
353
354   static clone(node: AutoLinkNode): AutoLinkNode {
355     return new AutoLinkNode(
356       node.__url,
357       {
358         isUnlinked: node.__isUnlinked,
359         rel: node.__rel,
360         target: node.__target,
361         title: node.__title,
362       },
363       node.__key,
364     );
365   }
366
367   getIsUnlinked(): boolean {
368     return this.__isUnlinked;
369   }
370
371   setIsUnlinked(value: boolean) {
372     const self = this.getWritable();
373     self.__isUnlinked = value;
374     return self;
375   }
376
377   createDOM(config: EditorConfig): LinkHTMLElementType {
378     if (this.__isUnlinked) {
379       return document.createElement('span');
380     } else {
381       return super.createDOM(config);
382     }
383   }
384
385   updateDOM(
386     prevNode: AutoLinkNode,
387     anchor: LinkHTMLElementType,
388     config: EditorConfig,
389   ): boolean {
390     return (
391       super.updateDOM(prevNode, anchor, config) ||
392       prevNode.__isUnlinked !== this.__isUnlinked
393     );
394   }
395
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,
402     });
403     node.setDirection(serializedNode.direction);
404     return node;
405   }
406
407   static importDOM(): null {
408     // TODO: Should link node should handle the import over autolink?
409     return null;
410   }
411
412   exportJSON(): SerializedAutoLinkNode {
413     return {
414       ...super.exportJSON(),
415       isUnlinked: this.__isUnlinked,
416       type: 'autolink',
417       version: 1,
418     };
419   }
420
421   insertNewAfter(
422     selection: RangeSelection,
423     restoreSelection = true,
424   ): null | ElementNode {
425     const element = this.getParentOrThrow().insertNewAfter(
426       selection,
427       restoreSelection,
428     );
429     if ($isElementNode(element)) {
430       const linkNode = $createAutoLinkNode(this.__url, {
431         isUnlinked: this.__isUnlinked,
432         rel: this.__rel,
433         target: this.__target,
434         title: this.__title,
435       });
436       element.append(linkNode);
437       return linkNode;
438     }
439     return null;
440   }
441 }
442
443 /**
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.
449  */
450 export function $createAutoLinkNode(
451   url: string,
452   attributes?: AutoLinkAttributes,
453 ): AutoLinkNode {
454   return $applyNodeReplacement(new AutoLinkNode(url, attributes));
455 }
456
457 /**
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.
461  */
462 export function $isAutoLinkNode(
463   node: LexicalNode | null | undefined,
464 ): node is AutoLinkNode {
465   return node instanceof AutoLinkNode;
466 }
467
468 export const TOGGLE_LINK_COMMAND: LexicalCommand<
469   string | ({url: string} & LinkAttributes) | null
470 > = createCommand('TOGGLE_LINK_COMMAND');
471
472 /**
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 \\}
477  */
478 export function $toggleLink(
479   url: null | string,
480   attributes: LinkAttributes = {},
481 ): void {
482   const {target, title} = attributes;
483   const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
484   const selection = $getSelection();
485
486   if (!$isRangeSelection(selection)) {
487     return;
488   }
489   const nodes = selection.extract();
490
491   if (url === null) {
492     // Remove LinkNodes
493     nodes.forEach((node) => {
494       const parent = node.getParent();
495
496       if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
497         const children = parent.getChildren();
498
499         for (let i = 0; i < children.length; i++) {
500           parent.insertBefore(children[i]);
501         }
502
503         parent.remove();
504       }
505     });
506   } else {
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);
517         }
518         if (rel !== null) {
519           linkNode.setRel(rel);
520         }
521         if (title !== undefined) {
522           linkNode.setTitle(title);
523         }
524         return;
525       }
526     }
527
528     let prevParent: ElementNode | LinkNode | null = null;
529     let linkNode: LinkNode | null = null;
530
531     nodes.forEach((node) => {
532       const parent = node.getParent();
533
534       if (
535         parent === linkNode ||
536         parent === null ||
537         ($isElementNode(node) && !node.isInline())
538       ) {
539         return;
540       }
541
542       if ($isLinkNode(parent)) {
543         linkNode = parent;
544         parent.setURL(url);
545         if (target !== undefined) {
546           parent.setTarget(target);
547         }
548         if (rel !== null) {
549           linkNode.setRel(rel);
550         }
551         if (title !== undefined) {
552           linkNode.setTitle(title);
553         }
554         return;
555       }
556
557       if (!parent.is(prevParent)) {
558         prevParent = parent;
559         linkNode = $createLinkNode(url, {rel, target, title});
560
561         if ($isLinkNode(parent)) {
562           if (node.getPreviousSibling() === null) {
563             parent.insertBefore(linkNode);
564           } else {
565             parent.insertAfter(linkNode);
566           }
567         } else {
568           node.insertBefore(linkNode);
569         }
570       }
571
572       if ($isLinkNode(node)) {
573         if (node.is(linkNode)) {
574           return;
575         }
576         if (linkNode !== null) {
577           const children = node.getChildren();
578
579           for (let i = 0; i < children.length; i++) {
580             linkNode.append(children[i]);
581           }
582         }
583
584         node.remove();
585         return;
586       }
587
588       if (linkNode !== null) {
589         linkNode.append(node);
590       }
591     });
592   }
593 }
594 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
595 export const toggleLink = $toggleLink;
596
597 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
598   node: LexicalNode,
599   predicate: (ancestor: LexicalNode) => ancestor is NodeType,
600 ) {
601   let parent = node;
602   while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
603     parent = parent.getParentOrThrow();
604   }
605   return predicate(parent) ? parent : null;
606 }