You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

268 lines
10 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { combineTransactionSteps, getChangedRanges, getMarksBetween, findChildrenInRange, getAttributes, Mark, mergeAttributes, markPasteRule } from '@tiptap/core';
import { test, find, registerCustomProtocol, reset } from 'linkifyjs';
import { Plugin, PluginKey } from 'prosemirror-state';
function autolink(options) {
return new Plugin({
key: new PluginKey('autolink'),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some(transaction => transaction.docChanged)
&& !oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some(transaction => transaction.getMeta('preventAutolink'));
if (!docChanges || preventAutolink) {
return;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const { mapping } = transform;
const changes = getChangedRanges(transform);
changes.forEach(({ oldRange, newRange }) => {
// at first we check if we have to remove links
getMarksBetween(oldRange.from, oldRange.to, oldState.doc)
.filter(item => item.mark.type === options.type)
.forEach(oldMark => {
const newFrom = mapping.map(oldMark.from);
const newTo = mapping.map(oldMark.to);
const newMarks = getMarksBetween(newFrom, newTo, newState.doc)
.filter(item => item.mark.type === options.type);
if (!newMarks.length) {
return;
}
const newMark = newMarks[0];
const oldLinkText = oldState.doc.textBetween(oldMark.from, oldMark.to, undefined, ' ');
const newLinkText = newState.doc.textBetween(newMark.from, newMark.to, undefined, ' ');
const wasLink = test(oldLinkText);
const isLink = test(newLinkText);
// remove only the link, if it was a link before too
// because we dont want to remove links that were set manually
if (wasLink && !isLink) {
tr.removeMark(newMark.from, newMark.to, options.type);
}
});
// now lets 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
// 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;
}
find(lastWordBeforeSpace)
.filter(link => link.isLink)
.filter(link => {
if (options.validate) {
return options.validate(link.value);
}
return true;
})
// calculate link position
.map(link => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// add link mark
.forEach(link => {
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;
const attrs = getAttributes(view.state, options.type.name);
const link = (_a = event.target) === null || _a === void 0 ? void 0 : _a.closest('a');
if (link && attrs.href) {
window.open(attrs.href, attrs.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).find(item => item.isLink && item.value === textContent);
if (!textContent || !link) {
return false;
}
options.editor.commands.setMark(options.type, {
href: link.href,
});
return true;
},
},
});
}
const Link = Mark.create({
name: 'link',
priority: 1000,
keepOnSplit: false,
onCreate() {
this.options.protocols.forEach(registerCustomProtocol);
},
onDestroy() {
reset();
},
inclusive() {
return this.options.autolink;
},
addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
protocols: [],
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
validate: undefined,
};
},
addAttributes() {
return {
href: {
default: null,
},
target: {
default: this.options.HTMLAttributes.target,
},
class: {
default: this.options.HTMLAttributes.class,
},
};
},
parseHTML() {
return [
{ tag: 'a[href]:not([href *= "javascript:" i])' },
];
},
renderHTML({ HTMLAttributes }) {
return [
'a',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setLink: attributes => ({ chain }) => {
return chain()
.setMark(this.name, attributes)
.setMeta('preventAutolink', true)
.run();
},
toggleLink: attributes => ({ chain }) => {
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 => find(text)
.filter(link => {
if (this.options.validate) {
return this.options.validate(link.value);
}
return true;
})
.filter(link => link.isLink)
.map(link => ({
text: link.value,
index: link.start,
data: link,
})),
type: this.type,
getAttributes: match => {
var _a;
return ({
href: (_a = match.data) === null || _a === void 0 ? void 0 : _a.href,
});
},
}),
];
},
addProseMirrorPlugins() {
const plugins = [];
if (this.options.autolink) {
plugins.push(autolink({
type: this.type,
validate: this.options.validate,
}));
}
if (this.options.openOnClick) {
plugins.push(clickHandler({
type: this.type,
}));
}
if (this.options.linkOnPaste) {
plugins.push(pasteHandler({
editor: this.editor,
type: this.type,
}));
}
return plugins;
},
});
export { Link, Link as default };
//# sourceMappingURL=tiptap-extension-link.esm.js.map