421 lines
17 KiB
JavaScript
421 lines
17 KiB
JavaScript
import { combineTransactionSteps, getChangedRanges, findChildrenInRange, getMarksBetween, getAttributes, Mark, mergeAttributes, markPasteRule } from '@tiptap/core';
|
||
import { tokenize, find, registerCustomProtocol, reset } from 'linkifyjs';
|
||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||
|
||
// From DOMPurify
|
||
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.ts
|
||
const UNICODE_WHITESPACE_PATTERN = '[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]';
|
||
const UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
|
||
const UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`);
|
||
const UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, 'g');
|
||
|
||
/**
|
||
* 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) {
|
||
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;
|
||
}
|
||
/**
|
||
* This plugin allows you to automatically add links to your editor.
|
||
* @param options The plugin options
|
||
* @returns The plugin instance
|
||
*/
|
||
function autolink(options) {
|
||
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;
|
||
let textBeforeWhitespace;
|
||
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) {
|
||
const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ');
|
||
if (!UNICODE_WHITESPACE_REGEX_END.test(endText)) {
|
||
return;
|
||
}
|
||
textBlock = nodesInChangedRanges[0];
|
||
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ');
|
||
}
|
||
if (textBlock && textBeforeWhitespace) {
|
||
const wordsBeforeWhitespace = textBeforeWhitespace.split(UNICODE_WHITESPACE_REGEX).filter(Boolean);
|
||
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);
|
||
})
|
||
// validate link
|
||
.filter(link => options.validate(link.value))
|
||
// 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;
|
||
},
|
||
});
|
||
}
|
||
|
||
function clickHandler(options) {
|
||
return new Plugin({
|
||
key: new PluginKey('handleClickLink'),
|
||
props: {
|
||
handleClick: (view, pos, event) => {
|
||
var _a, _b;
|
||
if (event.button !== 0) {
|
||
return false;
|
||
}
|
||
if (!view.editable) {
|
||
return false;
|
||
}
|
||
let a = event.target;
|
||
const els = [];
|
||
while (a.nodeName !== 'DIV') {
|
||
els.push(a);
|
||
a = a.parentNode;
|
||
}
|
||
if (!els.find(value => value.nodeName === 'A')) {
|
||
return false;
|
||
}
|
||
const attrs = getAttributes(view.state, options.type.name);
|
||
const link = event.target;
|
||
const href = (_a = link === null || link === void 0 ? void 0 : link.href) !== null && _a !== void 0 ? _a : attrs.href;
|
||
const target = (_b = link === null || link === void 0 ? void 0 : link.target) !== null && _b !== void 0 ? _b : attrs.target;
|
||
if (link && href) {
|
||
window.open(href, target);
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function pasteHandler(options) {
|
||
return new Plugin({
|
||
key: new PluginKey('handlePasteLink'),
|
||
props: {
|
||
handlePaste: (view, event, slice) => {
|
||
const { state } = view;
|
||
const { selection } = state;
|
||
const { empty } = selection;
|
||
if (empty) {
|
||
return false;
|
||
}
|
||
let textContent = '';
|
||
slice.content.forEach(node => {
|
||
textContent += node.textContent;
|
||
});
|
||
const link = find(textContent, { defaultProtocol: options.defaultProtocol }).find(item => item.isLink && item.value === textContent);
|
||
if (!textContent || !link) {
|
||
return false;
|
||
}
|
||
return options.editor.commands.setMark(options.type, {
|
||
href: link.href,
|
||
});
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi;
|
||
function isAllowedUri(uri, protocols) {
|
||
const allowedProtocols = [
|
||
'http',
|
||
'https',
|
||
'ftp',
|
||
'ftps',
|
||
'mailto',
|
||
'tel',
|
||
'callto',
|
||
'sms',
|
||
'cid',
|
||
'xmpp',
|
||
];
|
||
if (protocols) {
|
||
protocols.forEach(protocol => {
|
||
const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme;
|
||
if (nextProtocol) {
|
||
allowedProtocols.push(nextProtocol);
|
||
}
|
||
});
|
||
}
|
||
return (!uri
|
||
|| uri.replace(UNICODE_WHITESPACE_REGEX_GLOBAL, '').match(new RegExp(
|
||
// eslint-disable-next-line no-useless-escape
|
||
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`, 'i')));
|
||
}
|
||
/**
|
||
* This extension allows you to create links.
|
||
* @see https://www.tiptap.dev/api/marks/link
|
||
*/
|
||
const Link = Mark.create({
|
||
name: 'link',
|
||
priority: 1000,
|
||
keepOnSplit: false,
|
||
exitable: true,
|
||
onCreate() {
|
||
if (this.options.validate && !this.options.shouldAutoLink) {
|
||
// Copy the validate function to the shouldAutoLink option
|
||
this.options.shouldAutoLink = this.options.validate;
|
||
console.warn('The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.');
|
||
}
|
||
this.options.protocols.forEach(protocol => {
|
||
if (typeof protocol === 'string') {
|
||
registerCustomProtocol(protocol);
|
||
return;
|
||
}
|
||
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes);
|
||
});
|
||
},
|
||
onDestroy() {
|
||
reset();
|
||
},
|
||
inclusive() {
|
||
return this.options.autolink;
|
||
},
|
||
addOptions() {
|
||
return {
|
||
openOnClick: true,
|
||
linkOnPaste: true,
|
||
autolink: true,
|
||
protocols: [],
|
||
defaultProtocol: 'http',
|
||
HTMLAttributes: {
|
||
target: '_blank',
|
||
rel: 'noopener noreferrer nofollow',
|
||
class: null,
|
||
},
|
||
isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
|
||
validate: url => !!url,
|
||
shouldAutoLink: url => !!url,
|
||
};
|
||
},
|
||
addAttributes() {
|
||
return {
|
||
href: {
|
||
default: null,
|
||
parseHTML(element) {
|
||
return element.getAttribute('href');
|
||
},
|
||
},
|
||
target: {
|
||
default: this.options.HTMLAttributes.target,
|
||
},
|
||
rel: {
|
||
default: this.options.HTMLAttributes.rel,
|
||
},
|
||
class: {
|
||
default: this.options.HTMLAttributes.class,
|
||
},
|
||
};
|
||
},
|
||
parseHTML() {
|
||
return [
|
||
{
|
||
tag: 'a[href]',
|
||
getAttrs: dom => {
|
||
const href = dom.getAttribute('href');
|
||
// prevent XSS attacks
|
||
if (!href
|
||
|| !this.options.isAllowedUri(href, {
|
||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||
protocols: this.options.protocols,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
})) {
|
||
return false;
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
];
|
||
},
|
||
renderHTML({ HTMLAttributes }) {
|
||
// prevent XSS attacks
|
||
if (!this.options.isAllowedUri(HTMLAttributes.href, {
|
||
defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
|
||
protocols: this.options.protocols,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
})) {
|
||
// strip out the href
|
||
return [
|
||
'a',
|
||
mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }),
|
||
0,
|
||
];
|
||
}
|
||
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||
},
|
||
addCommands() {
|
||
return {
|
||
setLink: attributes => ({ chain }) => {
|
||
const { href } = attributes;
|
||
if (!this.options.isAllowedUri(href, {
|
||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||
protocols: this.options.protocols,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
})) {
|
||
return false;
|
||
}
|
||
return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run();
|
||
},
|
||
toggleLink: attributes => ({ chain }) => {
|
||
const { href } = attributes;
|
||
if (!this.options.isAllowedUri(href, {
|
||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||
protocols: this.options.protocols,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
})) {
|
||
return false;
|
||
}
|
||
return chain()
|
||
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
|
||
.setMeta('preventAutolink', true)
|
||
.run();
|
||
},
|
||
unsetLink: () => ({ chain }) => {
|
||
return chain()
|
||
.unsetMark(this.name, { extendEmptyMarkRange: true })
|
||
.setMeta('preventAutolink', true)
|
||
.run();
|
||
},
|
||
};
|
||
},
|
||
addPasteRules() {
|
||
return [
|
||
markPasteRule({
|
||
find: text => {
|
||
const foundLinks = [];
|
||
if (text) {
|
||
const { protocols, defaultProtocol } = this.options;
|
||
const links = find(text).filter(item => item.isLink
|
||
&& this.options.isAllowedUri(item.value, {
|
||
defaultValidate: href => !!isAllowedUri(href, protocols),
|
||
protocols,
|
||
defaultProtocol,
|
||
}));
|
||
if (links.length) {
|
||
links.forEach(link => foundLinks.push({
|
||
text: link.value,
|
||
data: {
|
||
href: link.href,
|
||
},
|
||
index: link.start,
|
||
}));
|
||
}
|
||
}
|
||
return foundLinks;
|
||
},
|
||
type: this.type,
|
||
getAttributes: match => {
|
||
var _a;
|
||
return {
|
||
href: (_a = match.data) === null || _a === void 0 ? void 0 : _a.href,
|
||
};
|
||
},
|
||
}),
|
||
];
|
||
},
|
||
addProseMirrorPlugins() {
|
||
const plugins = [];
|
||
const { protocols, defaultProtocol } = this.options;
|
||
if (this.options.autolink) {
|
||
plugins.push(autolink({
|
||
type: this.type,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
validate: url => this.options.isAllowedUri(url, {
|
||
defaultValidate: href => !!isAllowedUri(href, protocols),
|
||
protocols,
|
||
defaultProtocol,
|
||
}),
|
||
shouldAutoLink: this.options.shouldAutoLink,
|
||
}));
|
||
}
|
||
if (this.options.openOnClick === true) {
|
||
plugins.push(clickHandler({
|
||
type: this.type,
|
||
}));
|
||
}
|
||
if (this.options.linkOnPaste) {
|
||
plugins.push(pasteHandler({
|
||
editor: this.editor,
|
||
defaultProtocol: this.options.defaultProtocol,
|
||
type: this.type,
|
||
}));
|
||
}
|
||
return plugins;
|
||
},
|
||
});
|
||
|
||
export { Link, Link as default, isAllowedUri, pasteRegex };
|
||
//# sourceMappingURL=index.js.map
|