From 36785c333f1021998ffbd533e4d2be489d30f8c1 Mon Sep 17 00:00:00 2001 From: Ricardo Amaral Date: Fri, 8 Sep 2023 12:15:32 +0100 Subject: [PATCH] fix: Hyperlink a text selection when pasting a valid URL --- package-lock.json | 13 ++--- package.json | 1 + src/constants/extension-priorities.ts | 26 ++++++---- src/extensions/rich-text/paste-markdown.ts | 49 +++++++++++-------- src/extensions/rich-text/rich-text-link.ts | 23 +-------- .../shared/paste-html-table-as-string.ts | 4 +- src/index.ts | 2 +- 7 files changed, 58 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 184e55dc..baf53d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,6 +107,7 @@ "@react-hookz/web": "^14.2.3 || >=15.x", "emoji-regex": "^10.2.1", "hast-util-is-element": "^2.1.0", + "linkifyjs": "^4.1.1", "lodash-es": "^4.17.21", "mdast-util-gfm-autolink-literal": "^1.0.0", "mdast-util-gfm-strikethrough": "^1.0.0", @@ -17479,9 +17480,9 @@ } }, "node_modules/linkifyjs": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.0.tgz", - "integrity": "sha512-Ffv8VoY3+ixI1b3aZ3O+jM6x17cOsgwfB1Wq7pkytbo1WlyRp6ZO0YDMqiWT/gQPY/CmtiGuKfzDIVqxh1aCTA==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.1.tgz", + "integrity": "sha512-zFN/CTVmbcVef+WaDXT63dNzzkfRBKT1j464NJQkV7iSgJU0sLBus9W0HBwnXK13/hf168pbrx/V/bjEHOXNHA==" }, "node_modules/lint-staged": { "version": "14.0.1", @@ -41517,9 +41518,9 @@ } }, "linkifyjs": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.0.tgz", - "integrity": "sha512-Ffv8VoY3+ixI1b3aZ3O+jM6x17cOsgwfB1Wq7pkytbo1WlyRp6ZO0YDMqiWT/gQPY/CmtiGuKfzDIVqxh1aCTA==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.1.tgz", + "integrity": "sha512-zFN/CTVmbcVef+WaDXT63dNzzkfRBKT1j464NJQkV7iSgJU0sLBus9W0HBwnXK13/hf168pbrx/V/bjEHOXNHA==" }, "lint-staged": { "version": "14.0.1", diff --git a/package.json b/package.json index 216fa1fe..6dc61572 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "rehype-minify-whitespace": "^5.0.0", "rehype-raw": "^6.1.0", "rehype-stringify": "^9.0.0", + "linkifyjs": "^4.1.1", "remark": "^14.0.0", "remark-breaks": "^3.0.0", "remark-gfm": "^3.0.0", diff --git a/src/constants/extension-priorities.ts b/src/constants/extension-priorities.ts index 44431b1e..47caaa13 100644 --- a/src/constants/extension-priorities.ts +++ b/src/constants/extension-priorities.ts @@ -3,7 +3,21 @@ * be higher than most extensions, so that event handlers from the dropdown render function can take * precedence over all other event handlers in the chain. */ -const SUGGESTION_EXTENSION_PRIORITY = 1000 +const SUGGESTION_EXTENSION_PRIORITY = 10000 + +/** + * Priority for the `PasteHTMLTableAsString` extension. This needs to be higher than most paste + * extensions (e.g., `PasteSinglelineText`, `PasteMarkdown`, etc.), so that the extension can first + * parse HTML tables that might exist in the clipboard data. + */ +const PASTE_HTML_TABLE_AS_STRING_EXTENSION_PRIORITY = 1005 + +/** + * Priority for the `PasteMarkdown` extension. This needs to be higher than the built-in and + * official `Link` extension (i.e. `1000`), so that the extension can first parse Markdown links + * correctly, without having the `Link` extension paste handlers interfering. + */ +const PASTE_MARKDOWN_EXTENSION_PRIORITY = 1001 /** * Priority for the `SmartMarkdownTyping` extension. This needs to be higher than the @@ -12,13 +26,6 @@ const SUGGESTION_EXTENSION_PRIORITY = 1000 */ const SMART_MARKDOWN_TYPING_PRIORITY = 110 -/** - * Priority for the `PasteHTMLTableAsString` extension. This needs to be higher than most paste - * extensions (e.g., `PasteSinglelineText`, `PasteMarkdown`, etc.), so that the extension can first - * parse HTML tables that might exist in the clipboard data. - */ -const PASTE_EXTENSION_PRIORITY = 105 - /** * Priority for the `ViewEventHandlers` extension. This needs to be higher than the default for most * of the built-in and official extensions (i.e. `100`), so that the event handlers from the @@ -43,7 +50,8 @@ const CODE_EXTENSION_PRIORITY = 99 export { BLOCKQUOTE_EXTENSION_PRIORITY, CODE_EXTENSION_PRIORITY, - PASTE_EXTENSION_PRIORITY, + PASTE_HTML_TABLE_AS_STRING_EXTENSION_PRIORITY, + PASTE_MARKDOWN_EXTENSION_PRIORITY, SMART_MARKDOWN_TYPING_PRIORITY, SUGGESTION_EXTENSION_PRIORITY, VIEW_EVENT_HANDLERS_PRIORITY, diff --git a/src/extensions/rich-text/paste-markdown.ts b/src/extensions/rich-text/paste-markdown.ts index 175bb596..259bd226 100644 --- a/src/extensions/rich-text/paste-markdown.ts +++ b/src/extensions/rich-text/paste-markdown.ts @@ -1,8 +1,10 @@ import { Extension } from '@tiptap/core' +import * as linkify from 'linkifyjs' import { Fragment, Slice } from 'prosemirror-model' import { Plugin, PluginKey } from 'prosemirror-state' import { ClipboardDataType } from '../../constants/common' +import { PASTE_MARKDOWN_EXTENSION_PRIORITY } from '../../constants/extension-priorities' /** * A partial type for the the clipboard metadata coming from VS Code. @@ -22,6 +24,7 @@ type VSCodeClipboardMetadata = { */ const PasteMarkdown = Extension.create({ name: 'pasteMarkdown', + priority: PASTE_MARKDOWN_EXTENSION_PRIORITY, addProseMirrorPlugins() { const { editor } = this @@ -37,10 +40,30 @@ const PasteMarkdown = Extension.create({ return Slice.maxOpen(Fragment.from(editor.schema.text(text))) }, handlePaste(_, event, slice) { + const isInsideCodeBlockNode = + editor.state.selection.$from.parent.type.name === 'codeBlock' + // The clipboard contains text if the slice content size is greater than // zero, otherwise it contains other data types (like files or images) const clipboardContainsText = Boolean(slice.content.size) + // Do not handle the paste event if the user is pasting inside a code block + // or if the clipboard does not contain text + if (isInsideCodeBlockNode || !clipboardContainsText) { + return false + } + + // Get the clipboard text from the slice content instead of getting it from + // the clipboard data because the pasted content could have already been + // transformed by other ProseMirror plugins + const textContent = slice.content.textBetween(0, slice.content.size, '\n') + + // Do not handle the paste event if the clipboard text is only a link (in + // this case we want the built-in handlers in Tiptap to handle the event) + if (linkify.test(textContent)) { + return false + } + const clipboardContainsHTML = Boolean( event.clipboardData?.types.some( (type) => type === ClipboardDataType.HTML, @@ -77,35 +100,19 @@ const PasteMarkdown = Extension.create({ vsCodeClipboardMetadata.mode !== null && vsCodeClipboardMetadata.mode !== 'markdown' - const isInsideCodeBlockNode = - editor.state.selection.$from.parent.type.name === 'codeBlock' - - // Do not handle the paste event if: - // * The clipboard does NOT contain plain-text - // * The clipboard contains HTML but from an unknown source (like Google - // Drive, Dropbox Paper, etc.) - // * The clipboard contains HTML from VS Code that it's NOT plain-text or - // Markdown (like Python, TypeScript, JSON, etc.) - // * The user is pasting content inside a code block node - // For all the above conditions we want the default handling behaviour from - // ProseMirror to kick-in, otherwise we'll handle it ourselves below + // Do not handle the paste event if the clipboard contains HTML from an + // unknown source (e.g., Google Drive, Dropbox Paper, etc.) or from VS Code + // that it's NOT plain-text or Markdown (e.g., Python, TypeScript, etc.) if ( - !clipboardContainsText || clipboardContainsHTMLFromUnknownSource || - clipboardContainsHTMLFromVSCodeOtherThanTextOrMarkdown || - isInsideCodeBlockNode + clipboardContainsHTMLFromVSCodeOtherThanTextOrMarkdown ) { return false } // Send the clipboard text through the HTML serializer to convert potential // Markdown into HTML, and then insert it into the editor - editor.commands.insertMarkdownContent( - // The slice content is used instead of getting the text directly from - // the clipboard data because the pasted content could have already - // been transformed by other ProseMirror plugins - slice.content.textBetween(0, slice.content.size, '\n'), - ) + editor.commands.insertMarkdownContent(textContent) // Suppress the default handling behaviour return true diff --git a/src/extensions/rich-text/rich-text-link.ts b/src/extensions/rich-text/rich-text-link.ts index 9c272ab7..6b73508f 100644 --- a/src/extensions/rich-text/rich-text-link.ts +++ b/src/extensions/rich-text/rich-text-link.ts @@ -56,32 +56,13 @@ function linkPasteRule(config: Parameters[0]) { }) } -/** - * The options available to customize the `RichTextLink` extension. - */ -type RichTextLinkOptions = Omit< - LinkOptions, - // The `linkOnPaste` option is not available in the `RichTextLink` extension, since we're using - // our own paste rules to handle Markdown syntax (see `addOptions` below) - 'linkOnPaste' -> - /** * Custom extension that extends the built-in `Link` extension to add additional input/paste rules * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also * adds support for the `title` attribute. */ -const RichTextLink = Link.extend({ +const RichTextLink = Link.extend({ inclusive: false, - addOptions() { - return { - ...this.parent?.(), - // Disable the built-in auto-linking feature for pasted URLs, since we're using our own - // paste rules to handle Markdown syntax (on top of that, the `PasteMarkdown` extension - // takes precedence, and will handle auto-linking for pasted URLs anyway) - linkOnPaste: false, - } - }, addAttributes() { return { ...this.parent?.(), @@ -130,4 +111,4 @@ const RichTextLink = Link.extend({ export { RichTextLink } -export type { RichTextLinkOptions } +export type { LinkOptions as RichTextLinkOptions } diff --git a/src/extensions/shared/paste-html-table-as-string.ts b/src/extensions/shared/paste-html-table-as-string.ts index 5a78bbf6..0d724359 100644 --- a/src/extensions/shared/paste-html-table-as-string.ts +++ b/src/extensions/shared/paste-html-table-as-string.ts @@ -1,7 +1,7 @@ import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from 'prosemirror-state' -import { PASTE_EXTENSION_PRIORITY } from '../../constants/extension-priorities' +import { PASTE_HTML_TABLE_AS_STRING_EXTENSION_PRIORITY } from '../../constants/extension-priorities' import { parseHtmlToElement } from '../../helpers/dom' /** @@ -18,7 +18,7 @@ import { parseHtmlToElement } from '../../helpers/dom' */ const PasteHTMLTableAsString = Extension.create({ name: 'pasteHTMLTableAsString', - priority: PASTE_EXTENSION_PRIORITY, + priority: PASTE_HTML_TABLE_AS_STRING_EXTENSION_PRIORITY, addProseMirrorPlugins() { return [ new Plugin({ diff --git a/src/index.ts b/src/index.ts index 994de81a..26e633c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export type { UpdateProps, } from './components/typist-editor' export { TypistEditor } from './components/typist-editor' -export { SUGGESTION_EXTENSION_PRIORITY } from './constants/extension-priorities' +export * from './constants/extension-priorities' export * from './extensions/core/extra-editor-commands/commands/extend-word-range' export * from './extensions/core/extra-editor-commands/commands/insert-markdown-content' export { PlainTextKit } from './extensions/plain-text/plain-text-kit'