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}