feat(extension-link) Change autolink to only apply on space (#3232)
Co-authored-by: Cameron Hessler <cameron.hessler@buildertrend.com>
This commit is contained in:
@@ -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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={setLink}
|
||||||
|
className={editor.isActive('link') ? 'is-active' : ''}
|
||||||
|
data-testid="setLink"
|
||||||
|
>
|
||||||
|
setLink
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||||
|
disabled={!editor.isActive('link')}
|
||||||
|
data-testid="unsetLink"
|
||||||
|
>
|
||||||
|
unsetLink
|
||||||
|
</button>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,25 +8,30 @@ context('/src/Examples/AutolinkValidation/React/', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const validLinks = [
|
const validLinks = [
|
||||||
'https://tiptap.dev',
|
// [rawTextInput, textThatShouldBeLinked]
|
||||||
'http://tiptap.dev',
|
['https://tiptap.dev ', 'https://tiptap.dev'],
|
||||||
'https://www.tiptap.dev/',
|
['http://tiptap.dev ', 'http://tiptap.dev'],
|
||||||
'http://www.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 = [
|
const invalidLinks = [
|
||||||
'tiptap.dev',
|
'tiptap.dev',
|
||||||
'www.tiptap.dev',
|
'www.tiptap.dev',
|
||||||
|
// If you don't type a space, don't autolink
|
||||||
|
'https://tiptap.dev',
|
||||||
]
|
]
|
||||||
|
|
||||||
validLinks.forEach(link => {
|
validLinks.forEach(link => {
|
||||||
it(`${link} should get autolinked`, () => {
|
it(`${link[0]} should get autolinked`, () => {
|
||||||
cy.get('.ProseMirror').type(link)
|
cy.get('.ProseMirror').type(link[0])
|
||||||
cy.get('.ProseMirror').should('have.text', link)
|
cy.get('.ProseMirror').should('have.text', link[0])
|
||||||
cy.get('.ProseMirror')
|
cy.get('.ProseMirror')
|
||||||
.find('a')
|
.find('a')
|
||||||
.should('have.length', 1)
|
.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)
|
.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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,25 +8,30 @@ context('/src/Examples/AutolinkValidation/Vue/', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const validLinks = [
|
const validLinks = [
|
||||||
'https://tiptap.dev',
|
// [rawTextInput, textThatShouldBeLinked]
|
||||||
'http://tiptap.dev',
|
['https://tiptap.dev ', 'https://tiptap.dev'],
|
||||||
'https://www.tiptap.dev/',
|
['http://tiptap.dev ', 'http://tiptap.dev'],
|
||||||
'http://www.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 = [
|
const invalidLinks = [
|
||||||
'tiptap.dev',
|
'tiptap.dev',
|
||||||
'www.tiptap.dev',
|
'www.tiptap.dev',
|
||||||
|
// If you don't type a space, don't autolink
|
||||||
|
'https://tiptap.dev',
|
||||||
]
|
]
|
||||||
|
|
||||||
validLinks.forEach(link => {
|
validLinks.forEach(link => {
|
||||||
it(`${link} should get autolinked`, () => {
|
it(`${link[0]} should get autolinked`, () => {
|
||||||
cy.get('.ProseMirror').type(link)
|
cy.get('.ProseMirror').type(link[0])
|
||||||
cy.get('.ProseMirror').should('have.text', link)
|
cy.get('.ProseMirror').should('have.text', link[0])
|
||||||
cy.get('.ProseMirror')
|
cy.get('.ProseMirror')
|
||||||
.find('a')
|
.find('a')
|
||||||
.should('have.length', 1)
|
.should('have.length', 1)
|
||||||
.should('have.attr', 'href', link)
|
.should('have.attr', 'href', link[1])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
findChildrenInRange,
|
findChildrenInRange,
|
||||||
getChangedRanges,
|
getChangedRanges,
|
||||||
getMarksBetween,
|
getMarksBetween,
|
||||||
|
NodeWithPos,
|
||||||
} from '@tiptap/core'
|
} from '@tiptap/core'
|
||||||
import { find, test } from 'linkifyjs'
|
import { find, test } from 'linkifyjs'
|
||||||
import { MarkType } from 'prosemirror-model'
|
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
|
// now let’s see if we can add new links
|
||||||
findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
|
const nodesInChangedRanges = 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,
|
|
||||||
' ',
|
|
||||||
)
|
|
||||||
|
|
||||||
find(text)
|
let textBlock: NodeWithPos | undefined
|
||||||
.filter(link => link.isLink)
|
let textBeforeWhitespace: string | undefined
|
||||||
.filter(link => {
|
|
||||||
if (options.validate) {
|
|
||||||
return options.validate(link.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
if (nodesInChangedRanges.length > 1) {
|
||||||
})
|
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter)
|
||||||
// calculate link position
|
textBlock = nodesInChangedRanges[0]
|
||||||
.map(link => ({
|
textBeforeWhitespace = newState.doc.textBetween(
|
||||||
...link,
|
textBlock.pos,
|
||||||
from: textBlock.pos + link.start + 1,
|
textBlock.pos + textBlock.node.nodeSize,
|
||||||
to: textBlock.pos + link.end + 1,
|
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) {
|
if (!tr.steps.length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user