@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.
@@ -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')
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Link } from './link.js'
2
+
3
+ export * from './link.js'
4
+
5
+ export default Link