Skip to content

Commit

Permalink
fix(extension-link): fix link not being kept when pasting url with li…
Browse files Browse the repository at this point in the history
…nk (#3975)

* fix(extension-link): fix llinks not being kept when pasted text includes url

* fix(extension-link): fix links not being linked correctly on the correct pos

* fix(link): fix pasting behavior and move all to one plugin

* fix(link): dont do custom behavior if no links were pasted

* fix(link): copied text link should be kept

* fix(link): fix autolink overriding pasted links

* fix(link): fix links not pasting the correct link on selected text
  • Loading branch information
bdbch committed Apr 26, 2023
1 parent 0dcc684 commit e7d7d49
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 45 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,8 @@
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}
}
8 changes: 8 additions & 0 deletions demos/src/Marks/Link/React/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ context('/src/Marks/Link/React/', () => {
.should('have.attr', 'href', 'https://example.com')
})

it('detects a pasted URL with query params', () => {
cy.get('.ProseMirror')
.paste({ pastePayload: 'https://example.com?paramA=nice&paramB=cool', pasteType: 'text/plain' })
.find('a')
.should('contain', 'Example Text')
.should('have.attr', 'href', 'https://example.com?paramA=nice&paramB=cool')
})

it('correctly detects multiple pasted URLs', () => {
cy.get('.ProseMirror').paste({
pastePayload:
Expand Down
8 changes: 8 additions & 0 deletions demos/src/Marks/Link/Vue/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ context('/src/Marks/Link/Vue/', () => {
.should('have.attr', 'href', 'https://example.com')
})

it('detects a pasted URL with query params', () => {
cy.get('.ProseMirror')
.paste({ pastePayload: 'https://example.com?paramA=nice&paramB=cool', pasteType: 'text/plain' })
.find('a')
.should('contain', 'Example Text')
.should('have.attr', 'href', 'https://example.com?paramA=nice&paramB=cool')
})

it('correctly detects multiple pasted URLs', () => {
cy.get('.ProseMirror').paste({ pastePayload: 'https://example1.com, https://example2.com/foobar, (http://example3.com/foobar)', pasteType: 'text/plain' })

Expand Down
11 changes: 10 additions & 1 deletion packages/extension-link/src/helpers/autolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function autolink(options: AutolinkOptions): Plugin {
const transform = combineTransactionSteps(oldState.doc, [...transactions])
const { mapping } = transform
const changes = getChangedRanges(transform)
let needsAutolink = true

changes.forEach(({ oldRange, newRange }) => {
// at first we check if we have to remove links
Expand All @@ -51,13 +52,21 @@ export function autolink(options: AutolinkOptions): Plugin {
const wasLink = test(oldLinkText)
const isLink = test(newLinkText)

if (wasLink) {
needsAutolink = false
}

// 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)
tr.removeMark(needsAutolink ? newMark.from : newMark.to - 1, newMark.to, options.type)
}
})

if (!needsAutolink) {
return
}

// now let’s see if we can add new links
const nodesInChangedRanges = findChildrenInRange(
newState.doc,
Expand Down
76 changes: 67 additions & 9 deletions packages/extension-link/src/helpers/pasteHandler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Editor } from '@tiptap/core'
import { MarkType } from '@tiptap/pm/model'
import { Mark, MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { find } from 'linkifyjs'

type PasteHandlerOptions = {
editor: Editor
type: MarkType
linkOnPaste?: boolean
}

export function pasteHandler(options: PasteHandlerOptions): Plugin {
Expand All @@ -15,11 +16,18 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
handlePaste: (view, event, slice) => {
const { state } = view
const { selection } = state
const { empty } = selection

if (empty) {
return false
}
const pastedLinkMarks: Mark[] = []

slice.content.forEach(node => {
node.marks.forEach(mark => {
if (mark.type.name === options.type.name) {
pastedLinkMarks.push(mark)
}
})
})

const hasPastedLink = pastedLinkMarks.length > 0

let textContent = ''

Expand All @@ -29,15 +37,65 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {

const link = find(textContent).find(item => item.isLink && item.value === textContent)

if (!textContent || !link) {
if (!selection.empty && options.linkOnPaste) {
const pastedLink = hasPastedLink ? pastedLinkMarks[0].attrs.href : link?.href || null

if (pastedLink) {
options.editor.commands.setMark(options.type, {
href: pastedLink,
})
return true
}
}

if (slice.content.firstChild?.type.name === 'text' && slice.content.firstChild?.marks.some(mark => mark.type.name === options.type.name)) {
return false
}

options.editor.commands.setMark(options.type, {
href: link.href,
if (link && selection.empty) {
options.editor.commands.insertContent(`<a href="${link.href}">${link.href}</a>`)
return true
}

const { tr } = state
let deleteOnly = false

if (!selection.empty) {
deleteOnly = true
tr.delete(selection.from, selection.to)
}

let currentPos = selection.from

slice.content.forEach(node => {
const fragmentLinks = find(node.textContent)

tr.insert(currentPos - 1, node)

if (fragmentLinks.length > 0) {
deleteOnly = false

fragmentLinks.forEach(fragmentLink => {
const linkStart = currentPos + fragmentLink.start
const linkEnd = currentPos + fragmentLink.end

const hasMark = tr.doc.rangeHasMark(linkStart, linkEnd, options.type)

if (!hasMark) {
tr.addMark(linkStart, linkEnd, options.type.create({ href: fragmentLink.href }))
}
})

}
currentPos += node.nodeSize
})

return true
if (tr.docChanged && !deleteOnly) {
options.editor.view.dispatch(tr)
return true
}

return false
},
},
})
Expand Down
44 changes: 9 additions & 35 deletions packages/extension-link/src/link.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
import { Mark, mergeAttributes } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { find, registerCustomProtocol, reset } from 'linkifyjs'
import { registerCustomProtocol, reset } from 'linkifyjs'

import { autolink } from './helpers/autolink'
import { clickHandler } from './helpers/clickHandler'
Expand Down Expand Up @@ -146,31 +146,6 @@ export const Link = Mark.create<LinkOptions>({
}
},

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 => ({
href: match.data?.href,
}),
}),
]
},

addProseMirrorPlugins() {
const plugins: Plugin[] = []

Expand All @@ -191,14 +166,13 @@ export const Link = Mark.create<LinkOptions>({
)
}

if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type,
}),
)
}
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type,
linkOnPaste: this.options.linkOnPaste,
}),
)

return plugins
},
Expand Down

0 comments on commit e7d7d49

Please sign in to comment.