diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa6215fdd..81e38fca559 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "conventionalCommits.scopes": [ + "extension-link", + "settings" + ] } diff --git a/demos/src/Examples/AutolinkValidation/React/index.html b/demos/src/Examples/AutolinkValidation/React/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/Examples/AutolinkValidation/React/index.jsx b/demos/src/Examples/AutolinkValidation/React/index.jsx new file mode 100644 index 00000000000..5c1484131a5 --- /dev/null +++ b/demos/src/Examples/AutolinkValidation/React/index.jsx @@ -0,0 +1,32 @@ +import './styles.scss' + +import React from 'react' + +import Link from '@tiptap/extension-link' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' + +export default () => { + const editor = useEditor({ + extensions: [ + StarterKit, + Link.configure({ + validate: link => /^https?:\/\//.test(link), + }), + ], + content: ` +

Hey! Try to type in url with and without a http/s protocol. - Links without a protocol should not get auto linked

+ `, + editorProps: { + attributes: { + spellcheck: 'false', + }, + }, + }) + + return ( +
+ +
+ ) +} diff --git a/demos/src/Examples/AutolinkValidation/React/index.spec.js b/demos/src/Examples/AutolinkValidation/React/index.spec.js new file mode 100644 index 00000000000..e1d6b7d13a3 --- /dev/null +++ b/demos/src/Examples/AutolinkValidation/React/index.spec.js @@ -0,0 +1,42 @@ +context('/src/Examples/AutolinkValidation/React/', () => { + before(() => { + cy.visit('/src/Examples/AutolinkValidation/React/') + }) + + beforeEach(() => { + cy.get('.ProseMirror').type('{selectall}{backspace}') + }) + + const validLinks = [ + 'https://tiptap.dev', + 'http://tiptap.dev', + 'https://www.tiptap.dev/', + 'http://www.tiptap.dev/', + ] + + const invalidLinks = [ + 'tiptap.dev', + 'www.tiptap.dev', + ] + + validLinks.forEach(link => { + it(`${link} should get autolinked`, () => { + cy.get('.ProseMirror').type(link) + cy.get('.ProseMirror').should('have.text', link) + cy.get('.ProseMirror') + .find('a') + .should('have.length', 1) + .should('have.attr', 'href', link) + }) + }) + + invalidLinks.forEach(link => { + it(`${link} should NOT get autolinked`, () => { + cy.get('.ProseMirror').type(link) + cy.get('.ProseMirror').should('have.text', link) + cy.get('.ProseMirror') + .find('a') + .should('have.length', 0) + }) + }) +}) diff --git a/demos/src/Examples/AutolinkValidation/React/styles.scss b/demos/src/Examples/AutolinkValidation/React/styles.scss new file mode 100644 index 00000000000..9a6d1a7c558 --- /dev/null +++ b/demos/src/Examples/AutolinkValidation/React/styles.scss @@ -0,0 +1,54 @@ +/* Basic editor styles */ +.ProseMirror { + > * + * { + margin-top: 0.75em; + } + + ul, + ol { + padding: 0 1rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background-color: rgba(#616161, 0.1); + color: #616161; + } + + pre { + background: #0D0D0D; + color: #FFF; + font-family: 'JetBrainsMono', monospace; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + + code { + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + } + } + + img { + max-width: 100%; + height: auto; + } + + hr { + margin: 1rem 0; + } + + blockquote { + padding-left: 1rem; + border-left: 2px solid rgba(#0D0D0D, 0.1); + } +} diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.html b/demos/src/Examples/AutolinkValidation/Vue/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.spec.js b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js new file mode 100644 index 00000000000..a74965973d2 --- /dev/null +++ b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js @@ -0,0 +1,42 @@ +context('/src/Examples/AutolinkValidation/Vue/', () => { + before(() => { + cy.visit('/src/Examples/AutolinkValidation/Vue/') + }) + + beforeEach(() => { + cy.get('.ProseMirror').type('{selectall}{backspace}') + }) + + const validLinks = [ + 'https://tiptap.dev', + 'http://tiptap.dev', + 'https://www.tiptap.dev/', + 'http://www.tiptap.dev/', + ] + + const invalidLinks = [ + 'tiptap.dev', + 'www.tiptap.dev', + ] + + validLinks.forEach(link => { + it(`${link} should get autolinked`, () => { + cy.get('.ProseMirror').type(link) + cy.get('.ProseMirror').should('have.text', link) + cy.get('.ProseMirror') + .find('a') + .should('have.length', 1) + .should('have.attr', 'href', link) + }) + }) + + invalidLinks.forEach(link => { + it(`${link} should NOT get autolinked`, () => { + cy.get('.ProseMirror').type(link) + cy.get('.ProseMirror').should('have.text', link) + cy.get('.ProseMirror') + .find('a') + .should('have.length', 0) + }) + }) +}) diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.vue b/demos/src/Examples/AutolinkValidation/Vue/index.vue new file mode 100644 index 00000000000..b701cf8bef0 --- /dev/null +++ b/demos/src/Examples/AutolinkValidation/Vue/index.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/docs/api/marks/link.md b/docs/api/marks/link.md index 3464114565a..1aad36b2f79 100644 --- a/docs/api/marks/link.md +++ b/docs/api/marks/link.md @@ -64,6 +64,18 @@ Link.configure({ }) ``` +### validate +A function that validates every autolinked link. If it exists, it will be called with the link href as argument. If it returns `false`, the link will be removed. + +Can be used to set rules for example excluding or including certain domains, tlds, etc. + +```js +// only autolink urls with a protocol +Link.configure({ + validate: href => /^https?:\/\//.test(href), +}) +``` + ## Commands ### setLink() diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts index bd72b1ce9dc..f413792bb53 100644 --- a/packages/extension-link/src/helpers/autolink.ts +++ b/packages/extension-link/src/helpers/autolink.ts @@ -1,15 +1,17 @@ +import { find, test } from 'linkifyjs' +import { MarkType } from 'prosemirror-model' +import { Plugin, PluginKey } from 'prosemirror-state' + import { - getMarksBetween, - findChildrenInRange, combineTransactionSteps, + findChildrenInRange, getChangedRanges, + getMarksBetween, } from '@tiptap/core' -import { Plugin, PluginKey } from 'prosemirror-state' -import { MarkType } from 'prosemirror-model' -import { find, test } from 'linkifyjs' type AutolinkOptions = { type: MarkType, + validate?: (url: string) => boolean, } export function autolink(options: AutolinkOptions): Plugin { @@ -70,6 +72,13 @@ export function autolink(options: AutolinkOptions): Plugin { find(text) .filter(link => link.isLink) + .filter(link => { + if (options.validate) { + return options.validate(link.value) + } + + return true + }) // calculate link position .map(link => ({ ...link, diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts index fc06a206d86..f6aa323d314 100644 --- a/packages/extension-link/src/helpers/pasteHandler.ts +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -1,7 +1,8 @@ -import { Editor } from '@tiptap/core' -import { Plugin, PluginKey } from 'prosemirror-state' -import { MarkType } from 'prosemirror-model' import { find } from 'linkifyjs' +import { MarkType } from 'prosemirror-model' +import { Plugin, PluginKey } from 'prosemirror-state' + +import { Editor } from '@tiptap/core' type PasteHandlerOptions = { editor: Editor, diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index 40bd1c932f9..43d6c11f69f 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -1,5 +1,7 @@ -import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core' import { find } from 'linkifyjs' + +import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core' + import { autolink } from './helpers/autolink' import { clickHandler } from './helpers/clickHandler' import { pasteHandler } from './helpers/pasteHandler' @@ -21,6 +23,12 @@ export interface LinkOptions { * A list of HTML attributes to be rendered. */ HTMLAttributes: Record, + /** + * A validation function that modifies link verification for the auto linker. + * @param url - The url to be validated. + * @returns - True if the url is valid, false otherwise. + */ + validate?: (url: string) => boolean, } declare module '@tiptap/core' { @@ -63,6 +71,7 @@ export const Link = Mark.create({ rel: 'noopener noreferrer nofollow', class: null, }, + validate: undefined, } }, @@ -123,6 +132,13 @@ export const Link = Mark.create({ 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, @@ -143,6 +159,7 @@ export const Link = Mark.create({ if (this.options.autolink) { plugins.push(autolink({ type: this.type, + validate: this.options.validate, })) }