@ekz/lexical-link 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1190 @@
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
+ 'use strict';
10
+
11
+ var lexicalUtils = require('@ekz/lexical-utils');
12
+ var lexical = require('@ekz/lexical');
13
+ var lexicalExtension = require('@ekz/lexical-extension');
14
+
15
+ /**
16
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
17
+ *
18
+ * This source code is licensed under the MIT license found in the
19
+ * LICENSE file in the root directory of this source tree.
20
+ *
21
+ */
22
+
23
+ // Do not require this module directly! Use normal `invariant` calls.
24
+
25
+ function formatDevErrorMessage(message) {
26
+ throw new Error(message);
27
+ }
28
+
29
+ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']);
30
+
31
+ /** @noInheritDoc */
32
+ class LinkNode extends lexical.ElementNode {
33
+ /** @internal */
34
+ __url;
35
+ /** @internal */
36
+ __target;
37
+ /** @internal */
38
+ __rel;
39
+ /** @internal */
40
+ __title;
41
+ static getType() {
42
+ return 'link';
43
+ }
44
+ static clone(node) {
45
+ return new LinkNode(node.__url, {
46
+ rel: node.__rel,
47
+ target: node.__target,
48
+ title: node.__title
49
+ }, node.__key);
50
+ }
51
+ constructor(url = '', attributes = {}, key) {
52
+ super(key);
53
+ const {
54
+ target = null,
55
+ rel = null,
56
+ title = null
57
+ } = attributes;
58
+ this.__url = url;
59
+ this.__target = target;
60
+ this.__rel = rel;
61
+ this.__title = title;
62
+ }
63
+ createDOM(config) {
64
+ const element = document.createElement('a');
65
+ this.updateLinkDOM(null, element, config);
66
+ lexicalUtils.addClassNamesToElement(element, config.theme.link);
67
+ return element;
68
+ }
69
+ updateLinkDOM(prevNode, anchor, config) {
70
+ if (lexicalUtils.isHTMLAnchorElement(anchor)) {
71
+ if (!prevNode || prevNode.__url !== this.__url) {
72
+ anchor.href = this.sanitizeUrl(this.__url);
73
+ }
74
+ for (const attr of ['target', 'rel', 'title']) {
75
+ const key = `__${attr}`;
76
+ const value = this[key];
77
+ if (!prevNode || prevNode[key] !== value) {
78
+ if (value) {
79
+ anchor[attr] = value;
80
+ } else {
81
+ anchor.removeAttribute(attr);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ updateDOM(prevNode, anchor, config) {
88
+ this.updateLinkDOM(prevNode, anchor, config);
89
+ return false;
90
+ }
91
+ static importDOM() {
92
+ return {
93
+ a: node => ({
94
+ conversion: $convertAnchorElement,
95
+ priority: 1
96
+ })
97
+ };
98
+ }
99
+ static importJSON(serializedNode) {
100
+ return $createLinkNode().updateFromJSON(serializedNode);
101
+ }
102
+ updateFromJSON(serializedNode) {
103
+ return super.updateFromJSON(serializedNode).setURL(serializedNode.url).setRel(serializedNode.rel || null).setTarget(serializedNode.target || null).setTitle(serializedNode.title || null);
104
+ }
105
+ sanitizeUrl(url) {
106
+ url = formatUrl(url);
107
+ try {
108
+ const parsedUrl = new URL(formatUrl(url));
109
+ // eslint-disable-next-line no-script-url
110
+ if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
111
+ return 'about:blank';
112
+ }
113
+ } catch (_unused) {
114
+ return url;
115
+ }
116
+ return url;
117
+ }
118
+ exportJSON() {
119
+ return {
120
+ ...super.exportJSON(),
121
+ rel: this.getRel(),
122
+ target: this.getTarget(),
123
+ title: this.getTitle(),
124
+ url: this.getURL()
125
+ };
126
+ }
127
+ getURL() {
128
+ return this.getLatest().__url;
129
+ }
130
+ setURL(url) {
131
+ const writable = this.getWritable();
132
+ writable.__url = url;
133
+ return writable;
134
+ }
135
+ getTarget() {
136
+ return this.getLatest().__target;
137
+ }
138
+ setTarget(target) {
139
+ const writable = this.getWritable();
140
+ writable.__target = target;
141
+ return writable;
142
+ }
143
+ getRel() {
144
+ return this.getLatest().__rel;
145
+ }
146
+ setRel(rel) {
147
+ const writable = this.getWritable();
148
+ writable.__rel = rel;
149
+ return writable;
150
+ }
151
+ getTitle() {
152
+ return this.getLatest().__title;
153
+ }
154
+ setTitle(title) {
155
+ const writable = this.getWritable();
156
+ writable.__title = title;
157
+ return writable;
158
+ }
159
+ insertNewAfter(_, restoreSelection = true) {
160
+ const linkNode = $createLinkNode(this.__url, {
161
+ rel: this.__rel,
162
+ target: this.__target,
163
+ title: this.__title
164
+ });
165
+ this.insertAfter(linkNode, restoreSelection);
166
+ return linkNode;
167
+ }
168
+ canInsertTextBefore() {
169
+ return false;
170
+ }
171
+ canInsertTextAfter() {
172
+ return false;
173
+ }
174
+ canBeEmpty() {
175
+ return false;
176
+ }
177
+ isInline() {
178
+ return true;
179
+ }
180
+ extractWithChild(child, selection, destination) {
181
+ if (!lexical.$isRangeSelection(selection)) {
182
+ return false;
183
+ }
184
+ const anchorNode = selection.anchor.getNode();
185
+ const focusNode = selection.focus.getNode();
186
+ return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && selection.getTextContent().length > 0;
187
+ }
188
+ isEmailURI() {
189
+ return this.__url.startsWith('mailto:');
190
+ }
191
+ isWebSiteURI() {
192
+ return this.__url.startsWith('https://') || this.__url.startsWith('http://');
193
+ }
194
+ }
195
+ function $convertAnchorElement(domNode) {
196
+ let node = null;
197
+ if (lexicalUtils.isHTMLAnchorElement(domNode)) {
198
+ const content = domNode.textContent;
199
+ if (content !== null && content !== '' || domNode.children.length > 0) {
200
+ node = $createLinkNode(domNode.getAttribute('href') || '', {
201
+ rel: domNode.getAttribute('rel'),
202
+ target: domNode.getAttribute('target'),
203
+ title: domNode.getAttribute('title')
204
+ });
205
+ }
206
+ }
207
+ return {
208
+ node
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Takes a URL and creates a LinkNode.
214
+ * @param url - The URL the LinkNode should direct to.
215
+ * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
216
+ * @returns The LinkNode.
217
+ */
218
+ function $createLinkNode(url = '', attributes) {
219
+ return lexical.$applyNodeReplacement(new LinkNode(url, attributes));
220
+ }
221
+
222
+ /**
223
+ * Determines if node is a LinkNode.
224
+ * @param node - The node to be checked.
225
+ * @returns true if node is a LinkNode, false otherwise.
226
+ */
227
+ function $isLinkNode(node) {
228
+ return node instanceof LinkNode;
229
+ }
230
+ // Custom node type to override `canInsertTextAfter` that will
231
+ // allow typing within the link
232
+ class AutoLinkNode extends LinkNode {
233
+ /** @internal */
234
+ /** Indicates whether the autolink was ever unlinked. **/
235
+ __isUnlinked;
236
+ constructor(url = '', attributes = {}, key) {
237
+ super(url, attributes, key);
238
+ this.__isUnlinked = attributes.isUnlinked !== undefined && attributes.isUnlinked !== null ? attributes.isUnlinked : false;
239
+ }
240
+ static getType() {
241
+ return 'autolink';
242
+ }
243
+ static clone(node) {
244
+ return new AutoLinkNode(node.__url, {
245
+ isUnlinked: node.__isUnlinked,
246
+ rel: node.__rel,
247
+ target: node.__target,
248
+ title: node.__title
249
+ }, node.__key);
250
+ }
251
+ getIsUnlinked() {
252
+ return this.__isUnlinked;
253
+ }
254
+ setIsUnlinked(value) {
255
+ const self = this.getWritable();
256
+ self.__isUnlinked = value;
257
+ return self;
258
+ }
259
+ createDOM(config) {
260
+ if (this.__isUnlinked) {
261
+ return document.createElement('span');
262
+ } else {
263
+ return super.createDOM(config);
264
+ }
265
+ }
266
+ updateDOM(prevNode, anchor, config) {
267
+ return super.updateDOM(prevNode, anchor, config) || prevNode.__isUnlinked !== this.__isUnlinked;
268
+ }
269
+ static importJSON(serializedNode) {
270
+ return $createAutoLinkNode().updateFromJSON(serializedNode);
271
+ }
272
+ updateFromJSON(serializedNode) {
273
+ return super.updateFromJSON(serializedNode).setIsUnlinked(serializedNode.isUnlinked || false);
274
+ }
275
+ static importDOM() {
276
+ // TODO: Should link node should handle the import over autolink?
277
+ return null;
278
+ }
279
+ exportJSON() {
280
+ return {
281
+ ...super.exportJSON(),
282
+ isUnlinked: this.__isUnlinked
283
+ };
284
+ }
285
+ insertNewAfter(selection, restoreSelection = true) {
286
+ const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection);
287
+ if (lexical.$isElementNode(element)) {
288
+ const linkNode = $createAutoLinkNode(this.__url, {
289
+ isUnlinked: this.__isUnlinked,
290
+ rel: this.__rel,
291
+ target: this.__target,
292
+ title: this.__title
293
+ });
294
+ element.append(linkNode);
295
+ return linkNode;
296
+ }
297
+ return null;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
303
+ * during typing, which is especially useful when a button to generate a LinkNode is not practical.
304
+ * @param url - The URL the LinkNode should direct to.
305
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
306
+ * @returns The LinkNode.
307
+ */
308
+ function $createAutoLinkNode(url = '', attributes) {
309
+ return lexical.$applyNodeReplacement(new AutoLinkNode(url, attributes));
310
+ }
311
+
312
+ /**
313
+ * Determines if node is an AutoLinkNode.
314
+ * @param node - The node to be checked.
315
+ * @returns true if node is an AutoLinkNode, false otherwise.
316
+ */
317
+ function $isAutoLinkNode(node) {
318
+ return node instanceof AutoLinkNode;
319
+ }
320
+ const TOGGLE_LINK_COMMAND = lexical.createCommand('TOGGLE_LINK_COMMAND');
321
+ function $getPointNode(point, offset) {
322
+ if (point.type === 'element') {
323
+ const node = point.getNode();
324
+ if (!lexical.$isElementNode(node)) {
325
+ formatDevErrorMessage(`$getPointNode: element point is not an ElementNode`);
326
+ }
327
+ const childNode = node.getChildren()[point.offset + offset];
328
+ return childNode || null;
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Preserve the logical start/end of a RangeSelection in situations where
335
+ * the point is an element that may be reparented in the callback.
336
+ *
337
+ * @param $fn The function to run
338
+ * @returns The result of the callback
339
+ */
340
+ function $withSelectedNodes($fn) {
341
+ const initialSelection = lexical.$getSelection();
342
+ if (!lexical.$isRangeSelection(initialSelection)) {
343
+ return $fn();
344
+ }
345
+ const normalized = lexical.$normalizeSelection__EXPERIMENTAL(initialSelection);
346
+ const isBackwards = normalized.isBackward();
347
+ const anchorNode = $getPointNode(normalized.anchor, isBackwards ? -1 : 0);
348
+ const focusNode = $getPointNode(normalized.focus, isBackwards ? 0 : -1);
349
+ const rval = $fn();
350
+ if (anchorNode || focusNode) {
351
+ const updatedSelection = lexical.$getSelection();
352
+ if (lexical.$isRangeSelection(updatedSelection)) {
353
+ const finalSelection = updatedSelection.clone();
354
+ if (anchorNode) {
355
+ const anchorParent = anchorNode.getParent();
356
+ if (anchorParent) {
357
+ finalSelection.anchor.set(anchorParent.getKey(), anchorNode.getIndexWithinParent() + (isBackwards ? 1 : 0), 'element');
358
+ }
359
+ }
360
+ if (focusNode) {
361
+ const focusParent = focusNode.getParent();
362
+ if (focusParent) {
363
+ finalSelection.focus.set(focusParent.getKey(), focusNode.getIndexWithinParent() + (isBackwards ? 0 : 1), 'element');
364
+ }
365
+ }
366
+ lexical.$setSelection(lexical.$normalizeSelection__EXPERIMENTAL(finalSelection));
367
+ }
368
+ }
369
+ return rval;
370
+ }
371
+
372
+ /**
373
+ * Splits a LinkNode by removing selected children from it.
374
+ * Handles three cases: selection at start, end, or middle of the link.
375
+ * @param parentLink - The LinkNode to split
376
+ * @param extractedNodes - The nodes that were extracted from the selection
377
+ */
378
+ function $splitLinkAtSelection(parentLink, extractedNodes) {
379
+ const extractedKeys = new Set(extractedNodes.filter(n => parentLink.isParentOf(n)).map(n => n.getKey()));
380
+ const allChildren = parentLink.getChildren();
381
+ const extractedChildren = allChildren.filter(child => extractedKeys.has(child.getKey()));
382
+ if (extractedChildren.length === allChildren.length) {
383
+ allChildren.forEach(child => parentLink.insertBefore(child));
384
+ parentLink.remove();
385
+ return;
386
+ }
387
+ const firstExtractedIndex = allChildren.findIndex(child => extractedKeys.has(child.getKey()));
388
+ const lastExtractedIndex = allChildren.findLastIndex(child => extractedKeys.has(child.getKey()));
389
+ const isAtStart = firstExtractedIndex === 0;
390
+ const isAtEnd = lastExtractedIndex === allChildren.length - 1;
391
+ if (isAtStart) {
392
+ extractedChildren.forEach(child => parentLink.insertBefore(child));
393
+ } else if (isAtEnd) {
394
+ for (let i = extractedChildren.length - 1; i >= 0; i--) {
395
+ parentLink.insertAfter(extractedChildren[i]);
396
+ }
397
+ } else {
398
+ for (let i = extractedChildren.length - 1; i >= 0; i--) {
399
+ parentLink.insertAfter(extractedChildren[i]);
400
+ }
401
+ const trailingChildren = allChildren.slice(lastExtractedIndex + 1);
402
+ if (trailingChildren.length > 0) {
403
+ const newLink = $createLinkNode(parentLink.getURL(), {
404
+ rel: parentLink.getRel(),
405
+ target: parentLink.getTarget(),
406
+ title: parentLink.getTitle()
407
+ });
408
+ extractedChildren[extractedChildren.length - 1].insertAfter(newLink);
409
+ trailingChildren.forEach(child => newLink.append(child));
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
416
+ * but saves any children and brings them up to the parent node.
417
+ * @param urlOrAttributes - The URL the link directs to, or an attributes object with an url property
418
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
419
+ */
420
+ function $toggleLink(urlOrAttributes, attributes = {}) {
421
+ let url;
422
+ if (urlOrAttributes && typeof urlOrAttributes === 'object') {
423
+ const {
424
+ url: urlProp,
425
+ ...rest
426
+ } = urlOrAttributes;
427
+ url = urlProp;
428
+ attributes = {
429
+ ...rest,
430
+ ...attributes
431
+ };
432
+ } else {
433
+ url = urlOrAttributes;
434
+ }
435
+ const {
436
+ target,
437
+ title
438
+ } = attributes;
439
+ const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
440
+ const selection = lexical.$getSelection();
441
+ if (selection === null || !lexical.$isRangeSelection(selection) && !lexical.$isNodeSelection(selection)) {
442
+ return;
443
+ }
444
+ if (lexical.$isNodeSelection(selection)) {
445
+ const nodes = selection.getNodes();
446
+ if (nodes.length === 0) {
447
+ return;
448
+ }
449
+
450
+ // Handle all selected nodes
451
+ nodes.forEach(node => {
452
+ if (url === null) {
453
+ // Remove link
454
+ const linkParent = lexicalUtils.$findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
455
+ if (linkParent) {
456
+ linkParent.insertBefore(node);
457
+ if (linkParent.getChildren().length === 0) {
458
+ linkParent.remove();
459
+ }
460
+ }
461
+ } else {
462
+ // Add/Update link
463
+ const existingLink = lexicalUtils.$findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
464
+ if (existingLink) {
465
+ existingLink.setURL(url);
466
+ if (target !== undefined) {
467
+ existingLink.setTarget(target);
468
+ }
469
+ if (rel !== undefined) {
470
+ existingLink.setRel(rel);
471
+ }
472
+ } else {
473
+ const linkNode = $createLinkNode(url, {
474
+ rel,
475
+ target
476
+ });
477
+ node.insertBefore(linkNode);
478
+ linkNode.append(node);
479
+ }
480
+ }
481
+ });
482
+ return;
483
+ }
484
+
485
+ // Handle RangeSelection
486
+ const nodes = selection.extract();
487
+ if (url === null) {
488
+ const processedLinks = new Set();
489
+ nodes.forEach(node => {
490
+ const parentLink = node.getParent();
491
+ if ($isLinkNode(parentLink) && !$isAutoLinkNode(parentLink)) {
492
+ const linkKey = parentLink.getKey();
493
+ if (processedLinks.has(linkKey)) {
494
+ return;
495
+ }
496
+ $splitLinkAtSelection(parentLink, nodes);
497
+ processedLinks.add(linkKey);
498
+ }
499
+ });
500
+ return;
501
+ }
502
+ const updatedNodes = new Set();
503
+ const updateLinkNode = linkNode => {
504
+ if (updatedNodes.has(linkNode.getKey())) {
505
+ return;
506
+ }
507
+ updatedNodes.add(linkNode.getKey());
508
+ linkNode.setURL(url);
509
+ if (target !== undefined) {
510
+ linkNode.setTarget(target);
511
+ }
512
+ if (rel !== undefined) {
513
+ linkNode.setRel(rel);
514
+ }
515
+ if (title !== undefined) {
516
+ linkNode.setTitle(title);
517
+ }
518
+ };
519
+ // Add or merge LinkNodes
520
+ if (nodes.length === 1) {
521
+ const firstNode = nodes[0];
522
+ // if the first node is a LinkNode or if its
523
+ // parent is a LinkNode, we update the URL, target and rel.
524
+ const linkNode = lexicalUtils.$findMatchingParent(firstNode, $isLinkNode);
525
+ if (linkNode !== null) {
526
+ return updateLinkNode(linkNode);
527
+ }
528
+ }
529
+ $withSelectedNodes(() => {
530
+ let linkNode = null;
531
+ for (const node of nodes) {
532
+ if (!node.isAttached()) {
533
+ continue;
534
+ }
535
+ const parentLinkNode = lexicalUtils.$findMatchingParent(node, $isLinkNode);
536
+ if (parentLinkNode) {
537
+ updateLinkNode(parentLinkNode);
538
+ continue;
539
+ }
540
+ if (lexical.$isElementNode(node)) {
541
+ if (!node.isInline()) {
542
+ // Ignore block nodes, if there are any children we will see them
543
+ // later and wrap in a new LinkNode
544
+ continue;
545
+ }
546
+ if ($isLinkNode(node)) {
547
+ // If it's not an autolink node and we don't already have a LinkNode
548
+ // in this block then we can update it and re-use it
549
+ if (!$isAutoLinkNode(node) && (linkNode === null || !linkNode.getParentOrThrow().isParentOf(node))) {
550
+ updateLinkNode(node);
551
+ linkNode = node;
552
+ continue;
553
+ }
554
+ // Unwrap LinkNode, we already have one or it's an AutoLinkNode
555
+ for (const child of node.getChildren()) {
556
+ node.insertBefore(child);
557
+ }
558
+ node.remove();
559
+ continue;
560
+ }
561
+ }
562
+ const prevLinkNode = node.getPreviousSibling();
563
+ if ($isLinkNode(prevLinkNode) && prevLinkNode.is(linkNode)) {
564
+ prevLinkNode.append(node);
565
+ continue;
566
+ }
567
+ linkNode = $createLinkNode(url, {
568
+ rel,
569
+ target,
570
+ title
571
+ });
572
+ node.insertAfter(linkNode);
573
+ linkNode.append(node);
574
+ }
575
+ });
576
+ }
577
+ const PHONE_NUMBER_REGEX = /^\+?[0-9\s()-]{5,}$/;
578
+
579
+ /**
580
+ * Formats a URL string by adding appropriate protocol if missing
581
+ *
582
+ * @param url - URL to format
583
+ * @returns Formatted URL with appropriate protocol
584
+ */
585
+ function formatUrl(url) {
586
+ // Check if URL already has a protocol
587
+ if (url.match(/^[a-z][a-z0-9+.-]*:/i)) {
588
+ // URL already has a protocol, leave it as is
589
+ return url;
590
+ }
591
+ // Check if it's a relative path (starting with '/', '.', or '#')
592
+ else if (url.match(/^[/#.]/)) {
593
+ // Relative path, leave it as is
594
+ return url;
595
+ }
596
+
597
+ // Check for email address
598
+ else if (url.includes('@')) {
599
+ return `mailto:${url}`;
600
+ }
601
+
602
+ // Check for phone number
603
+ else if (PHONE_NUMBER_REGEX.test(url)) {
604
+ return `tel:${url}`;
605
+ }
606
+
607
+ // For everything else, return with https:// prefix
608
+ return `https://${url}`;
609
+ }
610
+
611
+ /**
612
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
613
+ *
614
+ * This source code is licensed under the MIT license found in the
615
+ * LICENSE file in the root directory of this source tree.
616
+ *
617
+ */
618
+
619
+ const defaultProps = {
620
+ attributes: undefined,
621
+ validateUrl: undefined
622
+ };
623
+
624
+ /** @internal */
625
+ function registerLink(editor, stores) {
626
+ return lexicalUtils.mergeRegister(lexicalExtension.effect(() => editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
627
+ const validateUrl = stores.validateUrl.peek();
628
+ const attributes = stores.attributes.peek();
629
+ if (payload === null) {
630
+ $toggleLink(null);
631
+ return true;
632
+ } else if (typeof payload === 'string') {
633
+ if (validateUrl === undefined || validateUrl(payload)) {
634
+ $toggleLink(payload, attributes);
635
+ return true;
636
+ }
637
+ return false;
638
+ } else {
639
+ const {
640
+ url,
641
+ target,
642
+ rel,
643
+ title
644
+ } = payload;
645
+ $toggleLink(url, {
646
+ ...attributes,
647
+ rel,
648
+ target,
649
+ title
650
+ });
651
+ return true;
652
+ }
653
+ }, lexical.COMMAND_PRIORITY_LOW)), lexicalExtension.effect(() => {
654
+ const validateUrl = stores.validateUrl.value;
655
+ if (!validateUrl) {
656
+ return;
657
+ }
658
+ const attributes = stores.attributes.value;
659
+ return editor.registerCommand(lexical.PASTE_COMMAND, event => {
660
+ const selection = lexical.$getSelection();
661
+ if (!lexical.$isRangeSelection(selection) || selection.isCollapsed() || !lexicalUtils.objectKlassEquals(event, ClipboardEvent)) {
662
+ return false;
663
+ }
664
+ if (event.clipboardData === null) {
665
+ return false;
666
+ }
667
+ const clipboardText = event.clipboardData.getData('text');
668
+ if (!validateUrl(clipboardText)) {
669
+ return false;
670
+ }
671
+ // If we select nodes that are elements then avoid applying the link.
672
+ if (!selection.getNodes().some(node => lexical.$isElementNode(node))) {
673
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
674
+ ...attributes,
675
+ url: clipboardText
676
+ });
677
+ event.preventDefault();
678
+ return true;
679
+ }
680
+ return false;
681
+ }, lexical.COMMAND_PRIORITY_LOW);
682
+ }));
683
+ }
684
+
685
+ /**
686
+ * Provides {@link LinkNode}, an implementation of
687
+ * {@link TOGGLE_LINK_COMMAND}, and a {@link PASTE_COMMAND}
688
+ * listener to wrap selected nodes in a link when a
689
+ * URL is pasted and `validateUrl` is defined.
690
+ */
691
+ const LinkExtension = lexical.defineExtension({
692
+ build(editor, config, state) {
693
+ return lexicalExtension.namedSignals(config);
694
+ },
695
+ config: defaultProps,
696
+ mergeConfig(config, overrides) {
697
+ const merged = lexical.shallowMergeConfig(config, overrides);
698
+ if (config.attributes) {
699
+ merged.attributes = lexical.shallowMergeConfig(config.attributes, merged.attributes);
700
+ }
701
+ return merged;
702
+ },
703
+ name: '@ekz/lexical-link/Link',
704
+ nodes: () => [LinkNode],
705
+ register(editor, config, state) {
706
+ return registerLink(editor, state.getOutput());
707
+ }
708
+ });
709
+
710
+ /**
711
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
712
+ *
713
+ * This source code is licensed under the MIT license found in the
714
+ * LICENSE file in the root directory of this source tree.
715
+ *
716
+ */
717
+
718
+ function findMatchingDOM(startNode, predicate) {
719
+ let node = startNode;
720
+ while (node != null) {
721
+ if (predicate(node)) {
722
+ return node;
723
+ }
724
+ node = node.parentNode;
725
+ }
726
+ return null;
727
+ }
728
+ function registerClickableLink(editor, stores, eventOptions = {}) {
729
+ const onClick = event => {
730
+ const target = event.target;
731
+ if (!lexical.isDOMNode(target)) {
732
+ return;
733
+ }
734
+ const nearestEditor = lexical.getNearestEditorFromDOMNode(target);
735
+ if (nearestEditor === null) {
736
+ return;
737
+ }
738
+ let url = null;
739
+ let urlTarget = null;
740
+ nearestEditor.update(() => {
741
+ const clickedNode = lexical.$getNearestNodeFromDOMNode(target);
742
+ if (clickedNode !== null) {
743
+ const maybeLinkNode = lexicalUtils.$findMatchingParent(clickedNode, lexical.$isElementNode);
744
+ if (!stores.disabled.peek()) {
745
+ if ($isLinkNode(maybeLinkNode)) {
746
+ url = maybeLinkNode.sanitizeUrl(maybeLinkNode.getURL());
747
+ urlTarget = maybeLinkNode.getTarget();
748
+ } else {
749
+ const a = findMatchingDOM(target, lexicalUtils.isHTMLAnchorElement);
750
+ if (a !== null) {
751
+ url = a.href;
752
+ urlTarget = a.target;
753
+ }
754
+ }
755
+ }
756
+ }
757
+ });
758
+ if (url === null || url === '') {
759
+ return;
760
+ }
761
+
762
+ // Allow user to select link text without following url
763
+ const selection = editor.getEditorState().read(lexical.$getSelection);
764
+ if (lexical.$isRangeSelection(selection) && !selection.isCollapsed()) {
765
+ event.preventDefault();
766
+ return;
767
+ }
768
+ const isMiddle = event.type === 'auxclick' && event.button === 1;
769
+ window.open(url, stores.newTab.peek() || isMiddle || event.metaKey || event.ctrlKey || urlTarget === '_blank' ? '_blank' : '_self');
770
+ event.preventDefault();
771
+ };
772
+ const onMouseUp = event => {
773
+ if (event.button === 1) {
774
+ onClick(event);
775
+ }
776
+ };
777
+ return editor.registerRootListener((rootElement, prevRootElement) => {
778
+ if (prevRootElement !== null) {
779
+ prevRootElement.removeEventListener('click', onClick);
780
+ prevRootElement.removeEventListener('mouseup', onMouseUp);
781
+ }
782
+ if (rootElement !== null) {
783
+ rootElement.addEventListener('click', onClick, eventOptions);
784
+ rootElement.addEventListener('mouseup', onMouseUp, eventOptions);
785
+ }
786
+ });
787
+ }
788
+
789
+ /**
790
+ * Normally in a Lexical editor the `CLICK_COMMAND` on a LinkNode will cause the
791
+ * selection to change instead of opening a link. This extension can be used to
792
+ * restore the default behavior, e.g. when the editor is not editable.
793
+ */
794
+ const ClickableLinkExtension = lexical.defineExtension({
795
+ build(editor, config, state) {
796
+ return lexicalExtension.namedSignals(config);
797
+ },
798
+ config: lexical.safeCast({
799
+ disabled: false,
800
+ newTab: false
801
+ }),
802
+ dependencies: [LinkExtension],
803
+ name: '@ekz/lexical-link/ClickableLink',
804
+ register(editor, config, state) {
805
+ return registerClickableLink(editor, state.getOutput());
806
+ }
807
+ });
808
+
809
+ /**
810
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
811
+ *
812
+ * This source code is licensed under the MIT license found in the
813
+ * LICENSE file in the root directory of this source tree.
814
+ *
815
+ */
816
+
817
+ function createLinkMatcherWithRegExp(regExp, urlTransformer = text => text) {
818
+ return text => {
819
+ const match = regExp.exec(text);
820
+ if (match === null) {
821
+ return null;
822
+ }
823
+ return {
824
+ index: match.index,
825
+ length: match[0].length,
826
+ text: match[0],
827
+ url: urlTransformer(match[0])
828
+ };
829
+ };
830
+ }
831
+ function findFirstMatch(text, matchers) {
832
+ for (let i = 0; i < matchers.length; i++) {
833
+ const match = matchers[i](text);
834
+ if (match) {
835
+ return match;
836
+ }
837
+ }
838
+ return null;
839
+ }
840
+ const PUNCTUATION_OR_SPACE = /[.,;\s]/;
841
+ function isSeparator(char) {
842
+ return PUNCTUATION_OR_SPACE.test(char);
843
+ }
844
+ function endsWithSeparator(textContent) {
845
+ return isSeparator(textContent[textContent.length - 1]);
846
+ }
847
+ function startsWithSeparator(textContent) {
848
+ return isSeparator(textContent[0]);
849
+ }
850
+
851
+ /**
852
+ * Check if the text content starts with a fullstop followed by a top-level domain.
853
+ * Meaning if the text content can be a beginning of a top level domain.
854
+ * @param textContent
855
+ * @param isEmail
856
+ * @returns boolean
857
+ */
858
+ function startsWithTLD(textContent, isEmail) {
859
+ if (isEmail) {
860
+ return /^\.[a-zA-Z]{2,}/.test(textContent);
861
+ } else {
862
+ return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
863
+ }
864
+ }
865
+ function isPreviousNodeValid(node) {
866
+ let previousNode = node.getPreviousSibling();
867
+ if (lexical.$isElementNode(previousNode)) {
868
+ previousNode = previousNode.getLastDescendant();
869
+ }
870
+ return previousNode === null || lexical.$isLineBreakNode(previousNode) || lexical.$isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent());
871
+ }
872
+ function isNextNodeValid(node) {
873
+ let nextNode = node.getNextSibling();
874
+ if (lexical.$isElementNode(nextNode)) {
875
+ nextNode = nextNode.getFirstDescendant();
876
+ }
877
+ return nextNode === null || lexical.$isLineBreakNode(nextNode) || lexical.$isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent());
878
+ }
879
+ function isContentAroundIsValid(matchStart, matchEnd, text, nodes) {
880
+ const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0]);
881
+ if (!contentBeforeIsValid) {
882
+ return false;
883
+ }
884
+ const contentAfterIsValid = matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1]);
885
+ return contentAfterIsValid;
886
+ }
887
+ function extractMatchingNodes(nodes, startIndex, endIndex) {
888
+ const unmodifiedBeforeNodes = [];
889
+ const matchingNodes = [];
890
+ const unmodifiedAfterNodes = [];
891
+ let matchingOffset = 0;
892
+ let currentOffset = 0;
893
+ const currentNodes = [...nodes];
894
+ while (currentNodes.length > 0) {
895
+ const currentNode = currentNodes[0];
896
+ const currentNodeText = currentNode.getTextContent();
897
+ const currentNodeLength = currentNodeText.length;
898
+ const currentNodeStart = currentOffset;
899
+ const currentNodeEnd = currentOffset + currentNodeLength;
900
+ if (currentNodeEnd <= startIndex) {
901
+ unmodifiedBeforeNodes.push(currentNode);
902
+ matchingOffset += currentNodeLength;
903
+ } else if (currentNodeStart >= endIndex) {
904
+ unmodifiedAfterNodes.push(currentNode);
905
+ } else {
906
+ matchingNodes.push(currentNode);
907
+ }
908
+ currentOffset += currentNodeLength;
909
+ currentNodes.shift();
910
+ }
911
+ return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes];
912
+ }
913
+ function $createAutoLinkNode_(nodes, startIndex, endIndex, match) {
914
+ const linkNode = $createAutoLinkNode(match.url, match.attributes);
915
+ if (nodes.length === 1) {
916
+ let remainingTextNode = nodes[0];
917
+ let linkTextNode;
918
+ if (startIndex === 0) {
919
+ [linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex);
920
+ } else {
921
+ [, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex);
922
+ }
923
+ const textNode = lexical.$createTextNode(match.text);
924
+ textNode.setFormat(linkTextNode.getFormat());
925
+ textNode.setDetail(linkTextNode.getDetail());
926
+ textNode.setStyle(linkTextNode.getStyle());
927
+ linkNode.append(textNode);
928
+ linkTextNode.replace(linkNode);
929
+ return remainingTextNode;
930
+ } else if (nodes.length > 1) {
931
+ const firstTextNode = nodes[0];
932
+ let offset = firstTextNode.getTextContent().length;
933
+ let firstLinkTextNode;
934
+ if (startIndex === 0) {
935
+ firstLinkTextNode = firstTextNode;
936
+ } else {
937
+ [, firstLinkTextNode] = firstTextNode.splitText(startIndex);
938
+ }
939
+ const linkNodes = [];
940
+ let remainingTextNode;
941
+ for (let i = 1; i < nodes.length; i++) {
942
+ const currentNode = nodes[i];
943
+ const currentNodeText = currentNode.getTextContent();
944
+ const currentNodeLength = currentNodeText.length;
945
+ const currentNodeStart = offset;
946
+ const currentNodeEnd = offset + currentNodeLength;
947
+ if (currentNodeStart < endIndex) {
948
+ if (currentNodeEnd <= endIndex) {
949
+ linkNodes.push(currentNode);
950
+ } else {
951
+ const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart);
952
+ linkNodes.push(linkTextNode);
953
+ remainingTextNode = endNode;
954
+ }
955
+ }
956
+ offset += currentNodeLength;
957
+ }
958
+ const selection = lexical.$getSelection();
959
+ const selectedTextNode = selection ? selection.getNodes().find(lexical.$isTextNode) : undefined;
960
+ const textNode = lexical.$createTextNode(firstLinkTextNode.getTextContent());
961
+ textNode.setFormat(firstLinkTextNode.getFormat());
962
+ textNode.setDetail(firstLinkTextNode.getDetail());
963
+ textNode.setStyle(firstLinkTextNode.getStyle());
964
+ linkNode.append(textNode, ...linkNodes);
965
+ // it does not preserve caret position if caret was at the first text node
966
+ // so we need to restore caret position
967
+ if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
968
+ if (lexical.$isRangeSelection(selection)) {
969
+ textNode.select(selection.anchor.offset, selection.focus.offset);
970
+ } else if (lexical.$isNodeSelection(selection)) {
971
+ textNode.select(0, textNode.getTextContent().length);
972
+ }
973
+ }
974
+ firstLinkTextNode.replace(linkNode);
975
+ return remainingTextNode;
976
+ }
977
+ return undefined;
978
+ }
979
+ function $handleLinkCreation(nodes, matchers, onChange) {
980
+ let currentNodes = [...nodes];
981
+ const initialText = currentNodes.map(node => node.getTextContent()).join('');
982
+ let text = initialText;
983
+ let match;
984
+ let invalidMatchEnd = 0;
985
+ while ((match = findFirstMatch(text, matchers)) && match !== null) {
986
+ const matchStart = match.index;
987
+ const matchLength = match.length;
988
+ const matchEnd = matchStart + matchLength;
989
+ const isValid = isContentAroundIsValid(invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd, initialText, currentNodes);
990
+ if (isValid) {
991
+ const [matchingOffset,, matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes(currentNodes, invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd);
992
+ const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset;
993
+ const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset;
994
+ const remainingTextNode = $createAutoLinkNode_(matchingNodes, actualMatchStart, actualMatchEnd, match);
995
+ currentNodes = remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes;
996
+ onChange(match.url, null);
997
+ invalidMatchEnd = 0;
998
+ } else {
999
+ invalidMatchEnd += matchEnd;
1000
+ }
1001
+ text = text.substring(matchEnd);
1002
+ }
1003
+ }
1004
+ function handleLinkEdit(linkNode, matchers, onChange) {
1005
+ // Check children are simple text
1006
+ const children = linkNode.getChildren();
1007
+ const childrenLength = children.length;
1008
+ for (let i = 0; i < childrenLength; i++) {
1009
+ const child = children[i];
1010
+ if (!lexical.$isTextNode(child) || !child.isSimpleText()) {
1011
+ replaceWithChildren(linkNode);
1012
+ onChange(null, linkNode.getURL());
1013
+ return;
1014
+ }
1015
+ }
1016
+
1017
+ // Check text content fully matches
1018
+ const text = linkNode.getTextContent();
1019
+ const match = findFirstMatch(text, matchers);
1020
+ if (match === null || match.text !== text) {
1021
+ replaceWithChildren(linkNode);
1022
+ onChange(null, linkNode.getURL());
1023
+ return;
1024
+ }
1025
+
1026
+ // Check neighbors
1027
+ if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) {
1028
+ replaceWithChildren(linkNode);
1029
+ onChange(null, linkNode.getURL());
1030
+ return;
1031
+ }
1032
+ const url = linkNode.getURL();
1033
+ if (url !== match.url) {
1034
+ linkNode.setURL(match.url);
1035
+ onChange(match.url, url);
1036
+ }
1037
+ if (match.attributes) {
1038
+ const rel = linkNode.getRel();
1039
+ if (rel !== match.attributes.rel) {
1040
+ linkNode.setRel(match.attributes.rel || null);
1041
+ onChange(match.attributes.rel || null, rel);
1042
+ }
1043
+ const target = linkNode.getTarget();
1044
+ if (target !== match.attributes.target) {
1045
+ linkNode.setTarget(match.attributes.target || null);
1046
+ onChange(match.attributes.target || null, target);
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ // Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible.
1052
+ // Given the creation preconditions, these can only be simple text nodes.
1053
+ function handleBadNeighbors(textNode, matchers, onChange) {
1054
+ const previousSibling = textNode.getPreviousSibling();
1055
+ const nextSibling = textNode.getNextSibling();
1056
+ const text = textNode.getTextContent();
1057
+ if ($isAutoLinkNode(previousSibling) && !previousSibling.getIsUnlinked() && (!startsWithSeparator(text) || startsWithTLD(text, previousSibling.isEmailURI()))) {
1058
+ previousSibling.append(textNode);
1059
+ handleLinkEdit(previousSibling, matchers, onChange);
1060
+ onChange(null, previousSibling.getURL());
1061
+ }
1062
+ if ($isAutoLinkNode(nextSibling) && !nextSibling.getIsUnlinked() && !endsWithSeparator(text)) {
1063
+ replaceWithChildren(nextSibling);
1064
+ handleLinkEdit(nextSibling, matchers, onChange);
1065
+ onChange(null, nextSibling.getURL());
1066
+ }
1067
+ }
1068
+ function replaceWithChildren(node) {
1069
+ const children = node.getChildren();
1070
+ const childrenLength = children.length;
1071
+ for (let j = childrenLength - 1; j >= 0; j--) {
1072
+ node.insertAfter(children[j]);
1073
+ }
1074
+ node.remove();
1075
+ return children.map(child => child.getLatest());
1076
+ }
1077
+ function getTextNodesToMatch(textNode) {
1078
+ // check if next siblings are simple text nodes till a node contains a space separator
1079
+ const textNodesToMatch = [textNode];
1080
+ let nextSibling = textNode.getNextSibling();
1081
+ while (nextSibling !== null && lexical.$isTextNode(nextSibling) && nextSibling.isSimpleText()) {
1082
+ textNodesToMatch.push(nextSibling);
1083
+ if (/[\s]/.test(nextSibling.getTextContent())) {
1084
+ break;
1085
+ }
1086
+ nextSibling = nextSibling.getNextSibling();
1087
+ }
1088
+ return textNodesToMatch;
1089
+ }
1090
+ const defaultConfig = {
1091
+ changeHandlers: [],
1092
+ matchers: []
1093
+ };
1094
+ function registerAutoLink(editor, config = defaultConfig) {
1095
+ const {
1096
+ matchers,
1097
+ changeHandlers
1098
+ } = config;
1099
+ const onChange = (url, prevUrl) => {
1100
+ for (const handler of changeHandlers) {
1101
+ handler(url, prevUrl);
1102
+ }
1103
+ };
1104
+ return lexicalUtils.mergeRegister(editor.registerNodeTransform(lexical.TextNode, textNode => {
1105
+ const parent = textNode.getParentOrThrow();
1106
+ const previous = textNode.getPreviousSibling();
1107
+ if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
1108
+ handleLinkEdit(parent, matchers, onChange);
1109
+ } else if (!$isLinkNode(parent)) {
1110
+ if (textNode.isSimpleText() && (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))) {
1111
+ const textNodesToMatch = getTextNodesToMatch(textNode);
1112
+ $handleLinkCreation(textNodesToMatch, matchers, onChange);
1113
+ }
1114
+ handleBadNeighbors(textNode, matchers, onChange);
1115
+ }
1116
+ }), editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
1117
+ const selection = lexical.$getSelection();
1118
+ if (payload !== null || !lexical.$isRangeSelection(selection)) {
1119
+ return false;
1120
+ }
1121
+ const nodes = selection.extract();
1122
+ nodes.forEach(node => {
1123
+ const parent = node.getParent();
1124
+ if ($isAutoLinkNode(parent)) {
1125
+ // invert the value
1126
+ parent.setIsUnlinked(!parent.getIsUnlinked());
1127
+ parent.markDirty();
1128
+ }
1129
+ });
1130
+ return false;
1131
+ }, lexical.COMMAND_PRIORITY_LOW));
1132
+ }
1133
+
1134
+ /**
1135
+ * An extension to automatically create AutoLinkNode from text
1136
+ * that matches the configured matchers. No default implementation
1137
+ * is provided for any matcher, see {@link createLinkMatcherWithRegExp}
1138
+ * for a helper function to create a matcher from a RegExp, and the
1139
+ * Playground's [AutoLinkPlugin](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutoLinkPlugin/index.tsx)
1140
+ * for some example RegExps that could be used.
1141
+ *
1142
+ * The given `matchers` and `changeHandlers` will be merged by
1143
+ * concatenating the configured arrays.
1144
+ */
1145
+ const AutoLinkExtension = lexical.defineExtension({
1146
+ config: defaultConfig,
1147
+ dependencies: [LinkExtension],
1148
+ mergeConfig(config, overrides) {
1149
+ const merged = lexical.shallowMergeConfig(config, overrides);
1150
+ for (const k of ['matchers', 'changeHandlers']) {
1151
+ const v = overrides[k];
1152
+ if (Array.isArray(v)) {
1153
+ merged[k] = [...config[k], ...v];
1154
+ }
1155
+ }
1156
+ return merged;
1157
+ },
1158
+ name: '@ekz/lexical-link/AutoLink',
1159
+ register: registerAutoLink
1160
+ });
1161
+
1162
+ /**
1163
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
1164
+ *
1165
+ * This source code is licensed under the MIT license found in the
1166
+ * LICENSE file in the root directory of this source tree.
1167
+ *
1168
+ */
1169
+
1170
+
1171
+ /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
1172
+ const toggleLink = $toggleLink;
1173
+
1174
+ exports.$createAutoLinkNode = $createAutoLinkNode;
1175
+ exports.$createLinkNode = $createLinkNode;
1176
+ exports.$isAutoLinkNode = $isAutoLinkNode;
1177
+ exports.$isLinkNode = $isLinkNode;
1178
+ exports.$toggleLink = $toggleLink;
1179
+ exports.AutoLinkExtension = AutoLinkExtension;
1180
+ exports.AutoLinkNode = AutoLinkNode;
1181
+ exports.ClickableLinkExtension = ClickableLinkExtension;
1182
+ exports.LinkExtension = LinkExtension;
1183
+ exports.LinkNode = LinkNode;
1184
+ exports.TOGGLE_LINK_COMMAND = TOGGLE_LINK_COMMAND;
1185
+ exports.createLinkMatcherWithRegExp = createLinkMatcherWithRegExp;
1186
+ exports.formatUrl = formatUrl;
1187
+ exports.registerAutoLink = registerAutoLink;
1188
+ exports.registerClickableLink = registerClickableLink;
1189
+ exports.registerLink = registerLink;
1190
+ exports.toggleLink = toggleLink;