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:
@@ -3,8 +3,8 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
|||||||
import Document from '@tiptap/extension-document'
|
import Document from '@tiptap/extension-document'
|
||||||
import Paragraph from '@tiptap/extension-paragraph'
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
import Text from '@tiptap/extension-text'
|
import Text from '@tiptap/extension-text'
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import Code from '@tiptap/extension-code'
|
import Code from '@tiptap/extension-code'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
@@ -13,10 +13,10 @@ export default () => {
|
|||||||
Document,
|
Document,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Text,
|
Text,
|
||||||
|
Code,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
}),
|
}),
|
||||||
Code,
|
|
||||||
],
|
],
|
||||||
content: `
|
content: `
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { Editor, EditorContent } from '@tiptap/vue-3'
|
|||||||
import Document from '@tiptap/extension-document'
|
import Document from '@tiptap/extension-document'
|
||||||
import Paragraph from '@tiptap/extension-paragraph'
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
import Text from '@tiptap/extension-text'
|
import Text from '@tiptap/extension-text'
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import Code from '@tiptap/extension-code'
|
import Code from '@tiptap/extension-code'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -35,10 +35,10 @@ export default {
|
|||||||
Document,
|
Document,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Text,
|
Text,
|
||||||
|
Code,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
}),
|
}),
|
||||||
Code,
|
|
||||||
],
|
],
|
||||||
content: `
|
content: `
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ npm install @tiptap/extension-link
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
### HTMLAttributes
|
### autolink
|
||||||
Custom HTML attributes that should be added to the rendered HTML tag.
|
If enabled, it adds links as you type.
|
||||||
|
|
||||||
|
Default: `true`
|
||||||
|
|
||||||
```js
|
```js
|
||||||
Link.configure({
|
Link.configure({
|
||||||
HTMLAttributes: {
|
autolink: false,
|
||||||
class: 'my-custom-class',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -53,6 +53,16 @@ Link.configure({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### HTMLAttributes
|
||||||
|
Custom HTML attributes that should be added to the rendered HTML tag.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Link.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'my-custom-class',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
18
packages/core/src/helpers/combineTransactionSteps.ts
Normal file
18
packages/core/src/helpers/combineTransactionSteps.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Node as ProseMirrorNode } from 'prosemirror-model'
|
||||||
|
import { Transaction } from 'prosemirror-state'
|
||||||
|
import { Transform } from 'prosemirror-transform'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new `Transform` based on all steps of the passed transactions.
|
||||||
|
*/
|
||||||
|
export default function combineTransactionSteps(oldDoc: ProseMirrorNode, transactions: Transaction[]): Transform {
|
||||||
|
const transform = new Transform(oldDoc)
|
||||||
|
|
||||||
|
transactions.forEach(transaction => {
|
||||||
|
transaction.steps.forEach(step => {
|
||||||
|
transform.step(step)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return transform
|
||||||
|
}
|
||||||
82
packages/core/src/helpers/getChangedRanges.ts
Normal file
82
packages/core/src/helpers/getChangedRanges.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Transform, Step } from 'prosemirror-transform'
|
||||||
|
import { Range } from '../types'
|
||||||
|
import removeDuplicates from '../utilities/removeDuplicates'
|
||||||
|
|
||||||
|
export type ChangedRange = {
|
||||||
|
oldRange: Range,
|
||||||
|
newRange: Range,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicated ranges and ranges that are
|
||||||
|
* fully captured by other ranges.
|
||||||
|
*/
|
||||||
|
function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] {
|
||||||
|
const uniqueChanges = removeDuplicates(changes)
|
||||||
|
|
||||||
|
return uniqueChanges.length === 1
|
||||||
|
? uniqueChanges
|
||||||
|
: uniqueChanges.filter((change, index) => {
|
||||||
|
const rest = uniqueChanges.filter((_, i) => i !== index)
|
||||||
|
|
||||||
|
return !rest.some(otherChange => {
|
||||||
|
return change.oldRange.from >= otherChange.oldRange.from
|
||||||
|
&& change.oldRange.to <= otherChange.oldRange.to
|
||||||
|
&& change.newRange.from >= otherChange.newRange.from
|
||||||
|
&& change.newRange.to <= otherChange.newRange.to
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of changed ranges
|
||||||
|
* based on the first and last state of all steps.
|
||||||
|
*/
|
||||||
|
export default function getChangedRanges(transform: Transform): ChangedRange[] {
|
||||||
|
const { mapping, steps } = transform
|
||||||
|
const changes: ChangedRange[] = []
|
||||||
|
|
||||||
|
mapping.maps.forEach((stepMap, index) => {
|
||||||
|
const ranges: Range[] = []
|
||||||
|
|
||||||
|
// This accounts for step changes where no range was actually altered
|
||||||
|
// e.g. when setting a mark, node attribute, etc.
|
||||||
|
// @ts-ignore
|
||||||
|
if (!stepMap.ranges.length) {
|
||||||
|
const { from, to } = steps[index] as Step & {
|
||||||
|
from?: number,
|
||||||
|
to?: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === undefined || to === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.push({ from, to })
|
||||||
|
} else {
|
||||||
|
stepMap.forEach((from, to) => {
|
||||||
|
ranges.push({ from, to })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.forEach(({ from, to }) => {
|
||||||
|
const newStart = mapping.slice(index).map(from, -1)
|
||||||
|
const newEnd = mapping.slice(index).map(to)
|
||||||
|
const oldStart = mapping.invert().map(newStart, -1)
|
||||||
|
const oldEnd = mapping.invert().map(newEnd)
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
oldRange: {
|
||||||
|
from: oldStart,
|
||||||
|
to: oldEnd,
|
||||||
|
},
|
||||||
|
newRange: {
|
||||||
|
from: newStart,
|
||||||
|
to: newEnd,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return simplifyChangedRanges(changes)
|
||||||
|
}
|
||||||
@@ -1,16 +1,37 @@
|
|||||||
import { EditorState } from 'prosemirror-state'
|
import { Node as ProseMirrorNode } from 'prosemirror-model'
|
||||||
import { MarkRange } from '../types'
|
import { MarkRange } from '../types'
|
||||||
|
import getMarkRange from './getMarkRange'
|
||||||
|
|
||||||
export default function getMarksBetween(from: number, to: number, state: EditorState): MarkRange[] {
|
export default function getMarksBetween(from: number, to: number, doc: ProseMirrorNode): MarkRange[] {
|
||||||
const marks: MarkRange[] = []
|
const marks: MarkRange[] = []
|
||||||
|
|
||||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
// get all inclusive marks on empty selection
|
||||||
|
if (from === to) {
|
||||||
|
doc
|
||||||
|
.resolve(from)
|
||||||
|
.marks()
|
||||||
|
.forEach(mark => {
|
||||||
|
const $pos = doc.resolve(from - 1)
|
||||||
|
const range = getMarkRange($pos, mark.type)
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
marks.push({
|
||||||
|
mark,
|
||||||
|
...range,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
doc.nodesBetween(from, to, (node, pos) => {
|
||||||
marks.push(...node.marks.map(mark => ({
|
marks.push(...node.marks.map(mark => ({
|
||||||
from: pos,
|
from: pos,
|
||||||
to: pos + node.nodeSize,
|
to: pos + node.nodeSize,
|
||||||
mark,
|
mark,
|
||||||
})))
|
})))
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return marks
|
return marks
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export { default as textPasteRule } from './pasteRules/textPasteRule'
|
|||||||
export { default as callOrReturn } from './utilities/callOrReturn'
|
export { default as callOrReturn } from './utilities/callOrReturn'
|
||||||
export { default as mergeAttributes } from './utilities/mergeAttributes'
|
export { default as mergeAttributes } from './utilities/mergeAttributes'
|
||||||
|
|
||||||
|
export { default as combineTransactionSteps } from './helpers/combineTransactionSteps'
|
||||||
export { default as defaultBlockAt } from './helpers/defaultBlockAt'
|
export { default as defaultBlockAt } from './helpers/defaultBlockAt'
|
||||||
export { default as getExtensionField } from './helpers/getExtensionField'
|
export { default as getExtensionField } from './helpers/getExtensionField'
|
||||||
export { default as findChildren } from './helpers/findChildren'
|
export { default as findChildren } from './helpers/findChildren'
|
||||||
@@ -32,6 +33,7 @@ export { default as findParentNodeClosestToPos } from './helpers/findParentNodeC
|
|||||||
export { default as generateHTML } from './helpers/generateHTML'
|
export { default as generateHTML } from './helpers/generateHTML'
|
||||||
export { default as generateJSON } from './helpers/generateJSON'
|
export { default as generateJSON } from './helpers/generateJSON'
|
||||||
export { default as generateText } from './helpers/generateText'
|
export { default as generateText } from './helpers/generateText'
|
||||||
|
export { default as getChangedRanges } from './helpers/getChangedRanges'
|
||||||
export { default as getSchema } from './helpers/getSchema'
|
export { default as getSchema } from './helpers/getSchema'
|
||||||
export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment'
|
export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment'
|
||||||
export { default as getDebugJSON } from './helpers/getDebugJSON'
|
export { default as getDebugJSON } from './helpers/getDebugJSON'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function markInputRule(config: {
|
|||||||
const textStart = range.from + fullMatch.indexOf(captureGroup)
|
const textStart = range.from + fullMatch.indexOf(captureGroup)
|
||||||
const textEnd = textStart + captureGroup.length
|
const textEnd = textStart + captureGroup.length
|
||||||
|
|
||||||
const excludedMarks = getMarksBetween(range.from, range.to, state)
|
const excludedMarks = getMarksBetween(range.from, range.to, state.doc)
|
||||||
.filter(item => {
|
.filter(item => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const excluded = item.mark.type.excluded as MarkType[]
|
const excluded = item.mark.type.excluded as MarkType[]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function markPasteRule(config: {
|
|||||||
const textStart = range.from + fullMatch.indexOf(captureGroup)
|
const textStart = range.from + fullMatch.indexOf(captureGroup)
|
||||||
const textEnd = textStart + captureGroup.length
|
const textEnd = textStart + captureGroup.length
|
||||||
|
|
||||||
const excludedMarks = getMarksBetween(range.from, range.to, state)
|
const excludedMarks = getMarksBetween(range.from, range.to, state.doc)
|
||||||
.filter(item => {
|
.filter(item => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const excluded = item.mark.type.excluded as MarkType[]
|
const excluded = item.mark.type.excluded as MarkType[]
|
||||||
|
|||||||
15
packages/core/src/utilities/removeDuplicates.ts
Normal file
15
packages/core/src/utilities/removeDuplicates.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Removes duplicated values within an array.
|
||||||
|
* Supports numbers, strings and objects.
|
||||||
|
*/
|
||||||
|
export default function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
|
||||||
|
const seen: Record<any, any> = {}
|
||||||
|
|
||||||
|
return array.filter(item => {
|
||||||
|
const key = by(item)
|
||||||
|
|
||||||
|
return Object.prototype.hasOwnProperty.call(seen, key)
|
||||||
|
? false
|
||||||
|
: (seen[key] = true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"linkifyjs": "^3.0.4",
|
"linkifyjs": "^3.0.4",
|
||||||
|
"prosemirror-model": "^1.15.0",
|
||||||
"prosemirror-state": "^1.3.4"
|
"prosemirror-state": "^1.3.4"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
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 {
|
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
|
||||||
Mark,
|
|
||||||
markPasteRule,
|
|
||||||
mergeAttributes,
|
|
||||||
} from '@tiptap/core'
|
|
||||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
|
||||||
import { find } from 'linkifyjs'
|
import { find } from 'linkifyjs'
|
||||||
|
import autolink from './helpers/autolink'
|
||||||
|
import clickHandler from './helpers/clickHandler'
|
||||||
|
import pasteHandler from './helpers/pasteHandler'
|
||||||
|
|
||||||
export interface LinkOptions {
|
export interface LinkOptions {
|
||||||
|
/**
|
||||||
|
* If enabled, it adds links as you type.
|
||||||
|
*/
|
||||||
|
autolink: boolean,
|
||||||
/**
|
/**
|
||||||
* If enabled, links will be opened on click.
|
* If enabled, links will be opened on click.
|
||||||
*/
|
*/
|
||||||
@@ -45,12 +47,17 @@ export const Link = Mark.create<LinkOptions>({
|
|||||||
|
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
|
|
||||||
inclusive: false,
|
keepOnSplit: false,
|
||||||
|
|
||||||
|
inclusive() {
|
||||||
|
return this.options.autolink
|
||||||
|
},
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
linkOnPaste: true,
|
linkOnPaste: true,
|
||||||
|
autolink: true,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel: 'noopener noreferrer nofollow',
|
rel: 'noopener noreferrer nofollow',
|
||||||
@@ -76,7 +83,11 @@ export const Link = Mark.create<LinkOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
return [
|
||||||
|
'a',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
0,
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
@@ -84,9 +95,11 @@ export const Link = Mark.create<LinkOptions>({
|
|||||||
setLink: attributes => ({ commands }) => {
|
setLink: attributes => ({ commands }) => {
|
||||||
return commands.setMark(this.name, attributes)
|
return commands.setMark(this.name, attributes)
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleLink: attributes => ({ commands }) => {
|
toggleLink: attributes => ({ commands }) => {
|
||||||
return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
|
return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
unsetLink: () => ({ commands }) => {
|
unsetLink: () => ({ commands }) => {
|
||||||
return commands.unsetMark(this.name, { extendEmptyMarkRange: true })
|
return commands.unsetMark(this.name, { extendEmptyMarkRange: true })
|
||||||
},
|
},
|
||||||
@@ -114,64 +127,23 @@ export const Link = Mark.create<LinkOptions>({
|
|||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const plugins = []
|
const plugins = []
|
||||||
|
|
||||||
if (this.options.openOnClick) {
|
if (this.options.autolink) {
|
||||||
plugins.push(
|
plugins.push(autolink({
|
||||||
new Plugin({
|
type: this.type,
|
||||||
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
|
if (this.options.openOnClick) {
|
||||||
},
|
plugins.push(clickHandler({
|
||||||
},
|
type: this.type,
|
||||||
}),
|
}))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.linkOnPaste) {
|
if (this.options.linkOnPaste) {
|
||||||
plugins.push(
|
plugins.push(pasteHandler({
|
||||||
new Plugin({
|
editor: this.editor,
|
||||||
key: new PluginKey('handlePasteLink'),
|
type: this.type,
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|||||||
Reference in New Issue
Block a user