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:
92
packages/extension-link/src/helpers/autolink.ts
Normal file
92
packages/extension-link/src/helpers/autolink.ts
Normal 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 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 => {
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
27
packages/extension-link/src/helpers/clickHandler.ts
Normal file
27
packages/extension-link/src/helpers/clickHandler.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
44
packages/extension-link/src/helpers/pasteHandler.ts
Normal file
44
packages/extension-link/src/helpers/pasteHandler.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user