112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
import { find, test } from 'linkifyjs'
|
||
import { MarkType } from 'prosemirror-model'
|
||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||
|
||
import {
|
||
combineTransactionSteps,
|
||
findChildrenInRange,
|
||
getChangedRanges,
|
||
getMarksBetween,
|
||
} from '@tiptap/core'
|
||
|
||
type AutolinkOptions = {
|
||
type: MarkType,
|
||
validate?: (url: string) => boolean,
|
||
}
|
||
|
||
export function autolink(options: AutolinkOptions): Plugin {
|
||
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 don’t want to remove links that were set manually
|
||
if (wasLink && !isLink) {
|
||
tr.removeMark(newMark.from, newMark.to, options.type)
|
||
}
|
||
})
|
||
|
||
// now let’s see if we can add new links
|
||
findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
|
||
.forEach(textBlock => {
|
||
// we need to define a placeholder for leaf nodes
|
||
// so that the link position can be calculated correctly
|
||
const text = newState.doc.textBetween(
|
||
textBlock.pos,
|
||
textBlock.pos + textBlock.node.nodeSize,
|
||
undefined,
|
||
' ',
|
||
)
|
||
|
||
find(text)
|
||
.filter(link => link.isLink)
|
||
.filter(link => {
|
||
if (options.validate) {
|
||
return options.validate(link.value)
|
||
}
|
||
|
||
return true
|
||
})
|
||
// calculate link position
|
||
.map(link => ({
|
||
...link,
|
||
from: textBlock.pos + link.start + 1,
|
||
to: textBlock.pos + link.end + 1,
|
||
}))
|
||
// check if link is within the changed range
|
||
.filter(link => {
|
||
const fromIsInRange = newRange.from >= link.from && newRange.from <= link.to
|
||
const toIsInRange = newRange.to >= link.from && newRange.to <= link.to
|
||
|
||
return fromIsInRange || toIsInRange
|
||
})
|
||
// add link mark
|
||
.forEach(link => {
|
||
tr.addMark(link.from, link.to, options.type.create({
|
||
href: link.href,
|
||
}))
|
||
})
|
||
})
|
||
})
|
||
|
||
if (!tr.steps.length) {
|
||
return
|
||
}
|
||
|
||
return tr
|
||
},
|
||
})
|
||
}
|