@lexical/link 0.44.1-nightly.20260519.0 → 0.45.1-dev.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,964 @@
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
+ LexicalUpdateJSON,
17
+ NodeKey,
18
+ Point,
19
+ PointCaret,
20
+ PointType,
21
+ RangeSelection,
22
+ SerializedElementNode,
23
+ } from 'lexical';
24
+
25
+ import invariant from '@lexical/internal/invariant';
26
+ import {
27
+ $findMatchingParent,
28
+ $insertNodeToNearestRootAtCaret,
29
+ addClassNamesToElement,
30
+ isHTMLAnchorElement,
31
+ } from '@lexical/utils';
32
+ import {
33
+ $applyNodeReplacement,
34
+ $caretFromPoint,
35
+ $copyNode,
36
+ $getChildCaret,
37
+ $getSelection,
38
+ $isElementNode,
39
+ $isNodeSelection,
40
+ $isRangeSelection,
41
+ $isSiblingCaret,
42
+ $normalizeCaret,
43
+ $normalizeSelection__EXPERIMENTAL,
44
+ $rewindSiblingCaret,
45
+ $setPointFromCaret,
46
+ $setSelection,
47
+ createCommand,
48
+ ElementNode,
49
+ Spread,
50
+ } from 'lexical';
51
+
52
+ export type LinkAttributes = {
53
+ rel?: null | string;
54
+ target?: null | string;
55
+ title?: null | string;
56
+ };
57
+
58
+ export type AutoLinkAttributes = Partial<
59
+ Spread<LinkAttributes, {isUnlinked?: boolean}>
60
+ >;
61
+
62
+ export type SerializedLinkNode = Spread<
63
+ {
64
+ url: string;
65
+ },
66
+ Spread<LinkAttributes, SerializedElementNode>
67
+ >;
68
+
69
+ type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
70
+
71
+ const SUPPORTED_URL_PROTOCOLS = new Set([
72
+ 'http:',
73
+ 'https:',
74
+ 'mailto:',
75
+ 'sms:',
76
+ 'tel:',
77
+ ]);
78
+
79
+ /** @noInheritDoc */
80
+ export class LinkNode extends ElementNode {
81
+ /** @internal */
82
+ __url: string;
83
+ /** @internal */
84
+ __target: null | string;
85
+ /** @internal */
86
+ __rel: null | string;
87
+ /** @internal */
88
+ __title: null | string;
89
+
90
+ static getType(): string {
91
+ return 'link';
92
+ }
93
+
94
+ static clone(node: LinkNode): LinkNode {
95
+ return new LinkNode(
96
+ node.__url,
97
+ {rel: node.__rel, target: node.__target, title: node.__title},
98
+ node.__key,
99
+ );
100
+ }
101
+
102
+ constructor(
103
+ url: string = '',
104
+ attributes: LinkAttributes = {},
105
+ key?: NodeKey,
106
+ ) {
107
+ super(key);
108
+ const {target = null, rel = null, title = null} = attributes;
109
+ this.__url = url;
110
+ this.__target = target;
111
+ this.__rel = rel;
112
+ this.__title = title;
113
+ }
114
+
115
+ afterCloneFrom(prevNode: this): void {
116
+ super.afterCloneFrom(prevNode);
117
+ this.__url = prevNode.__url;
118
+ this.__rel = prevNode.__rel;
119
+ this.__target = prevNode.__target;
120
+ this.__title = prevNode.__title;
121
+ }
122
+
123
+ createDOM(config: EditorConfig): LinkHTMLElementType {
124
+ const element = document.createElement('a');
125
+ this.updateLinkDOM(null, element, config);
126
+ addClassNamesToElement(element, config.theme.link);
127
+ return element;
128
+ }
129
+
130
+ updateLinkDOM(
131
+ prevNode: this | null,
132
+ anchor: LinkHTMLElementType,
133
+ config: EditorConfig,
134
+ ) {
135
+ if (isHTMLAnchorElement(anchor)) {
136
+ if (!prevNode || prevNode.__url !== this.__url) {
137
+ anchor.href = this.sanitizeUrl(this.__url);
138
+ }
139
+ for (const attr of ['target', 'rel', 'title'] as const) {
140
+ const key = `__${attr}` as const;
141
+ const value = this[key];
142
+ if (!prevNode || prevNode[key] !== value) {
143
+ if (value) {
144
+ anchor[attr] = value;
145
+ } else {
146
+ anchor.removeAttribute(attr);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ updateDOM(
154
+ prevNode: this,
155
+ anchor: LinkHTMLElementType,
156
+ config: EditorConfig,
157
+ ): boolean {
158
+ this.updateLinkDOM(prevNode, anchor, config);
159
+ return false;
160
+ }
161
+
162
+ static importDOM(): DOMConversionMap | null {
163
+ return {
164
+ a: (node: Node) => ({
165
+ conversion: $convertAnchorElement,
166
+ priority: 1,
167
+ }),
168
+ };
169
+ }
170
+
171
+ static importJSON(serializedNode: SerializedLinkNode): LinkNode {
172
+ return $createLinkNode().updateFromJSON(serializedNode);
173
+ }
174
+
175
+ updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedLinkNode>): this {
176
+ return super
177
+ .updateFromJSON(serializedNode)
178
+ .setURL(serializedNode.url)
179
+ .setRel(serializedNode.rel || null)
180
+ .setTarget(serializedNode.target || null)
181
+ .setTitle(serializedNode.title || null);
182
+ }
183
+
184
+ sanitizeUrl(url: string): string {
185
+ url = formatUrl(url);
186
+ try {
187
+ const parsedUrl = new URL(formatUrl(url));
188
+
189
+ if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
190
+ return 'about:blank';
191
+ }
192
+ } catch {
193
+ return url;
194
+ }
195
+ return url;
196
+ }
197
+
198
+ exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
199
+ return {
200
+ ...super.exportJSON(),
201
+ rel: this.getRel(),
202
+ target: this.getTarget(),
203
+ title: this.getTitle(),
204
+ url: this.getURL(),
205
+ };
206
+ }
207
+
208
+ getURL(): string {
209
+ return this.getLatest().__url;
210
+ }
211
+
212
+ setURL(url: string): this {
213
+ const writable = this.getWritable();
214
+ writable.__url = url;
215
+ return writable;
216
+ }
217
+
218
+ getTarget(): null | string {
219
+ return this.getLatest().__target;
220
+ }
221
+
222
+ setTarget(target: null | string): this {
223
+ const writable = this.getWritable();
224
+ writable.__target = target;
225
+ return writable;
226
+ }
227
+
228
+ getRel(): null | string {
229
+ return this.getLatest().__rel;
230
+ }
231
+
232
+ setRel(rel: null | string): this {
233
+ const writable = this.getWritable();
234
+ writable.__rel = rel;
235
+ return writable;
236
+ }
237
+
238
+ getTitle(): null | string {
239
+ return this.getLatest().__title;
240
+ }
241
+
242
+ setTitle(title: null | string): this {
243
+ const writable = this.getWritable();
244
+ writable.__title = title;
245
+ return writable;
246
+ }
247
+
248
+ insertNewAfter(
249
+ _: RangeSelection,
250
+ restoreSelection = true,
251
+ ): null | ElementNode {
252
+ const linkNode = $copyNode(this);
253
+ this.insertAfter(linkNode, restoreSelection);
254
+ return linkNode;
255
+ }
256
+
257
+ canInsertTextBefore(): false {
258
+ return false;
259
+ }
260
+
261
+ canInsertTextAfter(): false {
262
+ return false;
263
+ }
264
+
265
+ canBeEmpty(): boolean {
266
+ return false;
267
+ }
268
+
269
+ isInline(): true {
270
+ return true;
271
+ }
272
+
273
+ extractWithChild(
274
+ child: LexicalNode,
275
+ selection: BaseSelection,
276
+ destination: 'clone' | 'html',
277
+ ): boolean {
278
+ if (!$isRangeSelection(selection)) {
279
+ return false;
280
+ }
281
+
282
+ const anchorNode = selection.anchor.getNode();
283
+ const focusNode = selection.focus.getNode();
284
+
285
+ return (
286
+ this.isParentOf(anchorNode) &&
287
+ this.isParentOf(focusNode) &&
288
+ selection.getTextContent().length > 0
289
+ );
290
+ }
291
+
292
+ isEmailURI(): boolean {
293
+ return this.__url.startsWith('mailto:');
294
+ }
295
+
296
+ isWebSiteURI(): boolean {
297
+ return (
298
+ this.__url.startsWith('https://') || this.__url.startsWith('http://')
299
+ );
300
+ }
301
+
302
+ shouldMergeAdjacentLink(otherLink: LinkNode): boolean {
303
+ return (
304
+ this.getType() === otherLink.getType() &&
305
+ this.__url === otherLink.__url &&
306
+ this.__target === otherLink.__target &&
307
+ this.__rel === otherLink.__rel &&
308
+ this.__title === otherLink.__title
309
+ );
310
+ }
311
+ }
312
+
313
+ type CaretPair = [PointCaret<'next'>, PointCaret<'previous'>];
314
+
315
+ function $saveCaretPair(point: PointType): CaretPair {
316
+ const next = $caretFromPoint(point, 'next');
317
+ return [next, next.getFlipped()];
318
+ }
319
+
320
+ function $restoreCaretPair(point: PointType, pair: CaretPair): void {
321
+ for (const caret of pair) {
322
+ if (caret.origin.isAttached()) {
323
+ const normalized = $normalizeCaret(caret);
324
+ $setPointFromCaret(point, normalized);
325
+ return;
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Extracts block-level children from a LinkNode, splitting
332
+ * ancestor nodes as needed to maintain a valid document structure.
333
+ * @param link - The LinkNode to normalize
334
+ */
335
+ export function $linkNodeTransform(link: LinkNode): void {
336
+ const selection = $getSelection();
337
+ let anchorPair: CaretPair | null = null;
338
+ let focusPair: CaretPair | null = null;
339
+ if ($isRangeSelection(selection)) {
340
+ anchorPair = $saveCaretPair(selection.anchor);
341
+ focusPair = $saveCaretPair(selection.focus);
342
+ }
343
+ function $restoreSelection(): void {
344
+ if ($isRangeSelection(selection)) {
345
+ $restoreCaretPair(selection.anchor, anchorPair!);
346
+ $restoreCaretPair(selection.focus, focusPair!);
347
+ $normalizeSelection__EXPERIMENTAL(selection);
348
+ }
349
+ }
350
+
351
+ let transformed = false;
352
+ for (const caret of $getChildCaret(link, 'next')) {
353
+ const node = caret.origin;
354
+ if ($isElementNode(node) && !node.isInline()) {
355
+ const blockChildren = node.getChildren();
356
+ if (blockChildren.length > 0) {
357
+ const innerLink = $copyNode(link);
358
+ innerLink.append(...blockChildren);
359
+ node.append(innerLink);
360
+ transformed = true;
361
+ }
362
+ $insertNodeToNearestRootAtCaret(node, $rewindSiblingCaret(caret), {
363
+ $shouldSplit: () => false,
364
+ });
365
+ }
366
+ }
367
+ // Fix a caret pair that points to the end of the absorbing link,
368
+ // which would shift when new children are appended during merge.
369
+ function $fixMergeBoundaryCaret(
370
+ pair: CaretPair,
371
+ absorbingLink: LinkNode,
372
+ mergingLink: LinkNode,
373
+ ): CaretPair {
374
+ const [next, prev] = pair;
375
+ const $isAffected = (caret: PointCaret) =>
376
+ $isSiblingCaret(caret) && caret.origin.is(absorbingLink);
377
+ if (!$isAffected(next) && !$isAffected(prev)) {
378
+ return pair;
379
+ }
380
+ // Resolve the merge boundary from the merging link's start, since
381
+ // those children survive the move and represent the boundary position.
382
+ const fixed = $normalizeCaret($getChildCaret(mergingLink, 'next'));
383
+ return [fixed, fixed.getFlipped()];
384
+ }
385
+
386
+ if (link.isAttached()) {
387
+ const prevSibling = link.getPreviousSibling();
388
+ if ($isLinkNode(prevSibling) && prevSibling.shouldMergeAdjacentLink(link)) {
389
+ if (anchorPair) {
390
+ anchorPair = $fixMergeBoundaryCaret(anchorPair, prevSibling, link);
391
+ }
392
+ if (focusPair) {
393
+ focusPair = $fixMergeBoundaryCaret(focusPair, prevSibling, link);
394
+ }
395
+ prevSibling.append(...link.getChildren());
396
+ link.remove();
397
+ $restoreSelection();
398
+ return;
399
+ }
400
+ const nextSibling = link.getNextSibling();
401
+ if ($isLinkNode(nextSibling) && link.shouldMergeAdjacentLink(nextSibling)) {
402
+ if (anchorPair) {
403
+ anchorPair = $fixMergeBoundaryCaret(anchorPair, link, nextSibling);
404
+ }
405
+ if (focusPair) {
406
+ focusPair = $fixMergeBoundaryCaret(focusPair, link, nextSibling);
407
+ }
408
+ link.append(...nextSibling.getChildren());
409
+ nextSibling.remove();
410
+ transformed = true;
411
+ }
412
+ }
413
+ if (!transformed) {
414
+ return;
415
+ }
416
+ if (!link.canBeEmpty() && link.isEmpty()) {
417
+ const parent = link.getParent();
418
+ link.remove();
419
+ if (parent && parent.isEmpty()) {
420
+ parent.remove();
421
+ }
422
+ }
423
+
424
+ $restoreSelection();
425
+ }
426
+
427
+ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
428
+ let node = null;
429
+ if (isHTMLAnchorElement(domNode)) {
430
+ const content = domNode.textContent;
431
+ if ((content !== null && content !== '') || domNode.children.length > 0) {
432
+ node = $createLinkNode(domNode.getAttribute('href') || '', {
433
+ rel: domNode.getAttribute('rel'),
434
+ target: domNode.getAttribute('target'),
435
+ title: domNode.getAttribute('title'),
436
+ });
437
+ }
438
+ }
439
+ return {node};
440
+ }
441
+
442
+ /**
443
+ * Takes a URL and creates a LinkNode.
444
+ * @param url - The URL the LinkNode should direct to.
445
+ * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
446
+ * @returns The LinkNode.
447
+ */
448
+ export function $createLinkNode(
449
+ url: string = '',
450
+ attributes?: LinkAttributes,
451
+ ): LinkNode {
452
+ return $applyNodeReplacement(new LinkNode(url, attributes));
453
+ }
454
+
455
+ /**
456
+ * Determines if node is a LinkNode.
457
+ * @param node - The node to be checked.
458
+ * @returns true if node is a LinkNode, false otherwise.
459
+ */
460
+ export function $isLinkNode(
461
+ node: LexicalNode | null | undefined,
462
+ ): node is LinkNode {
463
+ return node instanceof LinkNode;
464
+ }
465
+
466
+ export type SerializedAutoLinkNode = Spread<
467
+ {
468
+ isUnlinked: boolean;
469
+ },
470
+ SerializedLinkNode
471
+ >;
472
+
473
+ // Custom node type to override `canInsertTextAfter` that will
474
+ // allow typing within the link
475
+ export class AutoLinkNode extends LinkNode {
476
+ /** @internal */
477
+ /** Indicates whether the autolink was ever unlinked. **/
478
+ __isUnlinked: boolean;
479
+
480
+ constructor(
481
+ url: string = '',
482
+ attributes: AutoLinkAttributes = {},
483
+ key?: NodeKey,
484
+ ) {
485
+ super(url, attributes, key);
486
+ this.__isUnlinked =
487
+ attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
488
+ ? attributes.isUnlinked
489
+ : false;
490
+ }
491
+
492
+ afterCloneFrom(prevNode: this): void {
493
+ super.afterCloneFrom(prevNode);
494
+ this.__isUnlinked = prevNode.__isUnlinked;
495
+ }
496
+
497
+ static getType(): string {
498
+ return 'autolink';
499
+ }
500
+
501
+ static clone(node: AutoLinkNode): AutoLinkNode {
502
+ return new AutoLinkNode(
503
+ node.__url,
504
+ {
505
+ isUnlinked: node.__isUnlinked,
506
+ rel: node.__rel,
507
+ target: node.__target,
508
+ title: node.__title,
509
+ },
510
+ node.__key,
511
+ );
512
+ }
513
+
514
+ shouldMergeAdjacentLink(_otherLink: LinkNode): boolean {
515
+ return false;
516
+ }
517
+
518
+ getIsUnlinked(): boolean {
519
+ return this.__isUnlinked;
520
+ }
521
+
522
+ setIsUnlinked(value: boolean): this {
523
+ const self = this.getWritable();
524
+ self.__isUnlinked = value;
525
+ return self;
526
+ }
527
+
528
+ createDOM(config: EditorConfig): LinkHTMLElementType {
529
+ if (this.__isUnlinked) {
530
+ return document.createElement('span');
531
+ } else {
532
+ return super.createDOM(config);
533
+ }
534
+ }
535
+
536
+ updateDOM(
537
+ prevNode: this,
538
+ anchor: LinkHTMLElementType,
539
+ config: EditorConfig,
540
+ ): boolean {
541
+ return (
542
+ super.updateDOM(prevNode, anchor, config) ||
543
+ prevNode.__isUnlinked !== this.__isUnlinked
544
+ );
545
+ }
546
+
547
+ static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
548
+ return $createAutoLinkNode().updateFromJSON(serializedNode);
549
+ }
550
+
551
+ updateFromJSON(
552
+ serializedNode: LexicalUpdateJSON<SerializedAutoLinkNode>,
553
+ ): this {
554
+ return super
555
+ .updateFromJSON(serializedNode)
556
+ .setIsUnlinked(serializedNode.isUnlinked || false);
557
+ }
558
+
559
+ static importDOM(): null {
560
+ // TODO: Should link node should handle the import over autolink?
561
+ return null;
562
+ }
563
+
564
+ exportJSON(): SerializedAutoLinkNode {
565
+ return {
566
+ ...super.exportJSON(),
567
+ isUnlinked: this.__isUnlinked,
568
+ };
569
+ }
570
+
571
+ insertNewAfter(
572
+ _: RangeSelection,
573
+ restoreSelection = true,
574
+ ): null | ElementNode {
575
+ const linkNode = $createAutoLinkNode(this.__url, {
576
+ isUnlinked: this.__isUnlinked,
577
+ rel: this.__rel,
578
+ target: this.__target,
579
+ title: this.__title,
580
+ });
581
+ this.insertAfter(linkNode, restoreSelection);
582
+ return linkNode;
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
588
+ * during typing, which is especially useful when a button to generate a LinkNode is not practical.
589
+ * @param url - The URL the LinkNode should direct to.
590
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
591
+ * @returns The LinkNode.
592
+ */
593
+ export function $createAutoLinkNode(
594
+ url: string = '',
595
+ attributes?: AutoLinkAttributes,
596
+ ): AutoLinkNode {
597
+ return $applyNodeReplacement(new AutoLinkNode(url, attributes));
598
+ }
599
+
600
+ /**
601
+ * Determines if node is an AutoLinkNode.
602
+ * @param node - The node to be checked.
603
+ * @returns true if node is an AutoLinkNode, false otherwise.
604
+ */
605
+ export function $isAutoLinkNode(
606
+ node: LexicalNode | null | undefined,
607
+ ): node is AutoLinkNode {
608
+ return node instanceof AutoLinkNode;
609
+ }
610
+
611
+ export const TOGGLE_LINK_COMMAND: LexicalCommand<
612
+ string | ({url: string} & LinkAttributes) | null
613
+ > = createCommand('TOGGLE_LINK_COMMAND');
614
+
615
+ function $getPointNode(point: Point, offset: number): LexicalNode | null {
616
+ if (point.type === 'element') {
617
+ const node = point.getNode();
618
+ invariant(
619
+ $isElementNode(node),
620
+ '$getPointNode: element point is not an ElementNode',
621
+ );
622
+ const childNode = node.getChildren()[point.offset + offset];
623
+ return childNode || null;
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /**
629
+ * Preserve the logical start/end of a RangeSelection in situations where
630
+ * the point is an element that may be reparented in the callback.
631
+ *
632
+ * @param $fn The function to run
633
+ * @returns The result of the callback
634
+ */
635
+ function $withSelectedNodes<T>($fn: () => T): T {
636
+ const initialSelection = $getSelection();
637
+ if (!$isRangeSelection(initialSelection)) {
638
+ return $fn();
639
+ }
640
+ const normalized = $normalizeSelection__EXPERIMENTAL(initialSelection);
641
+ const isBackwards = normalized.isBackward();
642
+ const anchorNode = $getPointNode(normalized.anchor, isBackwards ? -1 : 0);
643
+ const focusNode = $getPointNode(normalized.focus, isBackwards ? 0 : -1);
644
+ const rval = $fn();
645
+ if (anchorNode || focusNode) {
646
+ const updatedSelection = $getSelection();
647
+ if ($isRangeSelection(updatedSelection)) {
648
+ const finalSelection = updatedSelection.clone();
649
+ if (anchorNode) {
650
+ const anchorParent = anchorNode.getParent();
651
+ if (anchorParent) {
652
+ finalSelection.anchor.set(
653
+ anchorParent.getKey(),
654
+ anchorNode.getIndexWithinParent() + (isBackwards ? 1 : 0),
655
+ 'element',
656
+ );
657
+ }
658
+ }
659
+ if (focusNode) {
660
+ const focusParent = focusNode.getParent();
661
+ if (focusParent) {
662
+ finalSelection.focus.set(
663
+ focusParent.getKey(),
664
+ focusNode.getIndexWithinParent() + (isBackwards ? 0 : 1),
665
+ 'element',
666
+ );
667
+ }
668
+ }
669
+ $setSelection($normalizeSelection__EXPERIMENTAL(finalSelection));
670
+ }
671
+ }
672
+ return rval;
673
+ }
674
+
675
+ /**
676
+ * Splits a LinkNode by removing selected children from it.
677
+ * Handles three cases: selection at start, end, or middle of the link.
678
+ * @param parentLink - The LinkNode to split
679
+ * @param extractedNodes - The nodes that were extracted from the selection
680
+ */
681
+ function $splitLinkAtSelection(
682
+ parentLink: LinkNode,
683
+ extractedNodes: LexicalNode[],
684
+ ): void {
685
+ const extractedKeys = new Set(
686
+ extractedNodes.filter(n => parentLink.isParentOf(n)).map(n => n.getKey()),
687
+ );
688
+
689
+ const allChildren = parentLink.getChildren();
690
+ // Check if a child is an extracted node OR contains an extracted node
691
+ // This handles nested structures like LinkNode > HeadingNode > TextNode
692
+ const isExtractedChild = (child: LexicalNode): boolean =>
693
+ extractedKeys.has(child.getKey()) ||
694
+ ($isElementNode(child) &&
695
+ extractedNodes.some(
696
+ n => parentLink.isParentOf(n) && child.isParentOf(n),
697
+ ));
698
+
699
+ const extractedChildren = allChildren.filter(isExtractedChild);
700
+
701
+ if (extractedChildren.length === allChildren.length) {
702
+ allChildren.forEach(child => parentLink.insertBefore(child));
703
+ parentLink.remove();
704
+ return;
705
+ }
706
+
707
+ const firstExtractedIndex = allChildren.findIndex(isExtractedChild);
708
+ const lastExtractedIndex = allChildren.findLastIndex(isExtractedChild);
709
+
710
+ const isAtStart = firstExtractedIndex === 0;
711
+ const isAtEnd = lastExtractedIndex === allChildren.length - 1;
712
+
713
+ if (isAtStart) {
714
+ extractedChildren.forEach(child => parentLink.insertBefore(child));
715
+ } else if (isAtEnd) {
716
+ for (let i = extractedChildren.length - 1; i >= 0; i--) {
717
+ parentLink.insertAfter(extractedChildren[i]);
718
+ }
719
+ } else {
720
+ for (let i = extractedChildren.length - 1; i >= 0; i--) {
721
+ parentLink.insertAfter(extractedChildren[i]);
722
+ }
723
+
724
+ const trailingChildren = allChildren.slice(lastExtractedIndex + 1);
725
+ if (trailingChildren.length > 0) {
726
+ const newLink = $copyNode(parentLink);
727
+
728
+ extractedChildren[extractedChildren.length - 1].insertAfter(newLink);
729
+ trailingChildren.forEach(child => newLink.append(child));
730
+ }
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
736
+ * but saves any children and brings them up to the parent node.
737
+ * @param urlOrAttributes - The URL the link directs to, or an attributes object with an url property
738
+ * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
739
+ */
740
+ export function $toggleLink(
741
+ urlOrAttributes: null | string | (LinkAttributes & {url: null | string}),
742
+ attributes: LinkAttributes = {},
743
+ ): void {
744
+ let url: null | string;
745
+ if (urlOrAttributes && typeof urlOrAttributes === 'object') {
746
+ const {url: urlProp, ...rest} = urlOrAttributes;
747
+ url = urlProp;
748
+ attributes = {...rest, ...attributes};
749
+ } else {
750
+ url = urlOrAttributes;
751
+ }
752
+ const {target, title} = attributes;
753
+ const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
754
+ const selection = $getSelection();
755
+
756
+ if (
757
+ selection === null ||
758
+ (!$isRangeSelection(selection) && !$isNodeSelection(selection))
759
+ ) {
760
+ return;
761
+ }
762
+
763
+ if ($isNodeSelection(selection)) {
764
+ const nodes = selection.getNodes();
765
+ if (nodes.length === 0) {
766
+ return;
767
+ }
768
+
769
+ // Handle all selected nodes
770
+ nodes.forEach(node => {
771
+ if (url === null) {
772
+ // Remove link
773
+ const linkParent = $findMatchingParent(
774
+ node,
775
+ (parent): parent is LinkNode =>
776
+ !$isAutoLinkNode(parent) && $isLinkNode(parent),
777
+ );
778
+ if (linkParent) {
779
+ linkParent.insertBefore(node);
780
+ if (linkParent.getChildren().length === 0) {
781
+ linkParent.remove();
782
+ }
783
+ }
784
+ } else {
785
+ // Add/Update link
786
+ const existingLink = $findMatchingParent(
787
+ node,
788
+ (parent): parent is LinkNode =>
789
+ !$isAutoLinkNode(parent) && $isLinkNode(parent),
790
+ );
791
+ if (existingLink) {
792
+ existingLink.setURL(url);
793
+ if (target !== undefined) {
794
+ existingLink.setTarget(target);
795
+ }
796
+ if (rel !== undefined) {
797
+ existingLink.setRel(rel);
798
+ }
799
+ } else {
800
+ const linkNode = $createLinkNode(url, {rel, target});
801
+ node.insertBefore(linkNode);
802
+ linkNode.append(node);
803
+ }
804
+ }
805
+ });
806
+ return;
807
+ }
808
+
809
+ if (selection.isCollapsed() && url === null) {
810
+ for (const node of selection.getNodes()) {
811
+ const parentLink = $findMatchingParent(
812
+ node,
813
+ (parent): parent is LinkNode =>
814
+ !$isAutoLinkNode(parent) && $isLinkNode(parent),
815
+ );
816
+ if (parentLink !== null) {
817
+ parentLink
818
+ .getParentOrThrow()
819
+ .splice(
820
+ parentLink.getIndexWithinParent(),
821
+ 0,
822
+ parentLink.getChildren(),
823
+ );
824
+ parentLink.remove();
825
+ }
826
+ return;
827
+ }
828
+ }
829
+
830
+ // Handle RangeSelection
831
+ const nodes = selection.extract();
832
+
833
+ if (url === null) {
834
+ const processedLinks = new Set<NodeKey>();
835
+
836
+ nodes.forEach(node => {
837
+ const parentLink = $findMatchingParent(
838
+ node,
839
+ (parent): parent is LinkNode =>
840
+ !$isAutoLinkNode(parent) && $isLinkNode(parent),
841
+ );
842
+
843
+ if (parentLink !== null) {
844
+ const linkKey = parentLink.getKey();
845
+
846
+ if (processedLinks.has(linkKey)) {
847
+ return;
848
+ }
849
+
850
+ $splitLinkAtSelection(parentLink, nodes);
851
+ processedLinks.add(linkKey);
852
+ }
853
+ });
854
+ return;
855
+ }
856
+ const updatedNodes = new Set<NodeKey>();
857
+ const updateLinkNode = (linkNode: LinkNode) => {
858
+ if (updatedNodes.has(linkNode.getKey())) {
859
+ return;
860
+ }
861
+ updatedNodes.add(linkNode.getKey());
862
+ linkNode.setURL(url);
863
+ if (target !== undefined) {
864
+ linkNode.setTarget(target);
865
+ }
866
+ if (rel !== undefined) {
867
+ linkNode.setRel(rel);
868
+ }
869
+ if (title !== undefined) {
870
+ linkNode.setTitle(title);
871
+ }
872
+ };
873
+ // Add or merge LinkNodes
874
+ if (nodes.length === 1) {
875
+ const firstNode = nodes[0];
876
+ // if the first node is a LinkNode or if its
877
+ // parent is a LinkNode, we update the URL, target and rel.
878
+ const linkNode = $findMatchingParent(firstNode, $isLinkNode);
879
+ if (linkNode !== null) {
880
+ return updateLinkNode(linkNode);
881
+ }
882
+ }
883
+
884
+ $withSelectedNodes(() => {
885
+ let linkNode: LinkNode | null = null;
886
+ for (const node of nodes) {
887
+ if (!node.isAttached()) {
888
+ continue;
889
+ }
890
+ const parentLinkNode = $findMatchingParent(node, $isLinkNode);
891
+ if (parentLinkNode) {
892
+ updateLinkNode(parentLinkNode);
893
+ continue;
894
+ }
895
+ if ($isElementNode(node)) {
896
+ if (!node.isInline()) {
897
+ // Ignore block nodes, if there are any children we will see them
898
+ // later and wrap in a new LinkNode
899
+ continue;
900
+ }
901
+ if ($isLinkNode(node)) {
902
+ // If it's not an autolink node and we don't already have a LinkNode
903
+ // in this block then we can update it and re-use it
904
+ if (
905
+ !$isAutoLinkNode(node) &&
906
+ (linkNode === null || !linkNode.getParentOrThrow().isParentOf(node))
907
+ ) {
908
+ updateLinkNode(node);
909
+ linkNode = node;
910
+ continue;
911
+ }
912
+ // Unwrap LinkNode, we already have one or it's an AutoLinkNode
913
+ for (const child of node.getChildren()) {
914
+ node.insertBefore(child);
915
+ }
916
+ node.remove();
917
+ continue;
918
+ }
919
+ }
920
+ const prevLinkNode = node.getPreviousSibling();
921
+ if ($isLinkNode(prevLinkNode) && prevLinkNode.is(linkNode)) {
922
+ prevLinkNode.append(node);
923
+ continue;
924
+ }
925
+ linkNode = $createLinkNode(url, {rel, target, title});
926
+ node.insertAfter(linkNode);
927
+ linkNode.append(node);
928
+ }
929
+ });
930
+ }
931
+
932
+ const PHONE_NUMBER_REGEX = /^\+?[0-9\s()-]{5,}$/;
933
+
934
+ /**
935
+ * Formats a URL string by adding appropriate protocol if missing
936
+ *
937
+ * @param url - URL to format
938
+ * @returns Formatted URL with appropriate protocol
939
+ */
940
+ export function formatUrl(url: string): string {
941
+ // Check if URL already has a protocol
942
+ if (url.match(/^[a-z][a-z0-9+.-]*:/i)) {
943
+ // URL already has a protocol, leave it as is
944
+ return url;
945
+ }
946
+ // Check if it's a relative path (starting with '/', '.', or '#')
947
+ else if (url.match(/^[/#.]/)) {
948
+ // Relative path, leave it as is
949
+ return url;
950
+ }
951
+
952
+ // Check for email address
953
+ else if (url.includes('@')) {
954
+ return `mailto:${url}`;
955
+ }
956
+
957
+ // Check for phone number
958
+ else if (PHONE_NUMBER_REGEX.test(url)) {
959
+ return `tel:${url}`;
960
+ }
961
+
962
+ // For everything else, return with https:// prefix
963
+ return `https://${url}`;
964
+ }