a tool for shared writing and social publishing
1// copied from https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/src/helpers/autolink.ts
2import {
3 combineTransactionSteps,
4 findChildrenInRange,
5 getChangedRanges,
6 getMarksBetween,
7 NodeWithPos,
8} from "@tiptap/core";
9import { MarkType } from "prosemirror-model";
10import { Plugin, PluginKey } from "prosemirror-state";
11import { MultiToken, tokenize } from "linkifyjs";
12
13/**
14 * Check if the provided tokens form a valid link structure, which can either be a single link token
15 * or a link token surrounded by parentheses or square brackets.
16 *
17 * This ensures that only complete and valid text is hyperlinked, preventing cases where a valid
18 * top-level domain (TLD) is immediately followed by an invalid character, like a number. For
19 * example, with the `find` method from Linkify, entering `example.com1` would result in
20 * `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize`
21 * method, we can perform more comprehensive validation on the input text.
22 */
23function isValidLinkStructure(
24 tokens: Array<ReturnType<MultiToken["toObject"]>>,
25) {
26 if (tokens.length === 1) {
27 return tokens[0].isLink;
28 }
29
30 if (tokens.length === 3 && tokens[1].isLink) {
31 return ["()", "[]"].includes(tokens[0].value + tokens[2].value);
32 }
33
34 return false;
35}
36
37type AutolinkOptions = {
38 type: MarkType;
39 defaultProtocol: string;
40 shouldAutoLink: (url: string) => boolean;
41};
42
43/**
44 * This plugin allows you to automatically add links to your editor.
45 * @param options The plugin options
46 * @returns The plugin instance
47 */
48export function autolink(options: AutolinkOptions): Plugin {
49 return new Plugin({
50 key: new PluginKey("autolink"),
51 appendTransaction: (transactions, oldState, newState) => {
52 /**
53 * Does the transaction change the document?
54 */
55 const docChanges =
56 transactions.some((transaction) => transaction.docChanged) &&
57 !oldState.doc.eq(newState.doc);
58
59 /**
60 * Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`.
61 */
62 const preventAutolink = transactions.some((transaction) =>
63 transaction.getMeta("preventAutolink"),
64 );
65
66 /**
67 * Prevent autolink if the transaction is not a document change
68 * or if the transaction has the meta `preventAutolink`.
69 */
70 if (!docChanges || preventAutolink) {
71 return;
72 }
73
74 const { tr } = newState;
75 const transform = combineTransactionSteps(oldState.doc, [
76 ...transactions,
77 ]);
78 const changes = getChangedRanges(transform);
79
80 changes.forEach(({ newRange }) => {
81 // Now let’s see if we can add new links.
82 const nodesInChangedRanges = findChildrenInRange(
83 newState.doc,
84 newRange,
85 (node) => node.isTextblock,
86 );
87
88 let textBlock: NodeWithPos | undefined;
89 let textBeforeWhitespace: string | undefined;
90
91 if (nodesInChangedRanges.length > 1) {
92 // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
93 textBlock = nodesInChangedRanges[0];
94 textBeforeWhitespace = newState.doc.textBetween(
95 textBlock.pos,
96 textBlock.pos + textBlock.node.nodeSize,
97 undefined,
98 " ",
99 );
100 } else if (
101 nodesInChangedRanges.length &&
102 // We want to make sure to include the block seperator argument to treat hard breaks like spaces.
103 newState.doc
104 .textBetween(newRange.from, newRange.to, " ", " ")
105 .endsWith(" ")
106 ) {
107 textBlock = nodesInChangedRanges[0];
108 textBeforeWhitespace = newState.doc.textBetween(
109 textBlock.pos,
110 newRange.to,
111 undefined,
112 " ",
113 );
114 }
115
116 if (textBlock && textBeforeWhitespace) {
117 const wordsBeforeWhitespace = textBeforeWhitespace
118 .split(" ")
119 .filter((s) => s !== "");
120
121 if (wordsBeforeWhitespace.length <= 0) {
122 return false;
123 }
124
125 const lastWordBeforeSpace =
126 wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
127 const lastWordAndBlockOffset =
128 textBlock.pos +
129 textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
130
131 if (!lastWordBeforeSpace) {
132 return false;
133 }
134
135 const linksBeforeSpace = tokenize(lastWordBeforeSpace).map((t) =>
136 t.toObject(options.defaultProtocol),
137 );
138
139 if (!isValidLinkStructure(linksBeforeSpace)) {
140 return false;
141 }
142
143 linksBeforeSpace
144 .filter((link) => link.isLink)
145 // Calculate link position.
146 .map((link) => ({
147 ...link,
148 from: lastWordAndBlockOffset + link.start + 1,
149 to: lastWordAndBlockOffset + link.end + 1,
150 }))
151 // ignore link inside code mark
152 .filter((link) => {
153 if (!newState.schema.marks.code) {
154 return true;
155 }
156
157 return !newState.doc.rangeHasMark(
158 link.from,
159 link.to,
160 newState.schema.marks.code,
161 );
162 })
163 // check whether should autolink
164 .filter((link) => options.shouldAutoLink(link.value))
165 // Add link mark.
166 .forEach((link) => {
167 if (
168 getMarksBetween(link.from, link.to, newState.doc).some(
169 (item) => item.mark.type === options.type,
170 )
171 ) {
172 return;
173 }
174
175 tr.addMark(
176 link.from,
177 link.to,
178 options.type.create({
179 href: link.href,
180 }),
181 );
182 });
183 }
184 });
185
186 if (!tr.steps.length) {
187 return;
188 }
189
190 return tr;
191 },
192 });
193}