]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/link/index.ts
Lexical: Updated URL handling, added mouse handling
[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 /** @noInheritDoc */
52 export class LinkNode extends ElementNode {
53   /** @internal */
54   __url: string;
55   /** @internal */
56   __target: null | string;
57   /** @internal */
58   __rel: null | string;
59   /** @internal */
60   __title: null | string;
61
62   static getType(): string {
63     return 'link';
64   }
65
66   static clone(node: LinkNode): LinkNode {
67     return new LinkNode(
68       node.__url,
69       {rel: node.__rel, target: node.__target, title: node.__title},
70       node.__key,
71     );
72   }
73
74   constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
75     super(key);
76     const {target = null, rel = null, title = null} = attributes;
77     this.__url = url;
78     this.__target = target;
79     this.__rel = rel;
80     this.__title = title;
81   }
82
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;
88     }
89     if (this.__rel !== null) {
90       element.rel = this.__rel;
91     }
92     if (this.__title !== null) {
93       element.title = this.__title;
94     }
95     addClassNamesToElement(element, config.theme.link);
96     return element;
97   }
98
99   updateDOM(
100     prevNode: LinkNode,
101     anchor: LinkHTMLElementType,
102     config: EditorConfig,
103   ): boolean {
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) {
110         anchor.href = url;
111       }
112
113       if (target !== prevNode.__target) {
114         if (target) {
115           anchor.target = target;
116         } else {
117           anchor.removeAttribute('target');
118         }
119       }
120
121       if (rel !== prevNode.__rel) {
122         if (rel) {
123           anchor.rel = rel;
124         } else {
125           anchor.removeAttribute('rel');
126         }
127       }
128
129       if (title !== prevNode.__title) {
130         if (title) {
131           anchor.title = title;
132         } else {
133           anchor.removeAttribute('title');
134         }
135       }
136     }
137     return false;
138   }
139
140   static importDOM(): DOMConversionMap | null {
141     return {
142       a: (node: Node) => ({
143         conversion: $convertAnchorElement,
144         priority: 1,
145       }),
146     };
147   }
148
149   static importJSON(
150     serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
151   ): LinkNode {
152     const node = $createLinkNode(serializedNode.url, {
153       rel: serializedNode.rel,
154       target: serializedNode.target,
155       title: serializedNode.title,
156     });
157     node.setDirection(serializedNode.direction);
158     return node;
159   }
160
161   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
162     return {
163       ...super.exportJSON(),
164       rel: this.getRel(),
165       target: this.getTarget(),
166       title: this.getTitle(),
167       type: 'link',
168       url: this.getURL(),
169       version: 1,
170     };
171   }
172
173   getURL(): string {
174     return this.getLatest().__url;
175   }
176
177   setURL(url: string): void {
178     const writable = this.getWritable();
179     writable.__url = url;
180   }
181
182   getTarget(): null | string {
183     return this.getLatest().__target;
184   }
185
186   setTarget(target: null | string): void {
187     const writable = this.getWritable();
188     writable.__target = target;
189   }
190
191   getRel(): null | string {
192     return this.getLatest().__rel;
193   }
194
195   setRel(rel: null | string): void {
196     const writable = this.getWritable();
197     writable.__rel = rel;
198   }
199
200   getTitle(): null | string {
201     return this.getLatest().__title;
202   }
203
204   setTitle(title: null | string): void {
205     const writable = this.getWritable();
206     writable.__title = title;
207   }
208
209   insertNewAfter(
210     _: RangeSelection,
211     restoreSelection = true,
212   ): null | ElementNode {
213     const linkNode = $createLinkNode(this.__url, {
214       rel: this.__rel,
215       target: this.__target,
216       title: this.__title,
217     });
218     this.insertAfter(linkNode, restoreSelection);
219     return linkNode;
220   }
221
222   canInsertTextBefore(): false {
223     return false;
224   }
225
226   canInsertTextAfter(): false {
227     return false;
228   }
229
230   canBeEmpty(): false {
231     return false;
232   }
233
234   isInline(): true {
235     return true;
236   }
237
238   extractWithChild(
239     child: LexicalNode,
240     selection: BaseSelection,
241     destination: 'clone' | 'html',
242   ): boolean {
243     if (!$isRangeSelection(selection)) {
244       return false;
245     }
246
247     const anchorNode = selection.anchor.getNode();
248     const focusNode = selection.focus.getNode();
249
250     return (
251       this.isParentOf(anchorNode) &&
252       this.isParentOf(focusNode) &&
253       selection.getTextContent().length > 0
254     );
255   }
256
257   isEmailURI(): boolean {
258     return this.__url.startsWith('mailto:');
259   }
260
261   isWebSiteURI(): boolean {
262     return (
263       this.__url.startsWith('https://') || this.__url.startsWith('http://')
264     );
265   }
266 }
267
268 function $convertAnchorElement(domNode: Node): DOMConversionOutput {
269   let node = null;
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'),
277       });
278     }
279   }
280   return {node};
281 }
282
283 /**
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.
288  */
289 export function $createLinkNode(
290   url: string,
291   attributes?: LinkAttributes,
292 ): LinkNode {
293   return $applyNodeReplacement(new LinkNode(url, attributes));
294 }
295
296 /**
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.
300  */
301 export function $isLinkNode(
302   node: LexicalNode | null | undefined,
303 ): node is LinkNode {
304   return node instanceof LinkNode;
305 }
306
307 export type SerializedAutoLinkNode = Spread<
308   {
309     isUnlinked: boolean;
310   },
311   SerializedLinkNode
312 >;
313
314 // Custom node type to override `canInsertTextAfter` that will
315 // allow typing within the link
316 export class AutoLinkNode extends LinkNode {
317   /** @internal */
318   /** Indicates whether the autolink was ever unlinked. **/
319   __isUnlinked: boolean;
320
321   constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
322     super(url, attributes, key);
323     this.__isUnlinked =
324       attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
325         ? attributes.isUnlinked
326         : false;
327   }
328
329   static getType(): string {
330     return 'autolink';
331   }
332
333   static clone(node: AutoLinkNode): AutoLinkNode {
334     return new AutoLinkNode(
335       node.__url,
336       {
337         isUnlinked: node.__isUnlinked,
338         rel: node.__rel,
339         target: node.__target,
340         title: node.__title,
341       },
342       node.__key,
343     );
344   }
345
346   getIsUnlinked(): boolean {
347     return this.__isUnlinked;
348   }
349
350   setIsUnlinked(value: boolean) {
351     const self = this.getWritable();
352     self.__isUnlinked = value;
353     return self;
354   }
355
356   createDOM(config: EditorConfig): LinkHTMLElementType {
357     if (this.__isUnlinked) {
358       return document.createElement('span');
359     } else {
360       return super.createDOM(config);
361     }
362   }
363
364   updateDOM(
365     prevNode: AutoLinkNode,
366     anchor: LinkHTMLElementType,
367     config: EditorConfig,
368   ): boolean {
369     return (
370       super.updateDOM(prevNode, anchor, config) ||
371       prevNode.__isUnlinked !== this.__isUnlinked
372     );
373   }
374
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,
381     });
382     node.setDirection(serializedNode.direction);
383     return node;
384   }
385
386   static importDOM(): null {
387     // TODO: Should link node should handle the import over autolink?
388     return null;
389   }
390
391   exportJSON(): SerializedAutoLinkNode {
392     return {
393       ...super.exportJSON(),
394       isUnlinked: this.__isUnlinked,
395       type: 'autolink',
396       version: 1,
397     };
398   }
399
400   insertNewAfter(
401     selection: RangeSelection,
402     restoreSelection = true,
403   ): null | ElementNode {
404     const element = this.getParentOrThrow().insertNewAfter(
405       selection,
406       restoreSelection,
407     );
408     if ($isElementNode(element)) {
409       const linkNode = $createAutoLinkNode(this.__url, {
410         isUnlinked: this.__isUnlinked,
411         rel: this.__rel,
412         target: this.__target,
413         title: this.__title,
414       });
415       element.append(linkNode);
416       return linkNode;
417     }
418     return null;
419   }
420 }
421
422 /**
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.
428  */
429 export function $createAutoLinkNode(
430   url: string,
431   attributes?: AutoLinkAttributes,
432 ): AutoLinkNode {
433   return $applyNodeReplacement(new AutoLinkNode(url, attributes));
434 }
435
436 /**
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.
440  */
441 export function $isAutoLinkNode(
442   node: LexicalNode | null | undefined,
443 ): node is AutoLinkNode {
444   return node instanceof AutoLinkNode;
445 }
446
447 export const TOGGLE_LINK_COMMAND: LexicalCommand<
448   string | ({url: string} & LinkAttributes) | null
449 > = createCommand('TOGGLE_LINK_COMMAND');
450
451 /**
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 \\}
456  */
457 export function $toggleLink(
458   url: null | string,
459   attributes: LinkAttributes = {},
460 ): void {
461   const {target, title} = attributes;
462   const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
463   const selection = $getSelection();
464
465   if (!$isRangeSelection(selection)) {
466     return;
467   }
468   const nodes = selection.extract();
469
470   if (url === null) {
471     // Remove LinkNodes
472     nodes.forEach((node) => {
473       const parent = node.getParent();
474
475       if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
476         const children = parent.getChildren();
477
478         for (let i = 0; i < children.length; i++) {
479           parent.insertBefore(children[i]);
480         }
481
482         parent.remove();
483       }
484     });
485   } else {
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);
496         }
497         if (rel !== null) {
498           linkNode.setRel(rel);
499         }
500         if (title !== undefined) {
501           linkNode.setTitle(title);
502         }
503         return;
504       }
505     }
506
507     let prevParent: ElementNode | LinkNode | null = null;
508     let linkNode: LinkNode | null = null;
509
510     nodes.forEach((node) => {
511       const parent = node.getParent();
512
513       if (
514         parent === linkNode ||
515         parent === null ||
516         ($isElementNode(node) && !node.isInline())
517       ) {
518         return;
519       }
520
521       if ($isLinkNode(parent)) {
522         linkNode = parent;
523         parent.setURL(url);
524         if (target !== undefined) {
525           parent.setTarget(target);
526         }
527         if (rel !== null) {
528           linkNode.setRel(rel);
529         }
530         if (title !== undefined) {
531           linkNode.setTitle(title);
532         }
533         return;
534       }
535
536       if (!parent.is(prevParent)) {
537         prevParent = parent;
538         linkNode = $createLinkNode(url, {rel, target, title});
539
540         if ($isLinkNode(parent)) {
541           if (node.getPreviousSibling() === null) {
542             parent.insertBefore(linkNode);
543           } else {
544             parent.insertAfter(linkNode);
545           }
546         } else {
547           node.insertBefore(linkNode);
548         }
549       }
550
551       if ($isLinkNode(node)) {
552         if (node.is(linkNode)) {
553           return;
554         }
555         if (linkNode !== null) {
556           const children = node.getChildren();
557
558           for (let i = 0; i < children.length; i++) {
559             linkNode.append(children[i]);
560           }
561         }
562
563         node.remove();
564         return;
565       }
566
567       if (linkNode !== null) {
568         linkNode.append(node);
569       }
570     });
571   }
572 }
573 /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
574 export const toggleLink = $toggleLink;
575
576 function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
577   node: LexicalNode,
578   predicate: (ancestor: LexicalNode) => ancestor is NodeType,
579 ) {
580   let parent = node;
581   while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
582     parent = parent.getParentOrThrow();
583   }
584   return predicate(parent) ? parent : null;
585 }