@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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { NodeWithPos } from '@tiptap/core'
|
|
2
|
+
import { combineTransactionSteps, findChildrenInRange, getChangedRanges, getMarksBetween } from '@tiptap/core'
|
|
3
|
+
import type { MarkType } from '@tiptap/pm/model'
|
|
4
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
5
|
+
import type { MultiToken } from 'linkifyjs'
|
|
6
|
+
import { tokenize } from 'linkifyjs'
|
|
7
|
+
|
|
8
|
+
import { UNICODE_WHITESPACE_REGEX, UNICODE_WHITESPACE_REGEX_END } from './whitespace.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if the provided tokens form a valid link structure, which can either be a single link token
|
|
12
|
+
* or a link token surrounded by parentheses or square brackets.
|
|
13
|
+
*
|
|
14
|
+
* This ensures that only complete and valid text is hyperlinked, preventing cases where a valid
|
|
15
|
+
* top-level domain (TLD) is immediately followed by an invalid character, like a number. For
|
|
16
|
+
* example, with the `find` method from Linkify, entering `example.com1` would result in
|
|
17
|
+
* `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize`
|
|
18
|
+
* method, we can perform more comprehensive validation on the input text.
|
|
19
|
+
*/
|
|
20
|
+
function isValidLinkStructure(tokens: Array<ReturnType<MultiToken['toObject']>>) {
|
|
21
|
+
if (tokens.length === 1) {
|
|
22
|
+
return tokens[0].isLink
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (tokens.length === 3 && tokens[1].isLink) {
|
|
26
|
+
return ['()', '[]'].includes(tokens[0].value + tokens[2].value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type AutolinkOptions = {
|
|
33
|
+
type: MarkType
|
|
34
|
+
defaultProtocol: string
|
|
35
|
+
validate: (url: string) => boolean
|
|
36
|
+
shouldAutoLink: (url: string) => boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* This plugin allows you to automatically add links to your editor.
|
|
41
|
+
* @param options The plugin options
|
|
42
|
+
* @returns The plugin instance
|
|
43
|
+
*/
|
|
44
|
+
export function autolink(options: AutolinkOptions): Plugin {
|
|
45
|
+
return new Plugin({
|
|
46
|
+
key: new PluginKey('autolink'),
|
|
47
|
+
appendTransaction: (transactions, oldState, newState) => {
|
|
48
|
+
/**
|
|
49
|
+
* Does the transaction change the document?
|
|
50
|
+
*/
|
|
51
|
+
const docChanges = transactions.some(transaction => transaction.docChanged) && !oldState.doc.eq(newState.doc)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`.
|
|
55
|
+
*/
|
|
56
|
+
const preventAutolink = transactions.some(transaction => transaction.getMeta('preventAutolink'))
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Prevent autolink if the transaction is not a document change
|
|
60
|
+
* or if the transaction has the meta `preventAutolink`.
|
|
61
|
+
*/
|
|
62
|
+
if (!docChanges || preventAutolink) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { tr } = newState
|
|
67
|
+
const transform = combineTransactionSteps(oldState.doc, [...transactions])
|
|
68
|
+
const changes = getChangedRanges(transform)
|
|
69
|
+
|
|
70
|
+
changes.forEach(({ newRange }) => {
|
|
71
|
+
// Now let’s see if we can add new links.
|
|
72
|
+
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
|
|
73
|
+
|
|
74
|
+
let textBlock: NodeWithPos | undefined
|
|
75
|
+
let textBeforeWhitespace: string | undefined
|
|
76
|
+
|
|
77
|
+
if (nodesInChangedRanges.length > 1) {
|
|
78
|
+
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
|
|
79
|
+
textBlock = nodesInChangedRanges[0]
|
|
80
|
+
textBeforeWhitespace = newState.doc.textBetween(
|
|
81
|
+
textBlock.pos,
|
|
82
|
+
textBlock.pos + textBlock.node.nodeSize,
|
|
83
|
+
undefined,
|
|
84
|
+
' ',
|
|
85
|
+
)
|
|
86
|
+
} else if (nodesInChangedRanges.length) {
|
|
87
|
+
const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ')
|
|
88
|
+
if (!UNICODE_WHITESPACE_REGEX_END.test(endText)) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
textBlock = nodesInChangedRanges[0]
|
|
92
|
+
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (textBlock && textBeforeWhitespace) {
|
|
96
|
+
const wordsBeforeWhitespace = textBeforeWhitespace.split(UNICODE_WHITESPACE_REGEX).filter(Boolean)
|
|
97
|
+
|
|
98
|
+
if (wordsBeforeWhitespace.length <= 0) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]
|
|
103
|
+
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace)
|
|
104
|
+
|
|
105
|
+
if (!lastWordBeforeSpace) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const linksBeforeSpace = tokenize(lastWordBeforeSpace).map(t => t.toObject(options.defaultProtocol))
|
|
110
|
+
|
|
111
|
+
if (!isValidLinkStructure(linksBeforeSpace)) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
linksBeforeSpace
|
|
116
|
+
.filter(link => link.isLink)
|
|
117
|
+
// Calculate link position.
|
|
118
|
+
.map(link => ({
|
|
119
|
+
...link,
|
|
120
|
+
from: lastWordAndBlockOffset + link.start + 1,
|
|
121
|
+
to: lastWordAndBlockOffset + link.end + 1,
|
|
122
|
+
}))
|
|
123
|
+
// ignore link inside code mark
|
|
124
|
+
.filter(link => {
|
|
125
|
+
if (!newState.schema.marks.code) {
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code)
|
|
130
|
+
})
|
|
131
|
+
// validate link
|
|
132
|
+
.filter(link => options.validate(link.value))
|
|
133
|
+
// check whether should autolink
|
|
134
|
+
.filter(link => options.shouldAutoLink(link.value))
|
|
135
|
+
// Add link mark.
|
|
136
|
+
.forEach(link => {
|
|
137
|
+
if (getMarksBetween(link.from, link.to, newState.doc).some(item => item.mark.type === options.type)) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
tr.addMark(
|
|
142
|
+
link.from,
|
|
143
|
+
link.to,
|
|
144
|
+
options.type.create({
|
|
145
|
+
href: link.href,
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (!tr.steps.length) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return tr
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core'
|
|
2
|
+
import { getAttributes } from '@tiptap/core'
|
|
3
|
+
import type { MarkType } from '@tiptap/pm/model'
|
|
4
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
5
|
+
|
|
6
|
+
type ClickHandlerOptions = {
|
|
7
|
+
type: MarkType
|
|
8
|
+
editor: Editor
|
|
9
|
+
enableClickSelection?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clickHandler(options: ClickHandlerOptions): Plugin {
|
|
13
|
+
return new Plugin({
|
|
14
|
+
key: new PluginKey('handleClickLink'),
|
|
15
|
+
props: {
|
|
16
|
+
handleClick: (view, pos, event) => {
|
|
17
|
+
if (event.button !== 0) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!view.editable) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let link: HTMLAnchorElement | null = null
|
|
26
|
+
|
|
27
|
+
if (event.target instanceof HTMLAnchorElement) {
|
|
28
|
+
link = event.target
|
|
29
|
+
} else {
|
|
30
|
+
let a = event.target as HTMLElement
|
|
31
|
+
const els = []
|
|
32
|
+
|
|
33
|
+
while (a.nodeName !== 'DIV') {
|
|
34
|
+
els.push(a)
|
|
35
|
+
a = a.parentNode as HTMLElement
|
|
36
|
+
}
|
|
37
|
+
link = els.find(value => value.nodeName === 'A') as HTMLAnchorElement
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!link) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const attrs = getAttributes(view.state, options.type.name)
|
|
45
|
+
const href = link?.href ?? attrs.href
|
|
46
|
+
const target = link?.target ?? attrs.target
|
|
47
|
+
|
|
48
|
+
if (options.enableClickSelection) {
|
|
49
|
+
options.editor.commands.extendMarkRange(options.type.name)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (link && href) {
|
|
53
|
+
window.open(href, target)
|
|
54
|
+
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core'
|
|
2
|
+
import type { MarkType } from '@tiptap/pm/model'
|
|
3
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
import { find } from 'linkifyjs'
|
|
5
|
+
|
|
6
|
+
type PasteHandlerOptions = {
|
|
7
|
+
editor: Editor
|
|
8
|
+
defaultProtocol: string
|
|
9
|
+
type: MarkType
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
|
13
|
+
return new Plugin({
|
|
14
|
+
key: new PluginKey('handlePasteLink'),
|
|
15
|
+
props: {
|
|
16
|
+
handlePaste: (view, event, slice) => {
|
|
17
|
+
const { state } = view
|
|
18
|
+
const { selection } = state
|
|
19
|
+
const { empty } = selection
|
|
20
|
+
|
|
21
|
+
if (empty) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let textContent = ''
|
|
26
|
+
|
|
27
|
+
slice.content.forEach(node => {
|
|
28
|
+
textContent += node.textContent
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const link = find(textContent, { defaultProtocol: options.defaultProtocol }).find(
|
|
32
|
+
item => item.isLink && item.value === textContent,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!textContent || !link) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return options.editor.commands.setMark(options.type, {
|
|
40
|
+
href: link.href,
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// From DOMPurify
|
|
2
|
+
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.ts
|
|
3
|
+
export const UNICODE_WHITESPACE_PATTERN = '[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]'
|
|
4
|
+
|
|
5
|
+
export const UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN)
|
|
6
|
+
export const UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`)
|
|
7
|
+
export const UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, 'g')
|