From 8f5fbef7429c26d9e715e3677d71da60da292d1a Mon Sep 17 00:00:00 2001 From: Cameron Hessler Date: Mon, 26 Sep 2022 04:08:10 -0500 Subject: [PATCH] feat(extension-link) Change autolink to only apply on space (#3232) Co-authored-by: Cameron Hessler --- .../AutolinkValidation/React/index.jsx | 40 +++++++++ .../AutolinkValidation/React/index.spec.js | 83 +++++++++++++++-- .../AutolinkValidation/Vue/index.spec.js | 21 +++-- .../extension-link/src/helpers/autolink.ts | 88 +++++++++++-------- 4 files changed, 179 insertions(+), 53 deletions(-) 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) {