Skip to content

Commit

Permalink
fix: Hyperlink a text selection when pasting a valid URL (#435)
Browse files Browse the repository at this point in the history
  • Loading branch information
rfgamaral committed Sep 8, 2023
1 parent f0a2b83 commit 999455e
Show file tree
Hide file tree
Showing 7 changed files with 58 additions and 60 deletions.
13 changes: 7 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 17 additions & 9 deletions src/constants/extension-priorities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
49 changes: 28 additions & 21 deletions src/extensions/rich-text/paste-markdown.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -22,6 +24,7 @@ type VSCodeClipboardMetadata = {
*/
const PasteMarkdown = Extension.create({
name: 'pasteMarkdown',
priority: PASTE_MARKDOWN_EXTENSION_PRIORITY,
addProseMirrorPlugins() {
const { editor } = this

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
23 changes: 2 additions & 21 deletions src/extensions/rich-text/rich-text-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,32 +56,13 @@ function linkPasteRule(config: Parameters<typeof markPasteRule>[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<RichTextLinkOptions>({
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?.(),
Expand Down Expand Up @@ -130,4 +111,4 @@ const RichTextLink = Link.extend<RichTextLinkOptions>({

export { RichTextLink }

export type { RichTextLinkOptions }
export type { LinkOptions as RichTextLinkOptions }
4 changes: 2 additions & 2 deletions src/extensions/shared/paste-html-table-as-string.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 999455e

Please sign in to comment.