Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ describe('markdown paste', () => {
expect(paste(editor, '[link](https://example.com)')).toBe(false)
})

it('keeps pasted markdown literal inside inline code', () => {
editor = mount()
editor.commands.setContent('a `codehere` b', { contentType: 'markdown' })
editor.commands.setTextSelection(6)
expect(editor.isActive('code')).toBe(true)
expect(paste(editor, '*italic*')).toBe(false)
})

it('rejects the paste entirely in a read-only editor', () => {
editor = mount(false)
expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false)
Expand All @@ -104,21 +112,19 @@ describe('markdown paste', () => {
['empty string', ''],
['whitespace only', ' \n\n '],
['a bare thematic break (ambiguous — needs another markdown signal)', '---'],
['inline-only italic (single asterisk would false-positive on e.g. *args)', 'an *italic* word'],
['inline-only strikethrough', 'a ~~struck~~ word'],
['inline-only code', 'some `code` here'],
])('leaves %s to the default handler', (_label, text) => {
editor = mount()
expect(paste(editor, text)).toBe(false)
})

// Only structural / unambiguous constructs gate the markdown parse. Inline-only marks that
// `looksLikeMarkdown` deliberately omits to avoid false positives — single-asterisk italic
// (`*args`), `~~`, single-backtick code — are covered by the Markdown extension's own paste path,
// not MarkdownPaste, so they belong to a different test surface.
it.each([
['heading', '# Heading', 'heading'],
['bold', 'a **bold** word', 'bold'],
['italic', 'an *italic* word', 'italic'],
['underscore italic', 'an _italic_ word', 'italic'],
['underscore bold', 'a __bold__ word', 'bold'],
['strikethrough', 'a ~~struck~~ word', 'strike'],
['inline code', 'some `code` here', 'code'],
['bullet list', '- one\n- two', 'bulletList'],
['ordered list', '1. one\n2. two', 'orderedList'],
['task list', '- [x] done\n- [ ] todo', 'taskList'],
Expand All @@ -132,6 +138,28 @@ describe('markdown paste', () => {
expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`)
})

it.each([
['italic', 'an *italic* word', '<p>an <em>italic</em> word</p>'],
['strikethrough', 'a ~~struck~~ word', '<p>a <del>struck</del> word</p>'],
['inline code', 'some `code` here', '<p>some <code>code</code> here</p>'],
])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => {
editor = mount()
expect(paste(editor, text, html)).toBe(false)
})

it.each([
['space-flanked asterisks', 'area = 5 * width * height'],
['python args and kwargs', 'def foo(*args, **kwargs): pass'],
['snake_case identifiers', 'call user_name and file_path_here'],
])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => {
editor = mount()
expect(paste(editor, text)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).not.toContain('"type":"italic"')
expect(json).not.toContain('"type":"bold"')
expect(editor.getText()).toBe(text)
})

it('parses markdown-shaped plain text even when an HTML sibling is present', () => {
editor = mount()
const html = '<h1>Title</h1><ul><li>a</li><li>b</li></ul>'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { Plugin } from '@tiptap/pm/state'
import { parseMarkdownToDoc } from './markdown-parse'

/**
* Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather
* than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content
* instead of showing its raw `[text](url)` / `# ` source.
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
* than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs),
* so they are parsed even when the clipboard also carries an HTML sibling.
*/
const MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
/^#{1,6}\s/m,
/\*\*[^*]+\*\*/,
/\[[^\]]*]\([^)]+\)/,
Expand All @@ -18,21 +19,40 @@ const MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
/^\|.*\|.*\|/m,
]

function looksLikeMarkdown(text: string): boolean {
return MARKDOWN_HINTS.some((hint) => hint.test(text))
/**
* Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a
* rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a
* terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was
* rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated,
* not a `| … |` grid, so parsing it would flatten the table).
*/
const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
/\*[^*\n]+\*/,
/_[^_\n]+_/,
/~~[^~\n]+~~/,
/`[^`\n]+`/,
]
Comment thread
waleedlatif1 marked this conversation as resolved.

function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
return hints.some((hint) => hint.test(text))
}

/**
* Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
* are left untouched (code is meant to stay literal).
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
* untouched (code is meant to stay literal).
*
* Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion,
* GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from
* the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But
* inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the
* DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a
* terminal, a code editor, a `.md` file) always parses.
*
* A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub,
* or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping,
* even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully
* (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only
* defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard
* payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word
* processor, a web page selection, …) still goes through the DOM path unchanged.
* The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes
* emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules:
* false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path)
* never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path.
*/
export const MarkdownPaste = Extension.create({
name: 'markdownPaste',
Expand All @@ -44,11 +64,13 @@ export const MarkdownPaste = Extension.create({
props: {
handlePaste: (_view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock')) return false
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
const text = event.clipboardData?.getData('text/plain')
if (!text || !looksLikeMarkdown(text)) return false
// Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
// main thread the way the underlying superlinear parse would.
if (!text) return false
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
if (!hasAny(INLINE_MARK_HINTS, text)) return false
if (event.clipboardData?.getData('text/html')) return false
}
Comment thread
waleedlatif1 marked this conversation as resolved.
const doc = parseMarkdownToDoc(text)
if (!doc.content?.length) return false
return editor.commands.insertContent(doc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export function LoadedRichMarkdownEditor({
const editor = useEditor({
extensions: EXTENSIONS,
editable: isEditable,
enablePasteRules: false,
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function LoadedRichMarkdownField({
const editor = useEditor({
extensions,
editable: !disabled && !isStreaming,
enablePasteRules: false,
autofocus: autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
Expand Down
Loading