diff --git a/demos/src/Examples/AutolinkValidation/React/index.jsx b/demos/src/Examples/AutolinkValidation/React/index.jsx
index 21e28c44..b32b9ecf 100644
--- a/demos/src/Examples/AutolinkValidation/React/index.jsx
+++ b/demos/src/Examples/AutolinkValidation/React/index.jsx
@@ -23,8 +23,48 @@ export default () => {
},
})
+ const setLink = React.useCallback(() => {
+ const previousUrl = editor.getAttributes('link').href
+ const url = window.prompt('URL', previousUrl)
+
+ // cancelled
+ if (url === null) {
+ return
+ }
+
+ // empty
+ if (url === '') {
+ editor.chain().focus().extendMarkRange('link').unsetLink()
+ .run()
+
+ return
+ }
+
+ // update link
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url })
+ .run()
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
return (
+
+
)
diff --git a/demos/src/Examples/AutolinkValidation/React/index.spec.js b/demos/src/Examples/AutolinkValidation/React/index.spec.js
index e1d6b7d1..0967daba 100644
--- a/demos/src/Examples/AutolinkValidation/React/index.spec.js
+++ b/demos/src/Examples/AutolinkValidation/React/index.spec.js
@@ -8,25 +8,30 @@ context('/src/Examples/AutolinkValidation/React/', () => {
})
const validLinks = [
- 'https://tiptap.dev',
- 'http://tiptap.dev',
- 'https://www.tiptap.dev/',
- 'http://www.tiptap.dev/',
+ // [rawTextInput, textThatShouldBeLinked]
+ ['https://tiptap.dev ', 'https://tiptap.dev'],
+ ['http://tiptap.dev ', 'http://tiptap.dev'],
+ ['https://www.tiptap.dev/ ', 'https://www.tiptap.dev/'],
+ ['http://www.tiptap.dev/ ', 'http://www.tiptap.dev/'],
+ ['[http://www.example.com/] ', 'http://www.example.com/'],
+ ['(http://www.example.com/) ', 'http://www.example.com/'],
]
const invalidLinks = [
'tiptap.dev',
'www.tiptap.dev',
+ // If you don't type a space, don't autolink
+ 'https://tiptap.dev',
]
validLinks.forEach(link => {
- it(`${link} should get autolinked`, () => {
- cy.get('.ProseMirror').type(link)
- cy.get('.ProseMirror').should('have.text', link)
+ it(`${link[0]} should get autolinked`, () => {
+ cy.get('.ProseMirror').type(link[0])
+ cy.get('.ProseMirror').should('have.text', link[0])
cy.get('.ProseMirror')
.find('a')
.should('have.length', 1)
- .should('have.attr', 'href', link)
+ .should('have.attr', 'href', link[1])
})
})
@@ -39,4 +44,66 @@ context('/src/Examples/AutolinkValidation/React/', () => {
.should('have.length', 0)
})
})
+
+ it('should not relink unset links after entering second link', () => {
+ cy.get('.ProseMirror').type('https://tiptap.dev {home}')
+ cy.get('.ProseMirror').should('have.text', 'https://tiptap.dev ')
+ cy.get('[data-testid=unsetLink]').click()
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ cy.get('.ProseMirror').type('{end}http://www.example.com/ ')
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 1)
+ .should('have.attr', 'href', 'http://www.example.com/')
+ })
+
+ it('should not relink unset links after hitting next paragraph', () => {
+ cy.get('.ProseMirror').type('https://tiptap.dev {home}')
+ cy.get('.ProseMirror').should('have.text', 'https://tiptap.dev ')
+ cy.get('[data-testid=unsetLink]').click()
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ cy.get('.ProseMirror').type('{end}typing other text should prevent the link from relinking when hitting enter{enter}')
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ })
+
+ it('should not relink unset links after modifying', () => {
+ cy.get('.ProseMirror').type('https://tiptap.dev {home}')
+ cy.get('.ProseMirror').should('have.text', 'https://tiptap.dev ')
+ cy.get('[data-testid=unsetLink]').click()
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ cy.get('.ProseMirror')
+ .type('{home}')
+ .type('{rightArrow}'.repeat('https://'.length))
+ .type('blah')
+ cy.get('.ProseMirror').should('have.text', 'https://blahtiptap.dev ')
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ })
+
+ it('should autolink after hitting enter (new paragraph)', () => {
+ cy.get('.ProseMirror').type('https://tiptap.dev{enter}')
+ cy.get('.ProseMirror').should('have.text', 'https://tiptap.dev')
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 1)
+ .should('have.attr', 'href', 'https://tiptap.dev')
+ })
+
+ it('should autolink after hitting shift-enter (hardbreak)', () => {
+ cy.get('.ProseMirror').type('https://tiptap.dev{shift+enter}')
+ cy.get('.ProseMirror').should('have.text', 'https://tiptap.dev')
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 1)
+ .should('have.attr', 'href', 'https://tiptap.dev')
+ })
})
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.spec.js b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js
index a7496597..c08e2df5 100644
--- a/demos/src/Examples/AutolinkValidation/Vue/index.spec.js
+++ b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js
@@ -8,25 +8,30 @@ context('/src/Examples/AutolinkValidation/Vue/', () => {
})
const validLinks = [
- 'https://tiptap.dev',
- 'http://tiptap.dev',
- 'https://www.tiptap.dev/',
- 'http://www.tiptap.dev/',
+ // [rawTextInput, textThatShouldBeLinked]
+ ['https://tiptap.dev ', 'https://tiptap.dev'],
+ ['http://tiptap.dev ', 'http://tiptap.dev'],
+ ['https://www.tiptap.dev/ ', 'https://www.tiptap.dev/'],
+ ['http://www.tiptap.dev/ ', 'http://www.tiptap.dev/'],
+ ['[http://www.example.com/] ', 'http://www.example.com/'],
+ ['(http://www.example.com/) ', 'http://www.example.com/'],
]
const invalidLinks = [
'tiptap.dev',
'www.tiptap.dev',
+ // If you don't type a space, don't autolink
+ 'https://tiptap.dev',
]
validLinks.forEach(link => {
- it(`${link} should get autolinked`, () => {
- cy.get('.ProseMirror').type(link)
- cy.get('.ProseMirror').should('have.text', link)
+ it(`${link[0]} should get autolinked`, () => {
+ cy.get('.ProseMirror').type(link[0])
+ cy.get('.ProseMirror').should('have.text', link[0])
cy.get('.ProseMirror')
.find('a')
.should('have.length', 1)
- .should('have.attr', 'href', link)
+ .should('have.attr', 'href', link[1])
})
})
diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts
index a6dcb55b..ae59c3f8 100644
--- a/packages/extension-link/src/helpers/autolink.ts
+++ b/packages/extension-link/src/helpers/autolink.ts
@@ -3,6 +3,7 @@ import {
findChildrenInRange,
getChangedRanges,
getMarksBetween,
+ NodeWithPos,
} from '@tiptap/core'
import { find, test } from 'linkifyjs'
import { MarkType } from 'prosemirror-model'
@@ -58,46 +59,59 @@ export function autolink(options: AutolinkOptions): Plugin {
})
// now let’s see if we can add new links
- findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
- .forEach(textBlock => {
- // we need to define a placeholder for leaf nodes
- // so that the link position can be calculated correctly
- const text = newState.doc.textBetween(
- textBlock.pos,
- textBlock.pos + textBlock.node.nodeSize,
- undefined,
- ' ',
- )
+ const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
- find(text)
- .filter(link => link.isLink)
- .filter(link => {
- if (options.validate) {
- return options.validate(link.value)
- }
+ let textBlock: NodeWithPos | undefined
+ let textBeforeWhitespace: string | undefined
- return true
- })
- // calculate link position
- .map(link => ({
- ...link,
- from: textBlock.pos + link.start + 1,
- to: textBlock.pos + link.end + 1,
+ if (nodesInChangedRanges.length > 1) {
+ // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter)
+ textBlock = nodesInChangedRanges[0]
+ textBeforeWhitespace = newState.doc.textBetween(
+ textBlock.pos,
+ textBlock.pos + textBlock.node.nodeSize,
+ undefined,
+ ' ',
+ )
+ } else if (
+ // We want to make sure to include the block seperator argument to treat hard breaks like spaces
+ newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ').endsWith(' ')
+ ) {
+ textBlock = nodesInChangedRanges[0]
+ textBeforeWhitespace = newState.doc.textBetween(
+ textBlock.pos,
+ newRange.to,
+ undefined,
+ ' ',
+ )
+ }
+
+ if (textBlock && textBeforeWhitespace) {
+ const wordsBeforeWhitespace = textBeforeWhitespace.split(' ').filter(s => s !== '')
+ const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]
+ const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace)
+
+ find(lastWordBeforeSpace)
+ .filter(link => link.isLink)
+ .filter(link => {
+ if (options.validate) {
+ return options.validate(link.value)
+ }
+ return true
+ })
+ // calculate link position
+ .map(link => ({
+ ...link,
+ from: lastWordAndBlockOffset + link.start + 1,
+ to: lastWordAndBlockOffset + link.end + 1,
+ }))
+ // add link mark
+ .forEach(link => {
+ tr.addMark(link.from, link.to, options.type.create({
+ href: link.href,
}))
- // 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) {