// Fork and Edit from 'quill-magic-url' import Delta from 'quill-delta'; import normalizeUrl, { Options as NormalizeUrlOptions } from 'normalize-url'; import Quill from 'quill'; declare global { interface Window { Quill?: typeof Quill; } } export type MagicUrlOptions = { globalRegularExpression: RegExp; urlRegularExpression: RegExp; globalMailRegularExpression: RegExp; mailRegularExpression: RegExp; normalizeRegularExpression: RegExp; normalizeUrlOptions: NormalizeUrlOptions; }; export type Normalizer = (stringToNormalize: string) => string; const defaults = { globalRegularExpression: /(https?:\/\/|www\.)[\w-\.]+\.[\w-\.]+(\/([\S]+)?)?/gi, urlRegularExpression: /(https?:\/\/|www\.)[\w-\.]+\.[\w-\.]+(\/([\S]+)?)?/gi, globalMailRegularExpression: /([\w-\.]+@[\w-\.]+\.[\w-\.]+)/gi, mailRegularExpression: /([\w-\.]+@[\w-\.]+\.[\w-\.]+)/gi, normalizeRegularExpression: /(https?:\/\/|www\.)[\S]+/i, normalizeUrlOptions: { stripWWW: false, }, }; export class MagicUrl { quill: Quill; options: MagicUrlOptions; urlNormalizer: Normalizer; mailNormalizer: Normalizer; constructor(quill: Quill, options?: Partial) { this.quill = quill; options = options || {}; this.options = { ...defaults, ...options }; this.urlNormalizer = (url) => this.normalize(url); this.mailNormalizer = (mail) => `mailto:${mail}`; this.registerTypeListener(); this.registerPasteListener(); this.registerBlurListener(); } registerPasteListener() { // Preserves existing links this.quill.clipboard.addMatcher('A', (node, delta) => { const href = node.getAttribute('href'); const attributes = delta.ops[0]?.attributes; if (attributes?.link != null) { attributes.link = href; } return delta; }); this.quill.clipboard.addMatcher(Node.TEXT_NODE, (node, delta): Delta => { if (typeof node.data !== 'string') { return undefined as unknown as Delta; } const urlRegExp = this.options.globalRegularExpression; const mailRegExp = this.options.globalMailRegularExpression; urlRegExp.lastIndex = 0; mailRegExp.lastIndex = 0; const newDelta = new Delta(); let index = 0; let urlResult = urlRegExp.exec(node.data); let mailResult = mailRegExp.exec(node.data); const handleMatch = (result: RegExpExecArray, regExp: RegExp, normalizer: Normalizer) => { const head = node.data.substring(index, result.index); newDelta.insert(head); const match = result[0]; newDelta.insert(match, { link: normalizer(match) }); index = regExp.lastIndex; return regExp.exec(node.data); }; while (urlResult !== null || mailResult !== null) { if (urlResult === null) { if (mailResult) mailResult = handleMatch(mailResult, mailRegExp, this.mailNormalizer); } else if (mailResult === null) { urlResult = handleMatch(urlResult, urlRegExp, this.urlNormalizer); } else if (mailResult.index <= urlResult.index) { while (urlResult !== null && urlResult.index < mailRegExp.lastIndex) { urlResult = urlRegExp.exec(node.data); } mailResult = handleMatch(mailResult, mailRegExp, this.mailNormalizer); } else { while (mailResult !== null && mailResult.index < urlRegExp.lastIndex) { mailResult = mailRegExp.exec(node.data); } urlResult = handleMatch(urlResult, urlRegExp, this.urlNormalizer); } } if (index > 0) { const tail = node.data.substring(index); newDelta.insert(tail); if (delta) delta!.ops = newDelta.ops; } return delta; }); } registerTypeListener() { this.quill.on('text-change', (delta) => { const ops = delta.ops; // Only return true, if last operation includes whitespace inserts // Equivalent to listening for enter, tab or space if (!ops || ops.length < 1 || ops.length > 2) { return; } const lastOp = ops[ops.length - 1]; if (!lastOp.insert || typeof lastOp.insert !== 'string' || !lastOp.insert.match(/\s/)) { return; } this.checkTextForUrl(!!lastOp.insert.match(/ |\t/)); }); } registerBlurListener() { this.quill.root.addEventListener('blur', () => { this.checkTextForUrl(); }); } checkTextForUrl(triggeredByInlineWhitespace = false) { const sel = this.quill.getSelection(); if (!sel) { return; } const [leaf] = this.quill.getLeaf(sel.index); const leafIndex = this.quill.getIndex(leaf); if (!leaf.text) { return; } // We only care about the leaf until the current cursor position const relevantLength = sel.index - leafIndex; const text: string = leaf.text.slice(0, relevantLength); if (!text || leaf.parent.domNode.localName === 'a') { return; } const nextLetter = leaf.text[relevantLength]; // Do not proceed if we are in the middle of a word if (nextLetter != null && nextLetter.match(/\S/)) { return; } const bailOutEndingRegex = triggeredByInlineWhitespace ? /\s\s$/ : /\s$/; if (text.match(bailOutEndingRegex)) { return; } const urlMatches = text.match(this.options.urlRegularExpression); const mailMatches = text.match(this.options.mailRegularExpression); if (urlMatches) { this.handleMatches(leafIndex, text, urlMatches, this.urlNormalizer); } else if (mailMatches) { this.handleMatches(leafIndex, text, mailMatches, this.mailNormalizer); } } handleMatches( leafIndex: number, text: string, matches: RegExpMatchArray, normalizer: Normalizer, ) { const match = matches.pop(); if (match) { const matchIndex = text.lastIndexOf(match); const after = text.split(match).pop(); if (after && after.match(/\S/)) { return; } this.updateText(leafIndex + matchIndex, match.trim(), normalizer); } } updateText(index: number, string: string, normalizer: Normalizer) { const ops = new Delta().retain(index).retain(string.length, { link: normalizer(string) }); this.quill.updateContents(ops); } normalize(url: string) { if (this.options.normalizeRegularExpression.test(url)) { try { return normalizeUrl(url, this.options.normalizeUrlOptions); } catch (error) { console.error(error); } } return url; } }