quill-react-commercial
Version:
A Quill component for React and more maturely.
193 lines (182 loc) • 6.51 kB
text/typescript
// 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<MagicUrlOptions>) {
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;
}
}