Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Hyperlink a text selection when pasting a valid URL #435

Merged
merged 1 commit into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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