@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.
- package/{LexicalLink.dev.js → dist/LexicalLink.dev.js} +60 -2
- package/{LexicalLink.dev.mjs → dist/LexicalLink.dev.mjs} +60 -4
- package/{LexicalLink.mjs → dist/LexicalLink.mjs} +2 -0
- package/{LexicalLink.node.mjs → dist/LexicalLink.node.mjs} +2 -0
- package/dist/LexicalLink.prod.js +9 -0
- package/dist/LexicalLink.prod.mjs +9 -0
- package/dist/LinkImportExtension.d.ts +23 -0
- package/{index.d.ts → dist/index.d.ts} +1 -0
- package/package.json +33 -17
- package/src/ClickableLinkExtension.ts +142 -0
- package/src/LexicalAutoLinkExtension.ts +639 -0
- package/src/LexicalLinkExtension.ts +164 -0
- package/src/LexicalLinkNode.ts +964 -0
- package/src/LinkImportExtension.ts +67 -0
- package/src/index.ts +43 -0
- package/LexicalLink.prod.js +0 -9
- package/LexicalLink.prod.mjs +0 -9
- /package/{ClickableLinkExtension.d.ts → dist/ClickableLinkExtension.d.ts} +0 -0
- /package/{LexicalAutoLinkExtension.d.ts → dist/LexicalAutoLinkExtension.d.ts} +0 -0
- /package/{LexicalLink.js → dist/LexicalLink.js} +0 -0
- /package/{LexicalLink.js.flow → dist/LexicalLink.js.flow} +0 -0
- /package/{LexicalLinkExtension.d.ts → dist/LexicalLinkExtension.d.ts} +0 -0
- /package/{LexicalLinkNode.d.ts → dist/LexicalLinkNode.d.ts} +0 -0
|
@@ -0,0 +1,639 @@
|
|
|
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 {ElementNode, LexicalEditor, LexicalNode} from 'lexical';
|
|
10
|
+
|
|
11
|
+
import {mergeRegister} from '@lexical/utils';
|
|
12
|
+
import {
|
|
13
|
+
$createTextNode,
|
|
14
|
+
$getSelection,
|
|
15
|
+
$isElementNode,
|
|
16
|
+
$isLineBreakNode,
|
|
17
|
+
$isNodeSelection,
|
|
18
|
+
$isRangeSelection,
|
|
19
|
+
$isTextNode,
|
|
20
|
+
COMMAND_PRIORITY_LOW,
|
|
21
|
+
defineExtension,
|
|
22
|
+
shallowMergeConfig,
|
|
23
|
+
TextNode,
|
|
24
|
+
} from 'lexical';
|
|
25
|
+
|
|
26
|
+
import {LinkExtension} from './LexicalLinkExtension';
|
|
27
|
+
import {
|
|
28
|
+
$createAutoLinkNode,
|
|
29
|
+
$isAutoLinkNode,
|
|
30
|
+
$isLinkNode,
|
|
31
|
+
type AutoLinkAttributes,
|
|
32
|
+
AutoLinkNode,
|
|
33
|
+
TOGGLE_LINK_COMMAND,
|
|
34
|
+
} from './LexicalLinkNode';
|
|
35
|
+
|
|
36
|
+
export type ChangeHandler = (
|
|
37
|
+
url: string | null,
|
|
38
|
+
prevUrl: string | null,
|
|
39
|
+
) => void;
|
|
40
|
+
|
|
41
|
+
export interface LinkMatcherResult {
|
|
42
|
+
attributes?: AutoLinkAttributes;
|
|
43
|
+
index: number;
|
|
44
|
+
length: number;
|
|
45
|
+
text: string;
|
|
46
|
+
url: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type LinkMatcher = (text: string) => LinkMatcherResult | null;
|
|
50
|
+
|
|
51
|
+
export function createLinkMatcherWithRegExp(
|
|
52
|
+
regExp: RegExp,
|
|
53
|
+
urlTransformer: (text: string) => string = text => text,
|
|
54
|
+
) {
|
|
55
|
+
return (text: string) => {
|
|
56
|
+
const match = regExp.exec(text);
|
|
57
|
+
if (match === null) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
index: match.index,
|
|
62
|
+
length: match[0].length,
|
|
63
|
+
text: match[0],
|
|
64
|
+
url: urlTransformer(match[0]),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findFirstMatch(
|
|
70
|
+
text: string,
|
|
71
|
+
matchers: Array<LinkMatcher>,
|
|
72
|
+
): LinkMatcherResult | null {
|
|
73
|
+
for (let i = 0; i < matchers.length; i++) {
|
|
74
|
+
const match = matchers[i](text);
|
|
75
|
+
|
|
76
|
+
if (match) {
|
|
77
|
+
return match;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PUNCTUATION_OR_SPACE = /[.,;\s]/;
|
|
85
|
+
|
|
86
|
+
function isSeparator(char: string, separatorRegex: RegExp): boolean {
|
|
87
|
+
return separatorRegex.test(char);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function endsWithSeparator(
|
|
91
|
+
textContent: string,
|
|
92
|
+
separatorRegex: RegExp,
|
|
93
|
+
): boolean {
|
|
94
|
+
return isSeparator(textContent[textContent.length - 1], separatorRegex);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function startsWithSeparator(
|
|
98
|
+
textContent: string,
|
|
99
|
+
separatorRegex: RegExp,
|
|
100
|
+
): boolean {
|
|
101
|
+
return isSeparator(textContent[0], separatorRegex);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if the text content starts with a fullstop followed by a top-level domain.
|
|
106
|
+
* Meaning if the text content can be a beginning of a top level domain.
|
|
107
|
+
* @param textContent
|
|
108
|
+
* @param isEmail
|
|
109
|
+
* @returns boolean
|
|
110
|
+
*/
|
|
111
|
+
function startsWithTLD(textContent: string, isEmail: boolean): boolean {
|
|
112
|
+
if (isEmail) {
|
|
113
|
+
return /^\.[a-zA-Z]{2,}/.test(textContent);
|
|
114
|
+
} else {
|
|
115
|
+
return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isPreviousNodeValid(
|
|
120
|
+
node: LexicalNode,
|
|
121
|
+
separatorRegex: RegExp,
|
|
122
|
+
): boolean {
|
|
123
|
+
let previousNode = node.getPreviousSibling();
|
|
124
|
+
if ($isElementNode(previousNode)) {
|
|
125
|
+
previousNode = previousNode.getLastDescendant();
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
previousNode === null ||
|
|
129
|
+
$isLineBreakNode(previousNode) ||
|
|
130
|
+
($isTextNode(previousNode) &&
|
|
131
|
+
endsWithSeparator(previousNode.getTextContent(), separatorRegex))
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isNextNodeValid(node: LexicalNode, separatorRegex: RegExp): boolean {
|
|
136
|
+
let nextNode = node.getNextSibling();
|
|
137
|
+
if ($isElementNode(nextNode)) {
|
|
138
|
+
nextNode = nextNode.getFirstDescendant();
|
|
139
|
+
}
|
|
140
|
+
return (
|
|
141
|
+
nextNode === null ||
|
|
142
|
+
$isLineBreakNode(nextNode) ||
|
|
143
|
+
($isTextNode(nextNode) &&
|
|
144
|
+
startsWithSeparator(nextNode.getTextContent(), separatorRegex))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isContentAroundIsValid(
|
|
149
|
+
matchStart: number,
|
|
150
|
+
matchEnd: number,
|
|
151
|
+
separatorRegex: RegExp,
|
|
152
|
+
text: string,
|
|
153
|
+
nodes: TextNode[],
|
|
154
|
+
): boolean {
|
|
155
|
+
const contentBeforeIsValid =
|
|
156
|
+
matchStart > 0
|
|
157
|
+
? isSeparator(text[matchStart - 1], separatorRegex)
|
|
158
|
+
: isPreviousNodeValid(nodes[0], separatorRegex);
|
|
159
|
+
if (!contentBeforeIsValid) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const contentAfterIsValid =
|
|
164
|
+
matchEnd < text.length
|
|
165
|
+
? isSeparator(text[matchEnd], separatorRegex)
|
|
166
|
+
: isNextNodeValid(nodes[nodes.length - 1], separatorRegex);
|
|
167
|
+
return contentAfterIsValid;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function extractMatchingNodes(
|
|
171
|
+
nodes: TextNode[],
|
|
172
|
+
startIndex: number,
|
|
173
|
+
endIndex: number,
|
|
174
|
+
): [
|
|
175
|
+
matchingOffset: number,
|
|
176
|
+
unmodifiedBeforeNodes: TextNode[],
|
|
177
|
+
matchingNodes: TextNode[],
|
|
178
|
+
unmodifiedAfterNodes: TextNode[],
|
|
179
|
+
] {
|
|
180
|
+
const unmodifiedBeforeNodes: TextNode[] = [];
|
|
181
|
+
const matchingNodes: TextNode[] = [];
|
|
182
|
+
const unmodifiedAfterNodes: TextNode[] = [];
|
|
183
|
+
let matchingOffset = 0;
|
|
184
|
+
|
|
185
|
+
let currentOffset = 0;
|
|
186
|
+
const currentNodes = [...nodes];
|
|
187
|
+
|
|
188
|
+
while (currentNodes.length > 0) {
|
|
189
|
+
const currentNode = currentNodes[0];
|
|
190
|
+
const currentNodeText = currentNode.getTextContent();
|
|
191
|
+
const currentNodeLength = currentNodeText.length;
|
|
192
|
+
const currentNodeStart = currentOffset;
|
|
193
|
+
const currentNodeEnd = currentOffset + currentNodeLength;
|
|
194
|
+
|
|
195
|
+
if (currentNodeEnd <= startIndex) {
|
|
196
|
+
unmodifiedBeforeNodes.push(currentNode);
|
|
197
|
+
matchingOffset += currentNodeLength;
|
|
198
|
+
} else if (currentNodeStart >= endIndex) {
|
|
199
|
+
unmodifiedAfterNodes.push(currentNode);
|
|
200
|
+
} else {
|
|
201
|
+
matchingNodes.push(currentNode);
|
|
202
|
+
}
|
|
203
|
+
currentOffset += currentNodeLength;
|
|
204
|
+
currentNodes.shift();
|
|
205
|
+
}
|
|
206
|
+
return [
|
|
207
|
+
matchingOffset,
|
|
208
|
+
unmodifiedBeforeNodes,
|
|
209
|
+
matchingNodes,
|
|
210
|
+
unmodifiedAfterNodes,
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function $createAutoLinkNode_(
|
|
215
|
+
nodes: TextNode[],
|
|
216
|
+
startIndex: number,
|
|
217
|
+
endIndex: number,
|
|
218
|
+
match: LinkMatcherResult,
|
|
219
|
+
): TextNode | undefined {
|
|
220
|
+
const linkNode = $createAutoLinkNode(match.url, match.attributes);
|
|
221
|
+
if (nodes.length === 1) {
|
|
222
|
+
let remainingTextNode = nodes[0];
|
|
223
|
+
let linkTextNode;
|
|
224
|
+
if (startIndex === 0) {
|
|
225
|
+
[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex);
|
|
226
|
+
} else {
|
|
227
|
+
[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(
|
|
228
|
+
startIndex,
|
|
229
|
+
endIndex,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const textNode = $createTextNode(match.text);
|
|
233
|
+
textNode.setFormat(linkTextNode.getFormat());
|
|
234
|
+
textNode.setDetail(linkTextNode.getDetail());
|
|
235
|
+
textNode.setStyle(linkTextNode.getStyle());
|
|
236
|
+
linkNode.append(textNode);
|
|
237
|
+
linkTextNode.replace(linkNode);
|
|
238
|
+
return remainingTextNode;
|
|
239
|
+
} else if (nodes.length > 1) {
|
|
240
|
+
const firstTextNode = nodes[0];
|
|
241
|
+
let offset = firstTextNode.getTextContent().length;
|
|
242
|
+
let firstLinkTextNode;
|
|
243
|
+
if (startIndex === 0) {
|
|
244
|
+
firstLinkTextNode = firstTextNode;
|
|
245
|
+
} else {
|
|
246
|
+
[, firstLinkTextNode] = firstTextNode.splitText(startIndex);
|
|
247
|
+
}
|
|
248
|
+
const linkNodes = [];
|
|
249
|
+
let remainingTextNode;
|
|
250
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
251
|
+
const currentNode = nodes[i];
|
|
252
|
+
const currentNodeText = currentNode.getTextContent();
|
|
253
|
+
const currentNodeLength = currentNodeText.length;
|
|
254
|
+
const currentNodeStart = offset;
|
|
255
|
+
const currentNodeEnd = offset + currentNodeLength;
|
|
256
|
+
if (currentNodeStart < endIndex) {
|
|
257
|
+
if (currentNodeEnd <= endIndex) {
|
|
258
|
+
linkNodes.push(currentNode);
|
|
259
|
+
} else {
|
|
260
|
+
const [linkTextNode, endNode] = currentNode.splitText(
|
|
261
|
+
endIndex - currentNodeStart,
|
|
262
|
+
);
|
|
263
|
+
linkNodes.push(linkTextNode);
|
|
264
|
+
remainingTextNode = endNode;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
offset += currentNodeLength;
|
|
268
|
+
}
|
|
269
|
+
const selection = $getSelection();
|
|
270
|
+
const selectedTextNode = selection
|
|
271
|
+
? selection.getNodes().find($isTextNode)
|
|
272
|
+
: undefined;
|
|
273
|
+
const textNode = $createTextNode(firstLinkTextNode.getTextContent());
|
|
274
|
+
textNode.setFormat(firstLinkTextNode.getFormat());
|
|
275
|
+
textNode.setDetail(firstLinkTextNode.getDetail());
|
|
276
|
+
textNode.setStyle(firstLinkTextNode.getStyle());
|
|
277
|
+
linkNode.append(textNode, ...linkNodes);
|
|
278
|
+
// it does not preserve caret position if caret was at the first text node
|
|
279
|
+
// so we need to restore caret position
|
|
280
|
+
if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
|
|
281
|
+
if ($isRangeSelection(selection)) {
|
|
282
|
+
textNode.select(selection.anchor.offset, selection.focus.offset);
|
|
283
|
+
} else if ($isNodeSelection(selection)) {
|
|
284
|
+
textNode.select(0, textNode.getTextContent().length);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
firstLinkTextNode.replace(linkNode);
|
|
288
|
+
return remainingTextNode;
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function $handleLinkCreation(
|
|
294
|
+
nodes: TextNode[],
|
|
295
|
+
matchers: Array<LinkMatcher>,
|
|
296
|
+
onChange: ChangeHandler,
|
|
297
|
+
separatorRegex: RegExp,
|
|
298
|
+
): void {
|
|
299
|
+
// Early return if any node is already part of an AutoLinkNode (idempotency check)
|
|
300
|
+
for (const node of nodes) {
|
|
301
|
+
const parent = node.getParent();
|
|
302
|
+
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let currentNodes = [...nodes];
|
|
308
|
+
const initialText = currentNodes.map(node => node.getTextContent()).join('');
|
|
309
|
+
let text = initialText;
|
|
310
|
+
let match;
|
|
311
|
+
let invalidMatchEnd = 0;
|
|
312
|
+
|
|
313
|
+
while ((match = findFirstMatch(text, matchers)) && match !== null) {
|
|
314
|
+
const matchStart = match.index;
|
|
315
|
+
const matchLength = match.length;
|
|
316
|
+
const matchEnd = matchStart + matchLength;
|
|
317
|
+
const isValid = isContentAroundIsValid(
|
|
318
|
+
invalidMatchEnd + matchStart,
|
|
319
|
+
invalidMatchEnd + matchEnd,
|
|
320
|
+
separatorRegex,
|
|
321
|
+
initialText,
|
|
322
|
+
currentNodes,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (isValid) {
|
|
326
|
+
const [matchingOffset, , matchingNodes, unmodifiedAfterNodes] =
|
|
327
|
+
extractMatchingNodes(
|
|
328
|
+
currentNodes,
|
|
329
|
+
invalidMatchEnd + matchStart,
|
|
330
|
+
invalidMatchEnd + matchEnd,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// Skip if matching nodes are already part of an AutoLinkNode
|
|
334
|
+
let alreadyLinked = false;
|
|
335
|
+
for (const node of matchingNodes) {
|
|
336
|
+
const parent = node.getParent();
|
|
337
|
+
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
|
|
338
|
+
alreadyLinked = true;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (alreadyLinked) {
|
|
343
|
+
invalidMatchEnd += matchEnd;
|
|
344
|
+
text = text.substring(matchEnd);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset;
|
|
349
|
+
const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset;
|
|
350
|
+
const remainingTextNode = $createAutoLinkNode_(
|
|
351
|
+
matchingNodes,
|
|
352
|
+
actualMatchStart,
|
|
353
|
+
actualMatchEnd,
|
|
354
|
+
match,
|
|
355
|
+
);
|
|
356
|
+
currentNodes = remainingTextNode
|
|
357
|
+
? [remainingTextNode, ...unmodifiedAfterNodes]
|
|
358
|
+
: unmodifiedAfterNodes;
|
|
359
|
+
onChange(match.url, null);
|
|
360
|
+
invalidMatchEnd = 0;
|
|
361
|
+
} else {
|
|
362
|
+
invalidMatchEnd += matchEnd;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
text = text.substring(matchEnd);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function handleLinkEdit(
|
|
370
|
+
linkNode: AutoLinkNode,
|
|
371
|
+
matchers: Array<LinkMatcher>,
|
|
372
|
+
onChange: ChangeHandler,
|
|
373
|
+
separatorRegex: RegExp,
|
|
374
|
+
): void {
|
|
375
|
+
// Check children are simple text
|
|
376
|
+
const children = linkNode.getChildren();
|
|
377
|
+
const childrenLength = children.length;
|
|
378
|
+
for (let i = 0; i < childrenLength; i++) {
|
|
379
|
+
const child = children[i];
|
|
380
|
+
if (!$isTextNode(child) || !child.isSimpleText()) {
|
|
381
|
+
replaceWithChildren(linkNode);
|
|
382
|
+
onChange(null, linkNode.getURL());
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check text content fully matches
|
|
388
|
+
const text = linkNode.getTextContent();
|
|
389
|
+
const match = findFirstMatch(text, matchers);
|
|
390
|
+
if (match === null || match.text !== text) {
|
|
391
|
+
replaceWithChildren(linkNode);
|
|
392
|
+
onChange(null, linkNode.getURL());
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check neighbors
|
|
397
|
+
if (
|
|
398
|
+
!isPreviousNodeValid(linkNode, separatorRegex) ||
|
|
399
|
+
!isNextNodeValid(linkNode, separatorRegex)
|
|
400
|
+
) {
|
|
401
|
+
replaceWithChildren(linkNode);
|
|
402
|
+
onChange(null, linkNode.getURL());
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const url = linkNode.getURL();
|
|
407
|
+
if (url !== match.url) {
|
|
408
|
+
linkNode.setURL(match.url);
|
|
409
|
+
onChange(match.url, url);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (match.attributes) {
|
|
413
|
+
const rel = linkNode.getRel();
|
|
414
|
+
if (rel !== match.attributes.rel) {
|
|
415
|
+
linkNode.setRel(match.attributes.rel || null);
|
|
416
|
+
onChange(match.attributes.rel || null, rel);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const target = linkNode.getTarget();
|
|
420
|
+
if (target !== match.attributes.target) {
|
|
421
|
+
linkNode.setTarget(match.attributes.target || null);
|
|
422
|
+
onChange(match.attributes.target || null, target);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible.
|
|
428
|
+
// Given the creation preconditions, these can only be simple text nodes.
|
|
429
|
+
function handleBadNeighbors(
|
|
430
|
+
textNode: TextNode,
|
|
431
|
+
matchers: Array<LinkMatcher>,
|
|
432
|
+
onChange: ChangeHandler,
|
|
433
|
+
separatorRegex: RegExp,
|
|
434
|
+
): void {
|
|
435
|
+
const parent = textNode.getParent();
|
|
436
|
+
const previousSibling = textNode.getPreviousSibling();
|
|
437
|
+
const nextSibling = textNode.getNextSibling();
|
|
438
|
+
const text = textNode.getTextContent();
|
|
439
|
+
|
|
440
|
+
// Skip if textNode is already part of an AutoLinkNode (idempotency check)
|
|
441
|
+
// The handleLinkEdit on the parent will handle unwrapping if needed
|
|
442
|
+
if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Handle case: textNode added AFTER a link, making link invalid
|
|
447
|
+
// Check if previousSibling is a link and adding this textNode makes it invalid
|
|
448
|
+
if ($isAutoLinkNode(previousSibling) && !previousSibling.getIsUnlinked()) {
|
|
449
|
+
// Check if the textNode is still a sibling (hasn't been moved) to prevent loops
|
|
450
|
+
if (
|
|
451
|
+
previousSibling.is(textNode.getPreviousSibling()) &&
|
|
452
|
+
textNode.getParent() === previousSibling.getParent()
|
|
453
|
+
) {
|
|
454
|
+
// If text doesn't start with separator, link should be unwrapped
|
|
455
|
+
// because non-separator after link makes the boundary invalid
|
|
456
|
+
if (!startsWithSeparator(text, separatorRegex)) {
|
|
457
|
+
// Non-separator after link - unwrap the link
|
|
458
|
+
replaceWithChildren(previousSibling);
|
|
459
|
+
onChange(null, previousSibling.getURL());
|
|
460
|
+
return; // Early return after unwrapping to avoid further processing
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// If text starts with separator, check if it's valid TLD continuation
|
|
464
|
+
if (startsWithTLD(text, previousSibling.isEmailURI())) {
|
|
465
|
+
// Valid TLD continuation - try to append
|
|
466
|
+
const combinedText = previousSibling.getTextContent() + text;
|
|
467
|
+
const match = findFirstMatch(combinedText, matchers);
|
|
468
|
+
if (match !== null && match.text === combinedText) {
|
|
469
|
+
previousSibling.append(textNode);
|
|
470
|
+
handleLinkEdit(previousSibling, matchers, onChange, separatorRegex);
|
|
471
|
+
onChange(null, previousSibling.getURL());
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// If starts with separator but not valid TLD, do nothing (link stays valid)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Handle case: textNode added BEFORE a link, making link invalid
|
|
479
|
+
if (
|
|
480
|
+
$isAutoLinkNode(nextSibling) &&
|
|
481
|
+
!nextSibling.getIsUnlinked() &&
|
|
482
|
+
!endsWithSeparator(text, separatorRegex)
|
|
483
|
+
) {
|
|
484
|
+
// Check if the nextSibling is still a sibling (hasn't been moved) to prevent loops
|
|
485
|
+
if (
|
|
486
|
+
nextSibling.is(textNode.getNextSibling()) &&
|
|
487
|
+
textNode.getParent() === nextSibling.getParent()
|
|
488
|
+
) {
|
|
489
|
+
replaceWithChildren(nextSibling);
|
|
490
|
+
onChange(null, nextSibling.getURL());
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function replaceWithChildren(node: ElementNode): Array<LexicalNode> {
|
|
496
|
+
const children = node.getChildren();
|
|
497
|
+
const childrenLength = children.length;
|
|
498
|
+
|
|
499
|
+
for (let j = childrenLength - 1; j >= 0; j--) {
|
|
500
|
+
node.insertAfter(children[j]);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
node.remove();
|
|
504
|
+
return children.map(child => child.getLatest());
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function getTextNodesToMatch(textNode: TextNode): TextNode[] {
|
|
508
|
+
// check if next siblings are simple text nodes till a node contains a space separator
|
|
509
|
+
const textNodesToMatch = [textNode];
|
|
510
|
+
let nextSibling = textNode.getNextSibling();
|
|
511
|
+
while (
|
|
512
|
+
nextSibling !== null &&
|
|
513
|
+
$isTextNode(nextSibling) &&
|
|
514
|
+
nextSibling.isSimpleText()
|
|
515
|
+
) {
|
|
516
|
+
textNodesToMatch.push(nextSibling);
|
|
517
|
+
if (/[\s]/.test(nextSibling.getTextContent())) {
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
nextSibling = nextSibling.getNextSibling();
|
|
521
|
+
}
|
|
522
|
+
return textNodesToMatch;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface AutoLinkConfig {
|
|
526
|
+
changeHandlers: ChangeHandler[];
|
|
527
|
+
excludeParents: ((parent: ElementNode) => boolean)[];
|
|
528
|
+
matchers: LinkMatcher[];
|
|
529
|
+
/**
|
|
530
|
+
* The regular expression used to determine whether surrounding
|
|
531
|
+
* characters count as separators when validating auto-link
|
|
532
|
+
* boundaries. Defaults to `/[.,;\s]/`.
|
|
533
|
+
*/
|
|
534
|
+
separatorRegex: RegExp;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const defaultConfig: AutoLinkConfig = {
|
|
538
|
+
changeHandlers: [],
|
|
539
|
+
excludeParents: [],
|
|
540
|
+
matchers: [],
|
|
541
|
+
separatorRegex: PUNCTUATION_OR_SPACE,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
export function registerAutoLink(
|
|
545
|
+
editor: LexicalEditor,
|
|
546
|
+
config: Partial<AutoLinkConfig> &
|
|
547
|
+
Omit<AutoLinkConfig, 'separatorRegex'> = defaultConfig,
|
|
548
|
+
): () => void {
|
|
549
|
+
const {
|
|
550
|
+
matchers,
|
|
551
|
+
changeHandlers,
|
|
552
|
+
excludeParents,
|
|
553
|
+
separatorRegex = PUNCTUATION_OR_SPACE,
|
|
554
|
+
} = config;
|
|
555
|
+
const onChange: ChangeHandler = (url, prevUrl) => {
|
|
556
|
+
for (const handler of changeHandlers) {
|
|
557
|
+
handler(url, prevUrl);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
return mergeRegister(
|
|
561
|
+
editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
|
|
562
|
+
const parent = textNode.getParentOrThrow();
|
|
563
|
+
const previous = textNode.getPreviousSibling();
|
|
564
|
+
if ($isAutoLinkNode(parent)) {
|
|
565
|
+
handleLinkEdit(parent, matchers, onChange, separatorRegex);
|
|
566
|
+
} else if (
|
|
567
|
+
!$isLinkNode(parent) &&
|
|
568
|
+
!excludeParents.some(pred => pred(parent))
|
|
569
|
+
) {
|
|
570
|
+
if (
|
|
571
|
+
textNode.isSimpleText() &&
|
|
572
|
+
(startsWithSeparator(textNode.getTextContent(), separatorRegex) ||
|
|
573
|
+
!$isAutoLinkNode(previous))
|
|
574
|
+
) {
|
|
575
|
+
const textNodesToMatch = getTextNodesToMatch(textNode);
|
|
576
|
+
$handleLinkCreation(
|
|
577
|
+
textNodesToMatch,
|
|
578
|
+
matchers,
|
|
579
|
+
onChange,
|
|
580
|
+
separatorRegex,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
handleBadNeighbors(textNode, matchers, onChange, separatorRegex);
|
|
585
|
+
}
|
|
586
|
+
}),
|
|
587
|
+
editor.registerCommand(
|
|
588
|
+
TOGGLE_LINK_COMMAND,
|
|
589
|
+
payload => {
|
|
590
|
+
const selection = $getSelection();
|
|
591
|
+
if (payload !== null || !$isRangeSelection(selection)) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
const nodes = selection.extract();
|
|
595
|
+
nodes.forEach(node => {
|
|
596
|
+
const parent = node.getParent();
|
|
597
|
+
|
|
598
|
+
if ($isAutoLinkNode(parent)) {
|
|
599
|
+
// invert the value
|
|
600
|
+
parent.setIsUnlinked(!parent.getIsUnlinked());
|
|
601
|
+
parent.markDirty();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
return false;
|
|
605
|
+
},
|
|
606
|
+
// Has to be higher than TOGGLE_LINK_COMMAND in LinkExtension
|
|
607
|
+
COMMAND_PRIORITY_LOW,
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* An extension to automatically create AutoLinkNode from text
|
|
614
|
+
* that matches the configured matchers. No default implementation
|
|
615
|
+
* is provided for any matcher, see {@link createLinkMatcherWithRegExp}
|
|
616
|
+
* for a helper function to create a matcher from a RegExp, and the
|
|
617
|
+
* Playground's [AutoLinkPlugin](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutoLinkPlugin/index.tsx)
|
|
618
|
+
* for some example RegExps that could be used.
|
|
619
|
+
*
|
|
620
|
+
* The given `matchers` and `changeHandlers` will be merged by
|
|
621
|
+
* concatenating the configured arrays.
|
|
622
|
+
*/
|
|
623
|
+
export const AutoLinkExtension = defineExtension({
|
|
624
|
+
config: defaultConfig,
|
|
625
|
+
dependencies: [LinkExtension],
|
|
626
|
+
mergeConfig(config, overrides) {
|
|
627
|
+
const merged = shallowMergeConfig(config, overrides);
|
|
628
|
+
for (const k of ['matchers', 'changeHandlers', 'excludeParents'] as const) {
|
|
629
|
+
const v = overrides[k];
|
|
630
|
+
if (Array.isArray(v)) {
|
|
631
|
+
(merged[k] as unknown[]) = [...config[k], ...v];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return merged;
|
|
635
|
+
},
|
|
636
|
+
name: '@lexical/link/AutoLink',
|
|
637
|
+
nodes: [AutoLinkNode],
|
|
638
|
+
register: registerAutoLink,
|
|
639
|
+
});
|