From 2c74441688a18e16024c6f4d450d5cd7de3430cb Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 19 Apr 2023 10:19:00 +0200 Subject: [PATCH 1/7] fix(extension-link): fix llinks not being kept when pasted text includes url --- .../src/helpers/linkifyPaste.ts | 49 +++++++++++++++++++ packages/extension-link/src/link.ts | 35 +++---------- 2 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 packages/extension-link/src/helpers/linkifyPaste.ts diff --git a/packages/extension-link/src/helpers/linkifyPaste.ts b/packages/extension-link/src/helpers/linkifyPaste.ts new file mode 100644 index 00000000000..17db3a31010 --- /dev/null +++ b/packages/extension-link/src/helpers/linkifyPaste.ts @@ -0,0 +1,49 @@ +import { Editor } from '@tiptap/core' +import { MarkType } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { find } from 'linkifyjs' + +export type LinkifyPastePluginOptions = { + editor: Editor + type: MarkType +} + +export const LinkifyPastePlugin = ({ editor, type }: LinkifyPastePluginOptions) => { + return new Plugin({ + key: new PluginKey('linkifyPaste'), + + props: { + handlePaste(view, event, slice) { + let linkHref: string | null = null + let link = null + + // this only needs to run the linkify if the slice contains one text node + // that is not already a link & the text is a valid URL + slice.content.forEach(node => { + link = find(node.textContent).find(item => item.isLink) + + if (link && node.marks.some(mark => mark.type === type)) { + return + } + + const text = node.textContent + + if (!text || !link) { + return + } + + linkHref = link.href + }) + + if (!linkHref || !link) { + return false + } + + // handle pasting of links + editor.chain().insertContent(`${slice.content.child(0).textContent}`).focus().run() + + return true + }, + }, + }) +} diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index e7898dea132..7570df22acc 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -1,9 +1,10 @@ -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' +import { LinkifyPastePlugin } from './helpers/linkifyPaste' import { pasteHandler } from './helpers/pasteHandler' export interface LinkProtocolOptions { @@ -146,34 +147,14 @@ export const Link = Mark.create({ } }, - 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[] = [] + plugins.push(LinkifyPastePlugin({ + editor: this.editor, + type: this.type, + })) + if (this.options.autolink) { plugins.push( autolink({ From 3da8da9ceada9523433a230dcd27e05930f9ccd4 Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 19 Apr 2023 14:31:15 +0200 Subject: [PATCH 2/7] fix(extension-link): fix links not being linked correctly on the correct pos --- .../src/helpers/linkifyPaste.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/extension-link/src/helpers/linkifyPaste.ts b/packages/extension-link/src/helpers/linkifyPaste.ts index 17db3a31010..b72469e7f56 100644 --- a/packages/extension-link/src/helpers/linkifyPaste.ts +++ b/packages/extension-link/src/helpers/linkifyPaste.ts @@ -13,34 +13,34 @@ export const LinkifyPastePlugin = ({ editor, type }: LinkifyPastePluginOptions) key: new PluginKey('linkifyPaste'), props: { - handlePaste(view, event, slice) { - let linkHref: string | null = null - let link = null + handlePaste(_view, _event, slice) { + const { state } = editor + const { tr, selection } = state + + let currentPos = selection.anchor - 1 // this only needs to run the linkify if the slice contains one text node // that is not already a link & the text is a valid URL slice.content.forEach(node => { - link = find(node.textContent).find(item => item.isLink) + const links = find(node.textContent) - if (link && node.marks.some(mark => mark.type === type)) { - return - } + tr.insert(currentPos, node) - const text = node.textContent + links.forEach(link => { + const linkStart = currentPos + link.start + 1 + const linkEnd = currentPos + link.end + 1 - if (!text || !link) { - return - } + const hasMark = tr.doc.rangeHasMark(linkStart, linkEnd, type) - linkHref = link.href - }) + if (!hasMark) { + tr.addMark(currentPos + link.start + 1, currentPos + link.end + 1, type.create({ href: link.href })) + } + }) - if (!linkHref || !link) { - return false - } + currentPos += node.nodeSize + }) - // handle pasting of links - editor.chain().insertContent(`${slice.content.child(0).textContent}`).focus().run() + editor.view.dispatch(tr) return true }, From 79f4060c018ade7fa52ed06221c62ce0df78af6b Mon Sep 17 00:00:00 2001 From: Dominik Biedebach Date: Fri, 21 Apr 2023 09:23:19 +0200 Subject: [PATCH 3/7] fix(link): fix pasting behavior and move all to one plugin --- .../src/helpers/linkifyPaste.ts | 49 ----------------- .../src/helpers/pasteHandler.ts | 55 +++++++++++++++---- packages/extension-link/src/link.ts | 21 +++---- 3 files changed, 52 insertions(+), 73 deletions(-) delete mode 100644 packages/extension-link/src/helpers/linkifyPaste.ts diff --git a/packages/extension-link/src/helpers/linkifyPaste.ts b/packages/extension-link/src/helpers/linkifyPaste.ts deleted file mode 100644 index b72469e7f56..00000000000 --- a/packages/extension-link/src/helpers/linkifyPaste.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Editor } from '@tiptap/core' -import { MarkType } from '@tiptap/pm/model' -import { Plugin, PluginKey } from '@tiptap/pm/state' -import { find } from 'linkifyjs' - -export type LinkifyPastePluginOptions = { - editor: Editor - type: MarkType -} - -export const LinkifyPastePlugin = ({ editor, type }: LinkifyPastePluginOptions) => { - return new Plugin({ - key: new PluginKey('linkifyPaste'), - - props: { - handlePaste(_view, _event, slice) { - const { state } = editor - const { tr, selection } = state - - let currentPos = selection.anchor - 1 - - // this only needs to run the linkify if the slice contains one text node - // that is not already a link & the text is a valid URL - slice.content.forEach(node => { - const links = find(node.textContent) - - tr.insert(currentPos, node) - - links.forEach(link => { - const linkStart = currentPos + link.start + 1 - const linkEnd = currentPos + link.end + 1 - - const hasMark = tr.doc.rangeHasMark(linkStart, linkEnd, type) - - if (!hasMark) { - tr.addMark(currentPos + link.start + 1, currentPos + link.end + 1, type.create({ href: link.href })) - } - }) - - currentPos += node.nodeSize - }) - - editor.view.dispatch(tr) - - return true - }, - }, - }) -} diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts index ed03e6903a1..393b43a51a6 100644 --- a/packages/extension-link/src/helpers/pasteHandler.ts +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -6,6 +6,7 @@ import { find } from 'linkifyjs' type PasteHandlerOptions = { editor: Editor type: MarkType + linkOnPaste?: boolean } export function pasteHandler(options: PasteHandlerOptions): Plugin { @@ -15,11 +16,6 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { handlePaste: (view, event, slice) => { const { state } = view const { selection } = state - const { empty } = selection - - if (empty) { - return false - } let textContent = '' @@ -29,15 +25,54 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { const link = find(textContent).find(item => item.isLink && item.value === textContent) - if (!textContent || !link) { - return false + if (link && !selection.empty && options.linkOnPaste) { + options.editor.commands.setMark(options.type, { + href: link.href, + }) + + return true + } + + if (link && selection.empty) { + options.editor.commands.insertContent(`${link.href}`) + return true } - options.editor.commands.setMark(options.type, { - href: link.href, + const { tr } = state + + if (!selection.empty) { + tr.delete(selection.from, selection.to) + } + + let currentPos = selection.from + + slice.content.forEach(node => { + const fragmentLinks = find(node.textContent) + + if (fragmentLinks.length > 0) { + tr.insert(currentPos - 1, node) + + 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) { + options.editor.view.dispatch(tr) + return true + } + + return false }, }, }) diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index 7570df22acc..dde70e53e9d 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -4,7 +4,6 @@ import { registerCustomProtocol, reset } from 'linkifyjs' import { autolink } from './helpers/autolink' import { clickHandler } from './helpers/clickHandler' -import { LinkifyPastePlugin } from './helpers/linkifyPaste' import { pasteHandler } from './helpers/pasteHandler' export interface LinkProtocolOptions { @@ -150,11 +149,6 @@ export const Link = Mark.create({ addProseMirrorPlugins() { const plugins: Plugin[] = [] - plugins.push(LinkifyPastePlugin({ - editor: this.editor, - type: this.type, - })) - if (this.options.autolink) { plugins.push( autolink({ @@ -172,14 +166,13 @@ export const Link = Mark.create({ ) } - 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 }, From c4127773bbf10ea31cc13a06f40995e264a43f18 Mon Sep 17 00:00:00 2001 From: Dominik Biedebach Date: Fri, 21 Apr 2023 09:46:27 +0200 Subject: [PATCH 4/7] fix(link): dont do custom behavior if no links were pasted --- .vscode/settings.json | 3 +++ demos/src/Marks/Link/React/index.spec.js | 8 ++++++++ demos/src/Marks/Link/Vue/index.spec.js | 8 ++++++++ packages/extension-link/src/helpers/pasteHandler.ts | 5 ++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f89d258263c..77c9a5dc7b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,5 +60,8 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint", "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" } } diff --git a/demos/src/Marks/Link/React/index.spec.js b/demos/src/Marks/Link/React/index.spec.js index db43732b5a0..a34a7c9e99f 100644 --- a/demos/src/Marks/Link/React/index.spec.js +++ b/demos/src/Marks/Link/React/index.spec.js @@ -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¶mB=cool', pasteType: 'text/plain' }) + .find('a') + .should('contain', 'Example Text') + .should('have.attr', 'href', 'https://example.com?paramA=nice¶mB=cool') + }) + it('correctly detects multiple pasted URLs', () => { cy.get('.ProseMirror').paste({ pastePayload: diff --git a/demos/src/Marks/Link/Vue/index.spec.js b/demos/src/Marks/Link/Vue/index.spec.js index 7f80fb60550..a3b33016599 100644 --- a/demos/src/Marks/Link/Vue/index.spec.js +++ b/demos/src/Marks/Link/Vue/index.spec.js @@ -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¶mB=cool', pasteType: 'text/plain' }) + .find('a') + .should('contain', 'Example Text') + .should('have.attr', 'href', 'https://example.com?paramA=nice¶mB=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' }) diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts index 393b43a51a6..9175e9f339a 100644 --- a/packages/extension-link/src/helpers/pasteHandler.ts +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -39,8 +39,10 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { } const { tr } = state + let deleteOnly = false if (!selection.empty) { + deleteOnly = true tr.delete(selection.from, selection.to) } @@ -51,6 +53,7 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { if (fragmentLinks.length > 0) { tr.insert(currentPos - 1, node) + deleteOnly = false fragmentLinks.forEach(fragmentLink => { const linkStart = currentPos + fragmentLink.start @@ -67,7 +70,7 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { } }) - if (tr.docChanged) { + if (tr.docChanged && !deleteOnly) { options.editor.view.dispatch(tr) return true } From 93fbaef1a23dd316b402973b43d8fd8d1f140771 Mon Sep 17 00:00:00 2001 From: Dominik Biedebach Date: Mon, 24 Apr 2023 12:51:43 +0200 Subject: [PATCH 5/7] fix(link): copied text link should be kept --- packages/extension-link/src/helpers/pasteHandler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts index 9175e9f339a..80c73d2ccfb 100644 --- a/packages/extension-link/src/helpers/pasteHandler.ts +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -33,6 +33,10 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { return true } + if (slice.content.firstChild?.type.name === 'text' && slice.content.firstChild?.marks.some(mark => mark.type.name === options.type.name)) { + return false + } + if (link && selection.empty) { options.editor.commands.insertContent(`${link.href}`) return true From a47f1b44149f4477ba42bab6828fe543697754a4 Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 26 Apr 2023 11:06:49 +0200 Subject: [PATCH 6/7] fix(link): fix autolink overriding pasted links --- packages/extension-link/src/helpers/autolink.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts index 123a610c29b..643a76f0a61 100644 --- a/packages/extension-link/src/helpers/autolink.ts +++ b/packages/extension-link/src/helpers/autolink.ts @@ -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 @@ -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, From 7084cd26186c24954cec9271b84bf4621ae3d67b Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 26 Apr 2023 11:20:02 +0200 Subject: [PATCH 7/7] fix(link): fix links not pasting the correct link on selected text --- .../src/helpers/pasteHandler.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts index 80c73d2ccfb..0d4c834032c 100644 --- a/packages/extension-link/src/helpers/pasteHandler.ts +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -1,5 +1,5 @@ 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' @@ -17,6 +17,18 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { const { state } = view const { selection } = state + 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 = '' slice.content.forEach(node => { @@ -25,12 +37,15 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { const link = find(textContent).find(item => item.isLink && item.value === textContent) - if (link && !selection.empty && options.linkOnPaste) { - options.editor.commands.setMark(options.type, { - href: link.href, - }) + if (!selection.empty && options.linkOnPaste) { + const pastedLink = hasPastedLink ? pastedLinkMarks[0].attrs.href : link?.href || null - return true + 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)) { @@ -55,8 +70,9 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { slice.content.forEach(node => { const fragmentLinks = find(node.textContent) + tr.insert(currentPos - 1, node) + if (fragmentLinks.length > 0) { - tr.insert(currentPos - 1, node) deleteOnly = false fragmentLinks.forEach(fragmentLink => { @@ -70,8 +86,8 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { } }) - currentPos += node.nodeSize } + currentPos += node.nodeSize }) if (tr.docChanged && !deleteOnly) {