@thomasjahoda-forks/tiptap-extension-link 3.0.1
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/README.md +18 -0
- package/dist/index.cjs +448 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +150 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +418 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/helpers/autolink.ts +159 -0
- package/src/helpers/clickHandler.ts +62 -0
- package/src/helpers/pasteHandler.ts +45 -0
- package/src/helpers/whitespace.ts +7 -0
- package/src/index.ts +5 -0
- package/src/link.ts +458 -0
package/src/link.ts
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import type { PasteRuleMatch } from '@tiptap/core'
|
|
2
|
+
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
|
|
3
|
+
import type { Plugin } from '@tiptap/pm/state'
|
|
4
|
+
import { find, registerCustomProtocol, reset } from 'linkifyjs'
|
|
5
|
+
|
|
6
|
+
import { autolink } from './helpers/autolink.js'
|
|
7
|
+
import { clickHandler } from './helpers/clickHandler.js'
|
|
8
|
+
import { pasteHandler } from './helpers/pasteHandler.js'
|
|
9
|
+
import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
|
|
10
|
+
|
|
11
|
+
export interface LinkProtocolOptions {
|
|
12
|
+
/**
|
|
13
|
+
* The protocol scheme to be registered.
|
|
14
|
+
* @default '''
|
|
15
|
+
* @example 'ftp'
|
|
16
|
+
* @example 'git'
|
|
17
|
+
*/
|
|
18
|
+
scheme: string
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* If enabled, it allows optional slashes after the protocol.
|
|
22
|
+
* @default false
|
|
23
|
+
* @example true
|
|
24
|
+
*/
|
|
25
|
+
optionalSlashes?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const pasteRegex =
|
|
29
|
+
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated The default behavior is now to open links when the editor is not editable.
|
|
33
|
+
*/
|
|
34
|
+
type DeprecatedOpenWhenNotEditable = 'whenNotEditable'
|
|
35
|
+
|
|
36
|
+
export interface LinkOptions {
|
|
37
|
+
/**
|
|
38
|
+
* If enabled, the extension will automatically add links as you type.
|
|
39
|
+
* @default true
|
|
40
|
+
* @example false
|
|
41
|
+
*/
|
|
42
|
+
autolink: boolean
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An array of custom protocols to be registered with linkifyjs.
|
|
46
|
+
* @default []
|
|
47
|
+
* @example ['ftp', 'git']
|
|
48
|
+
*/
|
|
49
|
+
protocols: Array<LinkProtocolOptions | string>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default protocol to use when no protocol is specified.
|
|
53
|
+
* @default 'http'
|
|
54
|
+
*/
|
|
55
|
+
defaultProtocol: string
|
|
56
|
+
/**
|
|
57
|
+
* If enabled, links will be opened on click.
|
|
58
|
+
* @default true
|
|
59
|
+
* @example false
|
|
60
|
+
*/
|
|
61
|
+
openOnClick: boolean | DeprecatedOpenWhenNotEditable
|
|
62
|
+
/**
|
|
63
|
+
* If enabled, the link will be selected when clicked.
|
|
64
|
+
* @default false
|
|
65
|
+
* @example true
|
|
66
|
+
*/
|
|
67
|
+
enableClickSelection: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Adds a link to the current selection if the pasted content only contains an url.
|
|
70
|
+
* @default true
|
|
71
|
+
* @example false
|
|
72
|
+
*/
|
|
73
|
+
linkOnPaste: boolean
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* HTML attributes to add to the link element.
|
|
77
|
+
* @default {}
|
|
78
|
+
* @example { class: 'foo' }
|
|
79
|
+
*/
|
|
80
|
+
HTMLAttributes: Record<string, any>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @deprecated Use the `shouldAutoLink` option instead.
|
|
84
|
+
* A validation function that modifies link verification for the auto linker.
|
|
85
|
+
* @param url - The url to be validated.
|
|
86
|
+
* @returns - True if the url is valid, false otherwise.
|
|
87
|
+
*/
|
|
88
|
+
validate: (url: string) => boolean
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A validation function which is used for configuring link verification for preventing XSS attacks.
|
|
92
|
+
* Only modify this if you know what you're doing.
|
|
93
|
+
*
|
|
94
|
+
* @returns {boolean} `true` if the URL is valid, `false` otherwise.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
|
|
98
|
+
* return url.startsWith('./') || defaultValidate(url)
|
|
99
|
+
* }
|
|
100
|
+
*/
|
|
101
|
+
isAllowedUri: (
|
|
102
|
+
/**
|
|
103
|
+
* The URL to be validated.
|
|
104
|
+
*/
|
|
105
|
+
url: string,
|
|
106
|
+
ctx: {
|
|
107
|
+
/**
|
|
108
|
+
* The default validation function.
|
|
109
|
+
*/
|
|
110
|
+
defaultValidate: (url: string) => boolean
|
|
111
|
+
/**
|
|
112
|
+
* An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option.
|
|
113
|
+
*/
|
|
114
|
+
protocols: Array<LinkProtocolOptions | string>
|
|
115
|
+
/**
|
|
116
|
+
* A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option.
|
|
117
|
+
*/
|
|
118
|
+
defaultProtocol: string
|
|
119
|
+
},
|
|
120
|
+
) => boolean
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Determines whether a valid link should be automatically linked in the content.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} url - The URL that has already been validated.
|
|
126
|
+
* @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked.
|
|
127
|
+
*/
|
|
128
|
+
shouldAutoLink: (url: string) => boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
declare module '@tiptap/core' {
|
|
132
|
+
interface Commands<ReturnType> {
|
|
133
|
+
link: {
|
|
134
|
+
/**
|
|
135
|
+
* Set a link mark
|
|
136
|
+
* @param attributes The link attributes
|
|
137
|
+
* @example editor.commands.setLink({ href: 'https://tiptap.dev' })
|
|
138
|
+
*/
|
|
139
|
+
setLink: (attributes: {
|
|
140
|
+
href: string
|
|
141
|
+
target?: string | null
|
|
142
|
+
rel?: string | null
|
|
143
|
+
class?: string | null
|
|
144
|
+
}) => ReturnType
|
|
145
|
+
/**
|
|
146
|
+
* Toggle a link mark
|
|
147
|
+
* @param attributes The link attributes
|
|
148
|
+
* @example editor.commands.toggleLink({ href: 'https://tiptap.dev' })
|
|
149
|
+
*/
|
|
150
|
+
toggleLink: (attributes?: {
|
|
151
|
+
href: string
|
|
152
|
+
target?: string | null
|
|
153
|
+
rel?: string | null
|
|
154
|
+
class?: string | null
|
|
155
|
+
}) => ReturnType
|
|
156
|
+
/**
|
|
157
|
+
* Unset a link mark
|
|
158
|
+
* @example editor.commands.unsetLink()
|
|
159
|
+
*/
|
|
160
|
+
unsetLink: () => ReturnType
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
|
|
166
|
+
const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp']
|
|
167
|
+
|
|
168
|
+
if (protocols) {
|
|
169
|
+
protocols.forEach(protocol => {
|
|
170
|
+
const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
|
|
171
|
+
|
|
172
|
+
if (nextProtocol) {
|
|
173
|
+
allowedProtocols.push(nextProtocol)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
!uri ||
|
|
180
|
+
uri.replace(UNICODE_WHITESPACE_REGEX_GLOBAL, '').match(
|
|
181
|
+
new RegExp(
|
|
182
|
+
// eslint-disable-next-line no-useless-escape
|
|
183
|
+
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
|
|
184
|
+
'i',
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getEffectiveLinkHref(
|
|
191
|
+
href: string | null | undefined,
|
|
192
|
+
opts: LinkOptions
|
|
193
|
+
): string | null {
|
|
194
|
+
if (!href) {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (href.includes('://')) {
|
|
199
|
+
return href
|
|
200
|
+
}
|
|
201
|
+
return `${opts.defaultProtocol}://${href}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* This extension allows you to create links.
|
|
206
|
+
* @see https://www.tiptap.dev/api/marks/link
|
|
207
|
+
*/
|
|
208
|
+
export const Link = Mark.create<LinkOptions>({
|
|
209
|
+
name: 'link',
|
|
210
|
+
|
|
211
|
+
priority: 1000,
|
|
212
|
+
|
|
213
|
+
keepOnSplit: false,
|
|
214
|
+
|
|
215
|
+
exitable: true,
|
|
216
|
+
|
|
217
|
+
onCreate() {
|
|
218
|
+
if (this.options.validate && !this.options.shouldAutoLink) {
|
|
219
|
+
// Copy the validate function to the shouldAutoLink option
|
|
220
|
+
this.options.shouldAutoLink = this.options.validate
|
|
221
|
+
console.warn('The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.')
|
|
222
|
+
}
|
|
223
|
+
this.options.protocols.forEach(protocol => {
|
|
224
|
+
if (typeof protocol === 'string') {
|
|
225
|
+
registerCustomProtocol(protocol)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes)
|
|
229
|
+
})
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
onDestroy() {
|
|
233
|
+
reset()
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
inclusive() {
|
|
237
|
+
return this.options.autolink
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
addOptions() {
|
|
241
|
+
return {
|
|
242
|
+
openOnClick: true,
|
|
243
|
+
enableClickSelection: false,
|
|
244
|
+
linkOnPaste: true,
|
|
245
|
+
autolink: true,
|
|
246
|
+
protocols: [],
|
|
247
|
+
defaultProtocol: 'http',
|
|
248
|
+
HTMLAttributes: {
|
|
249
|
+
target: '_blank',
|
|
250
|
+
rel: 'noopener noreferrer nofollow',
|
|
251
|
+
class: null,
|
|
252
|
+
},
|
|
253
|
+
isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
|
|
254
|
+
validate: url => !!url,
|
|
255
|
+
shouldAutoLink: url => !!url,
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
addAttributes() {
|
|
260
|
+
return {
|
|
261
|
+
href: {
|
|
262
|
+
default: null,
|
|
263
|
+
parseHTML(element) {
|
|
264
|
+
return element.getAttribute('href')
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
target: {
|
|
268
|
+
default: this.options.HTMLAttributes.target,
|
|
269
|
+
},
|
|
270
|
+
rel: {
|
|
271
|
+
default: this.options.HTMLAttributes.rel,
|
|
272
|
+
},
|
|
273
|
+
class: {
|
|
274
|
+
default: this.options.HTMLAttributes.class,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
parseHTML() {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
tag: 'a[href]',
|
|
283
|
+
getAttrs: dom => {
|
|
284
|
+
const href = (dom as HTMLElement).getAttribute('href')
|
|
285
|
+
|
|
286
|
+
// prevent XSS attacks
|
|
287
|
+
if (
|
|
288
|
+
!href ||
|
|
289
|
+
!this.options.isAllowedUri(href, {
|
|
290
|
+
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
|
291
|
+
protocols: this.options.protocols,
|
|
292
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
293
|
+
})
|
|
294
|
+
) {
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
297
|
+
return null
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
]
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
renderHTML({ HTMLAttributes }) {
|
|
304
|
+
// prevent XSS attacks
|
|
305
|
+
if (
|
|
306
|
+
!this.options.isAllowedUri(HTMLAttributes.href, {
|
|
307
|
+
defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
|
|
308
|
+
protocols: this.options.protocols,
|
|
309
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
310
|
+
})
|
|
311
|
+
) {
|
|
312
|
+
// strip out the href
|
|
313
|
+
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const effectiveHTMLAttributes = {
|
|
317
|
+
...HTMLAttributes,
|
|
318
|
+
href: getEffectiveLinkHref(HTMLAttributes.href, this.options),
|
|
319
|
+
}
|
|
320
|
+
return ['a', mergeAttributes(this.options.HTMLAttributes, effectiveHTMLAttributes), 0]
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
addCommands() {
|
|
324
|
+
return {
|
|
325
|
+
setLink:
|
|
326
|
+
attributes =>
|
|
327
|
+
({ chain }) => {
|
|
328
|
+
const { href } = attributes
|
|
329
|
+
|
|
330
|
+
if (
|
|
331
|
+
!this.options.isAllowedUri(href, {
|
|
332
|
+
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
|
333
|
+
protocols: this.options.protocols,
|
|
334
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
335
|
+
})
|
|
336
|
+
) {
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run()
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
toggleLink:
|
|
344
|
+
attributes =>
|
|
345
|
+
({ chain }) => {
|
|
346
|
+
const { href } = attributes || {}
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
href &&
|
|
350
|
+
!this.options.isAllowedUri(href, {
|
|
351
|
+
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
|
352
|
+
protocols: this.options.protocols,
|
|
353
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
354
|
+
})
|
|
355
|
+
) {
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return chain()
|
|
360
|
+
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
|
|
361
|
+
.setMeta('preventAutolink', true)
|
|
362
|
+
.run()
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
unsetLink:
|
|
366
|
+
() =>
|
|
367
|
+
({ chain }) => {
|
|
368
|
+
return chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta('preventAutolink', true).run()
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
addPasteRules() {
|
|
374
|
+
return [
|
|
375
|
+
markPasteRule({
|
|
376
|
+
find: text => {
|
|
377
|
+
const foundLinks: PasteRuleMatch[] = []
|
|
378
|
+
|
|
379
|
+
if (text) {
|
|
380
|
+
const { protocols, defaultProtocol } = this.options
|
|
381
|
+
const links = find(text).filter(
|
|
382
|
+
item =>
|
|
383
|
+
item.isLink &&
|
|
384
|
+
this.options.isAllowedUri(item.value, {
|
|
385
|
+
defaultValidate: href => !!isAllowedUri(href, protocols),
|
|
386
|
+
protocols,
|
|
387
|
+
defaultProtocol,
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if (links.length) {
|
|
392
|
+
links.forEach(link =>
|
|
393
|
+
foundLinks.push({
|
|
394
|
+
text: link.value,
|
|
395
|
+
data: {
|
|
396
|
+
href: link.href,
|
|
397
|
+
},
|
|
398
|
+
index: link.start,
|
|
399
|
+
}),
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return foundLinks
|
|
405
|
+
},
|
|
406
|
+
type: this.type,
|
|
407
|
+
getAttributes: match => {
|
|
408
|
+
return {
|
|
409
|
+
href: match.data?.href,
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
]
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
addProseMirrorPlugins() {
|
|
417
|
+
const plugins: Plugin[] = []
|
|
418
|
+
const { protocols, defaultProtocol } = this.options
|
|
419
|
+
|
|
420
|
+
if (this.options.autolink) {
|
|
421
|
+
plugins.push(
|
|
422
|
+
autolink({
|
|
423
|
+
type: this.type,
|
|
424
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
425
|
+
validate: url =>
|
|
426
|
+
this.options.isAllowedUri(url, {
|
|
427
|
+
defaultValidate: href => !!isAllowedUri(href, protocols),
|
|
428
|
+
protocols,
|
|
429
|
+
defaultProtocol,
|
|
430
|
+
}),
|
|
431
|
+
shouldAutoLink: this.options.shouldAutoLink,
|
|
432
|
+
}),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (this.options.openOnClick === true) {
|
|
437
|
+
plugins.push(
|
|
438
|
+
clickHandler({
|
|
439
|
+
type: this.type,
|
|
440
|
+
editor: this.editor,
|
|
441
|
+
enableClickSelection: this.options.enableClickSelection,
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (this.options.linkOnPaste) {
|
|
447
|
+
plugins.push(
|
|
448
|
+
pasteHandler({
|
|
449
|
+
editor: this.editor,
|
|
450
|
+
defaultProtocol: this.options.defaultProtocol,
|
|
451
|
+
type: this.type,
|
|
452
|
+
}),
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return plugins
|
|
457
|
+
},
|
|
458
|
+
})
|