// copied from https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/src/helpers/autolink.ts import { combineTransactionSteps, findChildrenInRange, getChangedRanges, getMarksBetween, NodeWithPos, } from "@tiptap/core"; import { MarkType } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { MultiToken, tokenize } from "linkifyjs"; /** * Check if the provided tokens form a valid link structure, which can either be a single link token * or a link token surrounded by parentheses or square brackets. * * This ensures that only complete and valid text is hyperlinked, preventing cases where a valid * top-level domain (TLD) is immediately followed by an invalid character, like a number. For * example, with the `find` method from Linkify, entering `example.com1` would result in * `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize` * method, we can perform more comprehensive validation on the input text. */ function isValidLinkStructure( tokens: Array>, ) { if (tokens.length === 1) { return tokens[0].isLink; } if (tokens.length === 3 && tokens[1].isLink) { return ["()", "[]"].includes(tokens[0].value + tokens[2].value); } return false; } type AutolinkOptions = { type: MarkType; defaultProtocol: string; shouldAutoLink: (url: string) => boolean; }; /** * This plugin allows you to automatically add links to your editor. * @param options The plugin options * @returns The plugin instance */ export function autolink(options: AutolinkOptions): Plugin { return new Plugin({ key: new PluginKey("autolink"), appendTransaction: (transactions, oldState, newState) => { /** * Does the transaction change the document? */ const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); /** * Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`. */ const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"), ); /** * Prevent autolink if the transaction is not a document change * or if the transaction has the meta `preventAutolink`. */ if (!docChanges || preventAutolink) { return; } const { tr } = newState; const transform = combineTransactionSteps(oldState.doc, [ ...transactions, ]); const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { // Now let’s see if we can add new links. const nodesInChangedRanges = findChildrenInRange( newState.doc, newRange, (node) => node.isTextblock, ); let textBlock: NodeWithPos | undefined; let textBeforeWhitespace: string | undefined; if (nodesInChangedRanges.length > 1) { // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). textBlock = nodesInChangedRanges[0]; textBeforeWhitespace = newState.doc.textBetween( textBlock.pos, textBlock.pos + textBlock.node.nodeSize, undefined, " ", ); } else if ( nodesInChangedRanges.length && // We want to make sure to include the block seperator argument to treat hard breaks like spaces. newState.doc .textBetween(newRange.from, newRange.to, " ", " ") .endsWith(" ") ) { textBlock = nodesInChangedRanges[0]; textBeforeWhitespace = newState.doc.textBetween( textBlock.pos, newRange.to, undefined, " ", ); } if (textBlock && textBeforeWhitespace) { const wordsBeforeWhitespace = textBeforeWhitespace .split(" ") .filter((s) => s !== ""); if (wordsBeforeWhitespace.length <= 0) { return false; } const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); if (!lastWordBeforeSpace) { return false; } const linksBeforeSpace = tokenize(lastWordBeforeSpace).map((t) => t.toObject(options.defaultProtocol), ); if (!isValidLinkStructure(linksBeforeSpace)) { return false; } linksBeforeSpace .filter((link) => link.isLink) // Calculate link position. .map((link) => ({ ...link, from: lastWordAndBlockOffset + link.start + 1, to: lastWordAndBlockOffset + link.end + 1, })) // ignore link inside code mark .filter((link) => { if (!newState.schema.marks.code) { return true; } return !newState.doc.rangeHasMark( link.from, link.to, newState.schema.marks.code, ); }) // check whether should autolink .filter((link) => options.shouldAutoLink(link.value)) // Add link mark. .forEach((link) => { if ( getMarksBetween(link.from, link.to, newState.doc).some( (item) => item.mark.type === options.type, ) ) { return; } tr.addMark( link.from, link.to, options.type.create({ href: link.href, }), ); }); } }); if (!tr.steps.length) { return; } return tr; }, }); }