feat: Add support for autolink (#2226)

* wip

* WIP

* add autolink implementation

* refactoring

* set keepOnSplit to false

* refactoring

* improve changed ranges detection

* move some helpers into core

Co-authored-by: Philipp Kühn <philippkuehn@MacBook-Pro-von-Philipp.local>
This commit is contained in:
Philipp Kühn
2021-12-03 08:53:58 +01:00
committed by GitHub
parent 40a9404c94
commit 3d68981b47
15 changed files with 366 additions and 82 deletions

View File

@@ -25,6 +25,7 @@
},
"dependencies": {
"linkifyjs": "^3.0.4",
"prosemirror-model": "^1.15.0",
"prosemirror-state": "^1.3.4"
},
"repository": {

View File

@@ -0,0 +1,92 @@
import {
getMarksBetween,
findChildrenInRange,
combineTransactionSteps,
getChangedRanges,
} from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { MarkType } from 'prosemirror-model'
import { find, test } from 'linkifyjs'
type AutolinkOptions = {
type: MarkType,
}
export default 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)
if (!docChanges) {
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)
const newLinkText = newState.doc.textBetween(newMark.from, newMark.to)
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
findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
.forEach(textBlock => {
find(textBlock.node.textContent)
.filter(link => link.isLink)
// 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
},
})
}

View File

@@ -0,0 +1,27 @@
import { getAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { MarkType } from 'prosemirror-model'
type ClickHandlerOptions = {
type: MarkType,
}
export default function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey('handleClickLink'),
props: {
handleClick: (view, pos, event) => {
const attrs = getAttributes(view.state, options.type.name)
const link = (event.target as HTMLElement)?.closest('a')
if (link && attrs.href) {
window.open(attrs.href, attrs.target)
return true
}
return false
},
},
})
}

View File

@@ -0,0 +1,44 @@
import { Editor } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { MarkType } from 'prosemirror-model'
import { find } from 'linkifyjs'
type PasteHandlerOptions = {
editor: Editor,
type: MarkType,
}
export default function pasteHandler(options: PasteHandlerOptions): Plugin {
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
},
},
})
}

View File

@@ -1,12 +1,14 @@
import {
Mark,
markPasteRule,
mergeAttributes,
} from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
import { find } from 'linkifyjs'
import autolink from './helpers/autolink'
import clickHandler from './helpers/clickHandler'
import pasteHandler from './helpers/pasteHandler'
export interface LinkOptions {
/**
* If enabled, it adds links as you type.
*/
autolink: boolean,
/**
* If enabled, links will be opened on click.
*/
@@ -45,12 +47,17 @@ export const Link = Mark.create<LinkOptions>({
priority: 1000,
inclusive: false,
keepOnSplit: false,
inclusive() {
return this.options.autolink
},
addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
@@ -76,7 +83,11 @@ export const Link = Mark.create<LinkOptions>({
},
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
return [
'a',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
addCommands() {
@@ -84,9 +95,11 @@ export const Link = Mark.create<LinkOptions>({
setLink: attributes => ({ commands }) => {
return commands.setMark(this.name, attributes)
},
toggleLink: attributes => ({ commands }) => {
return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
},
unsetLink: () => ({ commands }) => {
return commands.unsetMark(this.name, { extendEmptyMarkRange: true })
},
@@ -114,64 +127,23 @@ export const Link = Mark.create<LinkOptions>({
addProseMirrorPlugins() {
const plugins = []
if (this.options.autolink) {
plugins.push(autolink({
type: this.type,
}))
}
if (this.options.openOnClick) {
plugins.push(
new Plugin({
key: new PluginKey('handleClickLink'),
props: {
handleClick: (view, pos, event) => {
const attrs = this.editor.getAttributes(this.name)
const link = (event.target as HTMLElement)?.closest('a')
if (link && attrs.href) {
window.open(attrs.href, attrs.target)
return true
}
return false
},
},
}),
)
plugins.push(clickHandler({
type: this.type,
}))
}
if (this.options.linkOnPaste) {
plugins.push(
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
}
this.editor.commands.setMark(this.type, {
href: link.href,
})
return true
},
},
}),
)
plugins.push(pasteHandler({
editor: this.editor,
type: this.type,
}))
}
return plugins