Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
6.1 kB · 193 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194// copied from https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/src/helpers/autolink.tsimport { 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<ReturnType<MultiToken["toObject"]>>,) { 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; }, });}