@lexical/link 0.35.1-nightly.20250924.0 → 0.36.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.
- package/ClickableLinkExtension.d.ts +22 -0
- package/LexicalAutoLinkExtension.d.ts +41 -0
- package/LexicalLink.dev.js +585 -9
- package/LexicalLink.dev.mjs +581 -12
- package/LexicalLink.js.flow +69 -0
- package/LexicalLink.mjs +7 -0
- package/LexicalLink.node.mjs +7 -0
- package/LexicalLink.prod.js +1 -1
- package/LexicalLink.prod.mjs +1 -1
- package/LexicalLinkExtension.d.ts +37 -0
- package/LexicalLinkNode.d.ts +126 -0
- package/index.d.ts +5 -117
- package/package.json +4 -3
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
import { NamedSignalsOutput } from '@lexical/extension';
|
|
9
|
+
import { LexicalEditor } from 'lexical';
|
|
10
|
+
export interface ClickableLinkConfig {
|
|
11
|
+
/** Open clicked links in a new tab when true (default false) */
|
|
12
|
+
newTab: boolean;
|
|
13
|
+
/** Disable this extension when true (default false) */
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function registerClickableLink(editor: LexicalEditor, stores: NamedSignalsOutput<ClickableLinkConfig>, eventOptions?: Pick<AddEventListenerOptions, 'signal'>): () => void;
|
|
17
|
+
/**
|
|
18
|
+
* Normally in a Lexical editor the `CLICK_COMMAND` on a LinkNode will cause the
|
|
19
|
+
* selection to change instead of opening a link. This extension can be used to
|
|
20
|
+
* restore the default behavior, e.g. when the editor is not editable.
|
|
21
|
+
*/
|
|
22
|
+
export declare const ClickableLinkExtension: import("lexical").LexicalExtension<ClickableLinkConfig, "@lexical/link/ClickableLink", NamedSignalsOutput<ClickableLinkConfig>, unknown>;
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import type { LexicalEditor } from 'lexical';
|
|
9
|
+
import { type AutoLinkAttributes } from './LexicalLinkNode';
|
|
10
|
+
export type ChangeHandler = (url: string | null, prevUrl: string | null) => void;
|
|
11
|
+
export interface LinkMatcherResult {
|
|
12
|
+
attributes?: AutoLinkAttributes;
|
|
13
|
+
index: number;
|
|
14
|
+
length: number;
|
|
15
|
+
text: string;
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
export type LinkMatcher = (text: string) => LinkMatcherResult | null;
|
|
19
|
+
export declare function createLinkMatcherWithRegExp(regExp: RegExp, urlTransformer?: (text: string) => string): (text: string) => {
|
|
20
|
+
index: number;
|
|
21
|
+
length: number;
|
|
22
|
+
text: string;
|
|
23
|
+
url: string;
|
|
24
|
+
} | null;
|
|
25
|
+
export interface AutoLinkConfig {
|
|
26
|
+
matchers: LinkMatcher[];
|
|
27
|
+
changeHandlers: ChangeHandler[];
|
|
28
|
+
}
|
|
29
|
+
export declare function registerAutoLink(editor: LexicalEditor, config?: AutoLinkConfig): () => void;
|
|
30
|
+
/**
|
|
31
|
+
* An extension to automatically create AutoLinkNode from text
|
|
32
|
+
* that matches the configured matchers. No default implementation
|
|
33
|
+
* is provided for any matcher, see {@link createLinkMatcherWithRegExp}
|
|
34
|
+
* for a helper function to create a matcher from a RegExp, and the
|
|
35
|
+
* Playground's [AutoLinkPlugin](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutoLinkPlugin/index.tsx)
|
|
36
|
+
* for some example RegExps that could be used.
|
|
37
|
+
*
|
|
38
|
+
* The given `matchers` and `changeHandlers` will be merged by
|
|
39
|
+
* concatenating the configured arrays.
|
|
40
|
+
*/
|
|
41
|
+
export declare const AutoLinkExtension: import("lexical").LexicalExtension<AutoLinkConfig, "@lexical/link/AutoLink", unknown, unknown>;
|
package/LexicalLink.dev.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
var utils = require('@lexical/utils');
|
|
12
12
|
var lexical = require('lexical');
|
|
13
|
+
var extension = require('@lexical/extension');
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
@@ -30,13 +31,13 @@ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', '
|
|
|
30
31
|
/** @noInheritDoc */
|
|
31
32
|
class LinkNode extends lexical.ElementNode {
|
|
32
33
|
/** @internal */
|
|
33
|
-
|
|
34
|
+
__url;
|
|
34
35
|
/** @internal */
|
|
35
|
-
|
|
36
|
+
__target;
|
|
36
37
|
/** @internal */
|
|
37
|
-
|
|
38
|
+
__rel;
|
|
38
39
|
/** @internal */
|
|
39
|
-
|
|
40
|
+
__title;
|
|
40
41
|
static getType() {
|
|
41
42
|
return 'link';
|
|
42
43
|
}
|
|
@@ -231,7 +232,7 @@ function $isLinkNode(node) {
|
|
|
231
232
|
class AutoLinkNode extends LinkNode {
|
|
232
233
|
/** @internal */
|
|
233
234
|
/** Indicates whether the autolink was ever unlinked. **/
|
|
234
|
-
|
|
235
|
+
__isUnlinked;
|
|
235
236
|
constructor(url = '', attributes = {}, key) {
|
|
236
237
|
super(url, attributes, key);
|
|
237
238
|
this.__isUnlinked = attributes.isUnlinked !== undefined && attributes.isUnlinked !== null ? attributes.isUnlinked : false;
|
|
@@ -371,10 +372,24 @@ function $withSelectedNodes($fn) {
|
|
|
371
372
|
/**
|
|
372
373
|
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
|
|
373
374
|
* but saves any children and brings them up to the parent node.
|
|
374
|
-
* @param
|
|
375
|
+
* @param urlOrAttributes - The URL the link directs to, or an attributes object with an url property
|
|
375
376
|
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
|
|
376
377
|
*/
|
|
377
|
-
function $toggleLink(
|
|
378
|
+
function $toggleLink(urlOrAttributes, attributes = {}) {
|
|
379
|
+
let url;
|
|
380
|
+
if (urlOrAttributes && typeof urlOrAttributes === 'object') {
|
|
381
|
+
const {
|
|
382
|
+
url: urlProp,
|
|
383
|
+
...rest
|
|
384
|
+
} = urlOrAttributes;
|
|
385
|
+
url = urlProp;
|
|
386
|
+
attributes = {
|
|
387
|
+
...rest,
|
|
388
|
+
...attributes
|
|
389
|
+
};
|
|
390
|
+
} else {
|
|
391
|
+
url = urlOrAttributes;
|
|
392
|
+
}
|
|
378
393
|
const {
|
|
379
394
|
target,
|
|
380
395
|
title
|
|
@@ -516,8 +531,6 @@ function $toggleLink(url, attributes = {}) {
|
|
|
516
531
|
}
|
|
517
532
|
});
|
|
518
533
|
}
|
|
519
|
-
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
|
|
520
|
-
const toggleLink = $toggleLink;
|
|
521
534
|
const PHONE_NUMBER_REGEX = /^\+?[0-9\s()-]{5,}$/;
|
|
522
535
|
|
|
523
536
|
/**
|
|
@@ -552,13 +565,576 @@ function formatUrl(url) {
|
|
|
552
565
|
return `https://${url}`;
|
|
553
566
|
}
|
|
554
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
570
|
+
*
|
|
571
|
+
* This source code is licensed under the MIT license found in the
|
|
572
|
+
* LICENSE file in the root directory of this source tree.
|
|
573
|
+
*
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
const defaultProps = {
|
|
577
|
+
attributes: undefined,
|
|
578
|
+
validateUrl: undefined
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/** @internal */
|
|
582
|
+
function registerLink(editor, stores) {
|
|
583
|
+
return utils.mergeRegister(extension.effect(() => editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
|
|
584
|
+
const validateUrl = stores.validateUrl.peek();
|
|
585
|
+
const attributes = stores.attributes.peek();
|
|
586
|
+
if (payload === null) {
|
|
587
|
+
$toggleLink(null);
|
|
588
|
+
return true;
|
|
589
|
+
} else if (typeof payload === 'string') {
|
|
590
|
+
if (validateUrl === undefined || validateUrl(payload)) {
|
|
591
|
+
$toggleLink(payload, attributes);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
} else {
|
|
596
|
+
const {
|
|
597
|
+
url,
|
|
598
|
+
target,
|
|
599
|
+
rel,
|
|
600
|
+
title
|
|
601
|
+
} = payload;
|
|
602
|
+
$toggleLink(url, {
|
|
603
|
+
...attributes,
|
|
604
|
+
rel,
|
|
605
|
+
target,
|
|
606
|
+
title
|
|
607
|
+
});
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
}, lexical.COMMAND_PRIORITY_LOW)), extension.effect(() => {
|
|
611
|
+
const validateUrl = stores.validateUrl.value;
|
|
612
|
+
if (!validateUrl) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const attributes = stores.attributes.value;
|
|
616
|
+
return editor.registerCommand(lexical.PASTE_COMMAND, event => {
|
|
617
|
+
const selection = lexical.$getSelection();
|
|
618
|
+
if (!lexical.$isRangeSelection(selection) || selection.isCollapsed() || !utils.objectKlassEquals(event, ClipboardEvent)) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
if (event.clipboardData === null) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
const clipboardText = event.clipboardData.getData('text');
|
|
625
|
+
if (!validateUrl(clipboardText)) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
// If we select nodes that are elements then avoid applying the link.
|
|
629
|
+
if (!selection.getNodes().some(node => lexical.$isElementNode(node))) {
|
|
630
|
+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
|
|
631
|
+
...attributes,
|
|
632
|
+
url: clipboardText
|
|
633
|
+
});
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
}, lexical.COMMAND_PRIORITY_LOW);
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Provides {@link LinkNode}, an implementation of
|
|
644
|
+
* {@link TOGGLE_LINK_COMMAND}, and a {@link PASTE_COMMAND}
|
|
645
|
+
* listener to wrap selected nodes in a link when a
|
|
646
|
+
* URL is pasted and `validateUrl` is defined.
|
|
647
|
+
*/
|
|
648
|
+
const LinkExtension = lexical.defineExtension({
|
|
649
|
+
build(editor, config, state) {
|
|
650
|
+
return extension.namedSignals(config);
|
|
651
|
+
},
|
|
652
|
+
config: defaultProps,
|
|
653
|
+
name: '@lexical/link/Link',
|
|
654
|
+
nodes: [LinkNode],
|
|
655
|
+
register(editor, config, state) {
|
|
656
|
+
return registerLink(editor, state.getOutput());
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
662
|
+
*
|
|
663
|
+
* This source code is licensed under the MIT license found in the
|
|
664
|
+
* LICENSE file in the root directory of this source tree.
|
|
665
|
+
*
|
|
666
|
+
*/
|
|
667
|
+
|
|
668
|
+
function findMatchingDOM(startNode, predicate) {
|
|
669
|
+
let node = startNode;
|
|
670
|
+
while (node != null) {
|
|
671
|
+
if (predicate(node)) {
|
|
672
|
+
return node;
|
|
673
|
+
}
|
|
674
|
+
node = node.parentNode;
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
function registerClickableLink(editor, stores, eventOptions = {}) {
|
|
679
|
+
const onClick = event => {
|
|
680
|
+
const target = event.target;
|
|
681
|
+
if (!lexical.isDOMNode(target)) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const nearestEditor = lexical.getNearestEditorFromDOMNode(target);
|
|
685
|
+
if (nearestEditor === null) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
let url = null;
|
|
689
|
+
let urlTarget = null;
|
|
690
|
+
nearestEditor.update(() => {
|
|
691
|
+
const clickedNode = lexical.$getNearestNodeFromDOMNode(target);
|
|
692
|
+
if (clickedNode !== null) {
|
|
693
|
+
const maybeLinkNode = utils.$findMatchingParent(clickedNode, lexical.$isElementNode);
|
|
694
|
+
if (!stores.disabled.peek()) {
|
|
695
|
+
if ($isLinkNode(maybeLinkNode)) {
|
|
696
|
+
url = maybeLinkNode.sanitizeUrl(maybeLinkNode.getURL());
|
|
697
|
+
urlTarget = maybeLinkNode.getTarget();
|
|
698
|
+
} else {
|
|
699
|
+
const a = findMatchingDOM(target, utils.isHTMLAnchorElement);
|
|
700
|
+
if (a !== null) {
|
|
701
|
+
url = a.href;
|
|
702
|
+
urlTarget = a.target;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
if (url === null || url === '') {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Allow user to select link text without following url
|
|
713
|
+
const selection = editor.getEditorState().read(lexical.$getSelection);
|
|
714
|
+
if (lexical.$isRangeSelection(selection) && !selection.isCollapsed()) {
|
|
715
|
+
event.preventDefault();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const isMiddle = event.type === 'auxclick' && event.button === 1;
|
|
719
|
+
window.open(url, stores.newTab.peek() || isMiddle || event.metaKey || event.ctrlKey || urlTarget === '_blank' ? '_blank' : '_self');
|
|
720
|
+
event.preventDefault();
|
|
721
|
+
};
|
|
722
|
+
const onMouseUp = event => {
|
|
723
|
+
if (event.button === 1) {
|
|
724
|
+
onClick(event);
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
return editor.registerRootListener((rootElement, prevRootElement) => {
|
|
728
|
+
if (prevRootElement !== null) {
|
|
729
|
+
prevRootElement.removeEventListener('click', onClick);
|
|
730
|
+
prevRootElement.removeEventListener('mouseup', onMouseUp);
|
|
731
|
+
}
|
|
732
|
+
if (rootElement !== null) {
|
|
733
|
+
rootElement.addEventListener('click', onClick, eventOptions);
|
|
734
|
+
rootElement.addEventListener('mouseup', onMouseUp, eventOptions);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Normally in a Lexical editor the `CLICK_COMMAND` on a LinkNode will cause the
|
|
741
|
+
* selection to change instead of opening a link. This extension can be used to
|
|
742
|
+
* restore the default behavior, e.g. when the editor is not editable.
|
|
743
|
+
*/
|
|
744
|
+
const ClickableLinkExtension = lexical.defineExtension({
|
|
745
|
+
build(editor, config, state) {
|
|
746
|
+
return extension.namedSignals(config);
|
|
747
|
+
},
|
|
748
|
+
config: lexical.safeCast({
|
|
749
|
+
disabled: false,
|
|
750
|
+
newTab: false
|
|
751
|
+
}),
|
|
752
|
+
dependencies: [LinkExtension],
|
|
753
|
+
name: '@lexical/link/ClickableLink',
|
|
754
|
+
register(editor, config, state) {
|
|
755
|
+
return registerClickableLink(editor, state.getOutput());
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
761
|
+
*
|
|
762
|
+
* This source code is licensed under the MIT license found in the
|
|
763
|
+
* LICENSE file in the root directory of this source tree.
|
|
764
|
+
*
|
|
765
|
+
*/
|
|
766
|
+
|
|
767
|
+
function createLinkMatcherWithRegExp(regExp, urlTransformer = text => text) {
|
|
768
|
+
return text => {
|
|
769
|
+
const match = regExp.exec(text);
|
|
770
|
+
if (match === null) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
index: match.index,
|
|
775
|
+
length: match[0].length,
|
|
776
|
+
text: match[0],
|
|
777
|
+
url: urlTransformer(match[0])
|
|
778
|
+
};
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
function findFirstMatch(text, matchers) {
|
|
782
|
+
for (let i = 0; i < matchers.length; i++) {
|
|
783
|
+
const match = matchers[i](text);
|
|
784
|
+
if (match) {
|
|
785
|
+
return match;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
const PUNCTUATION_OR_SPACE = /[.,;\s]/;
|
|
791
|
+
function isSeparator(char) {
|
|
792
|
+
return PUNCTUATION_OR_SPACE.test(char);
|
|
793
|
+
}
|
|
794
|
+
function endsWithSeparator(textContent) {
|
|
795
|
+
return isSeparator(textContent[textContent.length - 1]);
|
|
796
|
+
}
|
|
797
|
+
function startsWithSeparator(textContent) {
|
|
798
|
+
return isSeparator(textContent[0]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Check if the text content starts with a fullstop followed by a top-level domain.
|
|
803
|
+
* Meaning if the text content can be a beginning of a top level domain.
|
|
804
|
+
* @param textContent
|
|
805
|
+
* @param isEmail
|
|
806
|
+
* @returns boolean
|
|
807
|
+
*/
|
|
808
|
+
function startsWithTLD(textContent, isEmail) {
|
|
809
|
+
if (isEmail) {
|
|
810
|
+
return /^\.[a-zA-Z]{2,}/.test(textContent);
|
|
811
|
+
} else {
|
|
812
|
+
return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function isPreviousNodeValid(node) {
|
|
816
|
+
let previousNode = node.getPreviousSibling();
|
|
817
|
+
if (lexical.$isElementNode(previousNode)) {
|
|
818
|
+
previousNode = previousNode.getLastDescendant();
|
|
819
|
+
}
|
|
820
|
+
return previousNode === null || lexical.$isLineBreakNode(previousNode) || lexical.$isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent());
|
|
821
|
+
}
|
|
822
|
+
function isNextNodeValid(node) {
|
|
823
|
+
let nextNode = node.getNextSibling();
|
|
824
|
+
if (lexical.$isElementNode(nextNode)) {
|
|
825
|
+
nextNode = nextNode.getFirstDescendant();
|
|
826
|
+
}
|
|
827
|
+
return nextNode === null || lexical.$isLineBreakNode(nextNode) || lexical.$isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent());
|
|
828
|
+
}
|
|
829
|
+
function isContentAroundIsValid(matchStart, matchEnd, text, nodes) {
|
|
830
|
+
const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0]);
|
|
831
|
+
if (!contentBeforeIsValid) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
const contentAfterIsValid = matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1]);
|
|
835
|
+
return contentAfterIsValid;
|
|
836
|
+
}
|
|
837
|
+
function extractMatchingNodes(nodes, startIndex, endIndex) {
|
|
838
|
+
const unmodifiedBeforeNodes = [];
|
|
839
|
+
const matchingNodes = [];
|
|
840
|
+
const unmodifiedAfterNodes = [];
|
|
841
|
+
let matchingOffset = 0;
|
|
842
|
+
let currentOffset = 0;
|
|
843
|
+
const currentNodes = [...nodes];
|
|
844
|
+
while (currentNodes.length > 0) {
|
|
845
|
+
const currentNode = currentNodes[0];
|
|
846
|
+
const currentNodeText = currentNode.getTextContent();
|
|
847
|
+
const currentNodeLength = currentNodeText.length;
|
|
848
|
+
const currentNodeStart = currentOffset;
|
|
849
|
+
const currentNodeEnd = currentOffset + currentNodeLength;
|
|
850
|
+
if (currentNodeEnd <= startIndex) {
|
|
851
|
+
unmodifiedBeforeNodes.push(currentNode);
|
|
852
|
+
matchingOffset += currentNodeLength;
|
|
853
|
+
} else if (currentNodeStart >= endIndex) {
|
|
854
|
+
unmodifiedAfterNodes.push(currentNode);
|
|
855
|
+
} else {
|
|
856
|
+
matchingNodes.push(currentNode);
|
|
857
|
+
}
|
|
858
|
+
currentOffset += currentNodeLength;
|
|
859
|
+
currentNodes.shift();
|
|
860
|
+
}
|
|
861
|
+
return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes];
|
|
862
|
+
}
|
|
863
|
+
function $createAutoLinkNode_(nodes, startIndex, endIndex, match) {
|
|
864
|
+
const linkNode = $createAutoLinkNode(match.url, match.attributes);
|
|
865
|
+
if (nodes.length === 1) {
|
|
866
|
+
let remainingTextNode = nodes[0];
|
|
867
|
+
let linkTextNode;
|
|
868
|
+
if (startIndex === 0) {
|
|
869
|
+
[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex);
|
|
870
|
+
} else {
|
|
871
|
+
[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex);
|
|
872
|
+
}
|
|
873
|
+
const textNode = lexical.$createTextNode(match.text);
|
|
874
|
+
textNode.setFormat(linkTextNode.getFormat());
|
|
875
|
+
textNode.setDetail(linkTextNode.getDetail());
|
|
876
|
+
textNode.setStyle(linkTextNode.getStyle());
|
|
877
|
+
linkNode.append(textNode);
|
|
878
|
+
linkTextNode.replace(linkNode);
|
|
879
|
+
return remainingTextNode;
|
|
880
|
+
} else if (nodes.length > 1) {
|
|
881
|
+
const firstTextNode = nodes[0];
|
|
882
|
+
let offset = firstTextNode.getTextContent().length;
|
|
883
|
+
let firstLinkTextNode;
|
|
884
|
+
if (startIndex === 0) {
|
|
885
|
+
firstLinkTextNode = firstTextNode;
|
|
886
|
+
} else {
|
|
887
|
+
[, firstLinkTextNode] = firstTextNode.splitText(startIndex);
|
|
888
|
+
}
|
|
889
|
+
const linkNodes = [];
|
|
890
|
+
let remainingTextNode;
|
|
891
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
892
|
+
const currentNode = nodes[i];
|
|
893
|
+
const currentNodeText = currentNode.getTextContent();
|
|
894
|
+
const currentNodeLength = currentNodeText.length;
|
|
895
|
+
const currentNodeStart = offset;
|
|
896
|
+
const currentNodeEnd = offset + currentNodeLength;
|
|
897
|
+
if (currentNodeStart < endIndex) {
|
|
898
|
+
if (currentNodeEnd <= endIndex) {
|
|
899
|
+
linkNodes.push(currentNode);
|
|
900
|
+
} else {
|
|
901
|
+
const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart);
|
|
902
|
+
linkNodes.push(linkTextNode);
|
|
903
|
+
remainingTextNode = endNode;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
offset += currentNodeLength;
|
|
907
|
+
}
|
|
908
|
+
const selection = lexical.$getSelection();
|
|
909
|
+
const selectedTextNode = selection ? selection.getNodes().find(lexical.$isTextNode) : undefined;
|
|
910
|
+
const textNode = lexical.$createTextNode(firstLinkTextNode.getTextContent());
|
|
911
|
+
textNode.setFormat(firstLinkTextNode.getFormat());
|
|
912
|
+
textNode.setDetail(firstLinkTextNode.getDetail());
|
|
913
|
+
textNode.setStyle(firstLinkTextNode.getStyle());
|
|
914
|
+
linkNode.append(textNode, ...linkNodes);
|
|
915
|
+
// it does not preserve caret position if caret was at the first text node
|
|
916
|
+
// so we need to restore caret position
|
|
917
|
+
if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
|
|
918
|
+
if (lexical.$isRangeSelection(selection)) {
|
|
919
|
+
textNode.select(selection.anchor.offset, selection.focus.offset);
|
|
920
|
+
} else if (lexical.$isNodeSelection(selection)) {
|
|
921
|
+
textNode.select(0, textNode.getTextContent().length);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
firstLinkTextNode.replace(linkNode);
|
|
925
|
+
return remainingTextNode;
|
|
926
|
+
}
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
function $handleLinkCreation(nodes, matchers, onChange) {
|
|
930
|
+
let currentNodes = [...nodes];
|
|
931
|
+
const initialText = currentNodes.map(node => node.getTextContent()).join('');
|
|
932
|
+
let text = initialText;
|
|
933
|
+
let match;
|
|
934
|
+
let invalidMatchEnd = 0;
|
|
935
|
+
while ((match = findFirstMatch(text, matchers)) && match !== null) {
|
|
936
|
+
const matchStart = match.index;
|
|
937
|
+
const matchLength = match.length;
|
|
938
|
+
const matchEnd = matchStart + matchLength;
|
|
939
|
+
const isValid = isContentAroundIsValid(invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd, initialText, currentNodes);
|
|
940
|
+
if (isValid) {
|
|
941
|
+
const [matchingOffset,, matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes(currentNodes, invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd);
|
|
942
|
+
const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset;
|
|
943
|
+
const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset;
|
|
944
|
+
const remainingTextNode = $createAutoLinkNode_(matchingNodes, actualMatchStart, actualMatchEnd, match);
|
|
945
|
+
currentNodes = remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes;
|
|
946
|
+
onChange(match.url, null);
|
|
947
|
+
invalidMatchEnd = 0;
|
|
948
|
+
} else {
|
|
949
|
+
invalidMatchEnd += matchEnd;
|
|
950
|
+
}
|
|
951
|
+
text = text.substring(matchEnd);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function handleLinkEdit(linkNode, matchers, onChange) {
|
|
955
|
+
// Check children are simple text
|
|
956
|
+
const children = linkNode.getChildren();
|
|
957
|
+
const childrenLength = children.length;
|
|
958
|
+
for (let i = 0; i < childrenLength; i++) {
|
|
959
|
+
const child = children[i];
|
|
960
|
+
if (!lexical.$isTextNode(child) || !child.isSimpleText()) {
|
|
961
|
+
replaceWithChildren(linkNode);
|
|
962
|
+
onChange(null, linkNode.getURL());
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Check text content fully matches
|
|
968
|
+
const text = linkNode.getTextContent();
|
|
969
|
+
const match = findFirstMatch(text, matchers);
|
|
970
|
+
if (match === null || match.text !== text) {
|
|
971
|
+
replaceWithChildren(linkNode);
|
|
972
|
+
onChange(null, linkNode.getURL());
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Check neighbors
|
|
977
|
+
if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) {
|
|
978
|
+
replaceWithChildren(linkNode);
|
|
979
|
+
onChange(null, linkNode.getURL());
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const url = linkNode.getURL();
|
|
983
|
+
if (url !== match.url) {
|
|
984
|
+
linkNode.setURL(match.url);
|
|
985
|
+
onChange(match.url, url);
|
|
986
|
+
}
|
|
987
|
+
if (match.attributes) {
|
|
988
|
+
const rel = linkNode.getRel();
|
|
989
|
+
if (rel !== match.attributes.rel) {
|
|
990
|
+
linkNode.setRel(match.attributes.rel || null);
|
|
991
|
+
onChange(match.attributes.rel || null, rel);
|
|
992
|
+
}
|
|
993
|
+
const target = linkNode.getTarget();
|
|
994
|
+
if (target !== match.attributes.target) {
|
|
995
|
+
linkNode.setTarget(match.attributes.target || null);
|
|
996
|
+
onChange(match.attributes.target || null, target);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible.
|
|
1002
|
+
// Given the creation preconditions, these can only be simple text nodes.
|
|
1003
|
+
function handleBadNeighbors(textNode, matchers, onChange) {
|
|
1004
|
+
const previousSibling = textNode.getPreviousSibling();
|
|
1005
|
+
const nextSibling = textNode.getNextSibling();
|
|
1006
|
+
const text = textNode.getTextContent();
|
|
1007
|
+
if ($isAutoLinkNode(previousSibling) && !previousSibling.getIsUnlinked() && (!startsWithSeparator(text) || startsWithTLD(text, previousSibling.isEmailURI()))) {
|
|
1008
|
+
previousSibling.append(textNode);
|
|
1009
|
+
handleLinkEdit(previousSibling, matchers, onChange);
|
|
1010
|
+
onChange(null, previousSibling.getURL());
|
|
1011
|
+
}
|
|
1012
|
+
if ($isAutoLinkNode(nextSibling) && !nextSibling.getIsUnlinked() && !endsWithSeparator(text)) {
|
|
1013
|
+
replaceWithChildren(nextSibling);
|
|
1014
|
+
handleLinkEdit(nextSibling, matchers, onChange);
|
|
1015
|
+
onChange(null, nextSibling.getURL());
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function replaceWithChildren(node) {
|
|
1019
|
+
const children = node.getChildren();
|
|
1020
|
+
const childrenLength = children.length;
|
|
1021
|
+
for (let j = childrenLength - 1; j >= 0; j--) {
|
|
1022
|
+
node.insertAfter(children[j]);
|
|
1023
|
+
}
|
|
1024
|
+
node.remove();
|
|
1025
|
+
return children.map(child => child.getLatest());
|
|
1026
|
+
}
|
|
1027
|
+
function getTextNodesToMatch(textNode) {
|
|
1028
|
+
// check if next siblings are simple text nodes till a node contains a space separator
|
|
1029
|
+
const textNodesToMatch = [textNode];
|
|
1030
|
+
let nextSibling = textNode.getNextSibling();
|
|
1031
|
+
while (nextSibling !== null && lexical.$isTextNode(nextSibling) && nextSibling.isSimpleText()) {
|
|
1032
|
+
textNodesToMatch.push(nextSibling);
|
|
1033
|
+
if (/[\s]/.test(nextSibling.getTextContent())) {
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
nextSibling = nextSibling.getNextSibling();
|
|
1037
|
+
}
|
|
1038
|
+
return textNodesToMatch;
|
|
1039
|
+
}
|
|
1040
|
+
const defaultConfig = {
|
|
1041
|
+
changeHandlers: [],
|
|
1042
|
+
matchers: []
|
|
1043
|
+
};
|
|
1044
|
+
function registerAutoLink(editor, config = defaultConfig) {
|
|
1045
|
+
const {
|
|
1046
|
+
matchers,
|
|
1047
|
+
changeHandlers
|
|
1048
|
+
} = config;
|
|
1049
|
+
const onChange = (url, prevUrl) => {
|
|
1050
|
+
for (const handler of changeHandlers) {
|
|
1051
|
+
handler(url, prevUrl);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
return utils.mergeRegister(editor.registerNodeTransform(lexical.TextNode, textNode => {
|
|
1055
|
+
const parent = textNode.getParentOrThrow();
|
|
1056
|
+
const previous = textNode.getPreviousSibling();
|
|
1057
|
+
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
|
|
1058
|
+
handleLinkEdit(parent, matchers, onChange);
|
|
1059
|
+
} else if (!$isLinkNode(parent)) {
|
|
1060
|
+
if (textNode.isSimpleText() && (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))) {
|
|
1061
|
+
const textNodesToMatch = getTextNodesToMatch(textNode);
|
|
1062
|
+
$handleLinkCreation(textNodesToMatch, matchers, onChange);
|
|
1063
|
+
}
|
|
1064
|
+
handleBadNeighbors(textNode, matchers, onChange);
|
|
1065
|
+
}
|
|
1066
|
+
}), editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
|
|
1067
|
+
const selection = lexical.$getSelection();
|
|
1068
|
+
if (payload !== null || !lexical.$isRangeSelection(selection)) {
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
const nodes = selection.extract();
|
|
1072
|
+
nodes.forEach(node => {
|
|
1073
|
+
const parent = node.getParent();
|
|
1074
|
+
if ($isAutoLinkNode(parent)) {
|
|
1075
|
+
// invert the value
|
|
1076
|
+
parent.setIsUnlinked(!parent.getIsUnlinked());
|
|
1077
|
+
parent.markDirty();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
return false;
|
|
1081
|
+
}, lexical.COMMAND_PRIORITY_LOW));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* An extension to automatically create AutoLinkNode from text
|
|
1086
|
+
* that matches the configured matchers. No default implementation
|
|
1087
|
+
* is provided for any matcher, see {@link createLinkMatcherWithRegExp}
|
|
1088
|
+
* for a helper function to create a matcher from a RegExp, and the
|
|
1089
|
+
* Playground's [AutoLinkPlugin](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutoLinkPlugin/index.tsx)
|
|
1090
|
+
* for some example RegExps that could be used.
|
|
1091
|
+
*
|
|
1092
|
+
* The given `matchers` and `changeHandlers` will be merged by
|
|
1093
|
+
* concatenating the configured arrays.
|
|
1094
|
+
*/
|
|
1095
|
+
const AutoLinkExtension = lexical.defineExtension({
|
|
1096
|
+
config: defaultConfig,
|
|
1097
|
+
dependencies: [LinkExtension],
|
|
1098
|
+
mergeConfig(config, overrides) {
|
|
1099
|
+
const merged = lexical.shallowMergeConfig(config, overrides);
|
|
1100
|
+
for (const k of ['matchers', 'changeHandlers']) {
|
|
1101
|
+
const v = overrides[k];
|
|
1102
|
+
if (Array.isArray(v)) {
|
|
1103
|
+
merged[k] = [...config[k], ...v];
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return merged;
|
|
1107
|
+
},
|
|
1108
|
+
name: '@lexical/link/AutoLink',
|
|
1109
|
+
register: registerAutoLink
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
1114
|
+
*
|
|
1115
|
+
* This source code is licensed under the MIT license found in the
|
|
1116
|
+
* LICENSE file in the root directory of this source tree.
|
|
1117
|
+
*
|
|
1118
|
+
*/
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
|
|
1122
|
+
const toggleLink = $toggleLink;
|
|
1123
|
+
|
|
555
1124
|
exports.$createAutoLinkNode = $createAutoLinkNode;
|
|
556
1125
|
exports.$createLinkNode = $createLinkNode;
|
|
557
1126
|
exports.$isAutoLinkNode = $isAutoLinkNode;
|
|
558
1127
|
exports.$isLinkNode = $isLinkNode;
|
|
559
1128
|
exports.$toggleLink = $toggleLink;
|
|
1129
|
+
exports.AutoLinkExtension = AutoLinkExtension;
|
|
560
1130
|
exports.AutoLinkNode = AutoLinkNode;
|
|
1131
|
+
exports.ClickableLinkExtension = ClickableLinkExtension;
|
|
1132
|
+
exports.LinkExtension = LinkExtension;
|
|
561
1133
|
exports.LinkNode = LinkNode;
|
|
562
1134
|
exports.TOGGLE_LINK_COMMAND = TOGGLE_LINK_COMMAND;
|
|
1135
|
+
exports.createLinkMatcherWithRegExp = createLinkMatcherWithRegExp;
|
|
563
1136
|
exports.formatUrl = formatUrl;
|
|
1137
|
+
exports.registerAutoLink = registerAutoLink;
|
|
1138
|
+
exports.registerClickableLink = registerClickableLink;
|
|
1139
|
+
exports.registerLink = registerLink;
|
|
564
1140
|
exports.toggleLink = toggleLink;
|