@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,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
|
+
}
|