fix(markdown-editor): keep inline formatting from rich-HTML pastes

An inline-only rich paste (Google Docs/Word/mail single paragraph - <b>,
<a href>, styled spans) carries no block tags, so the paste plugin hijacked
it into a plain-text markdown parse and silently dropped bold/italic/links.
Defer to ProseMirror's native HTML parse when the clipboard has HTML and the
text/plain shows no markdown cues - the schema DOMParser keeps those marks.
Markdown-looking text (a VS Code copy of markdown source wrapped in
syntax-color spans) still parses as markdown, matching load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-04 12:11:13 +07:00
co-authored by Claude Opus 4.8
parent c2d04a1d94
commit 773e9de5d8
2 changed files with 55 additions and 6 deletions
@@ -31,10 +31,30 @@ describe('shouldParseMarkdownOnPaste — markdown-parse plain text, defer rich s
expect(shouldParseMarkdownOnPaste('# x', html, false)).toBe(false);
});
it('still parses when the HTML is only styled-inline (e.g. a VS Code span, no block tags)', () => {
it('still parses when the HTML is only styled-inline but the text IS markdown (VS Code markdown copy)', () => {
expect(shouldParseMarkdownOnPaste('**x**', '<span style="color:red">**x**</span>', false)).toBe(true);
});
it.each([
['# heading', '<span># heading</span>'],
['- item one\n- item two', '<span>- item one<br>- item two</span>'],
['| a | b |', '<span>| a | b |</span>'],
['```\ncode\n```', '<span>```</span>'],
['> quoted', '<span>&gt; quoted</span>'],
['see [docs](https://x.dev)', '<span>see [docs](https://x.dev)</span>'],
['1. first', '<span>1. first</span>'],
])('parses markdown-looking text %s despite an inline-only HTML wrapper', (text, html) => {
expect(shouldParseMarkdownOnPaste(text, html, false)).toBe(true);
});
it.each([
['hello world', '<b style="font-weight:700">hello world</b>'], // Google Docs bold paragraph
['see the docs', '<a href="https://example.com">see the docs</a>'], // inline link
['plain sentence', '<span style="font-style:italic">plain sentence</span>'], // Word italic
])('defers inline-formatted HTML whose text %s has no markdown (native parse keeps the marks)', (text, html) => {
expect(shouldParseMarkdownOnPaste(text, html, false)).toBe(false);
});
it('keeps a paste inside a code context literal', () => {
expect(shouldParseMarkdownOnPaste('# x', '', true)).toBe(false);
});
@@ -78,4 +98,10 @@ describe('MarkdownPaste — the parsed payload matches load (same tuned markdown
expect(html).not.toContain('<h1>');
});
it('defers inline-formatted HTML with markdown-free text (plugin inserts nothing)', () => {
const html = pasteHtml('hello bold world', '<b>hello bold world</b>');
expect(html).not.toContain('hello bold world');
});
});
@@ -3,13 +3,27 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
// @tiptap/markdown parses markdown for load/insertContent but never for the clipboard, so a paste of block
// markdown (# ## - 1. | >) would land as literal text (only StarterKit's inline mark paste-rules fire).
// Route plain-text pastes through the same markdown layer as load; defer to ProseMirror's own path for rich
// sources — an in-editor copy (carries `data-pm-slice`) or web/Office HTML (block tags) — so their fidelity
// survives, and keep pastes inside code literal.
// Route markdown-looking plain-text pastes through the same markdown layer as load; defer to ProseMirror's
// own path for rich sources — an in-editor copy (carries `data-pm-slice`), web/Office HTML (block tags), or
// inline-formatted HTML whose text carries no markdown — so their fidelity survives, and keep pastes inside
// code literal.
const RICH_HTML_BLOCK = /<(?:h[1-6]|ul|ol|li|table|thead|tbody|tr|td|th|blockquote|pre|img|hr)\b/i;
const MARKDOWN_CUES = [
/^#{1,6}\s/m, // heading
/\*\*[^*]+\*\*/, // bold
/\[.+\]\(.+\)/, // link
/^[-*+]\s/m, // bullet item
/^\d+\.\s/m, // ordered item
/^ {0,3}(?:```|~~~)/m, // fence
/^\|/m, // table row
/^>\s/m, // blockquote
];
const looksLikeMarkdown = (text: string): boolean => MARKDOWN_CUES.some((cue) => cue.test(text));
export const shouldParseMarkdownOnPaste = (text: string, html: string, isCodeContext: boolean): boolean => {
if (!text.trim()) {
if (!text.trim() || isCodeContext) {
return false;
}
@@ -17,7 +31,16 @@ export const shouldParseMarkdownOnPaste = (text: string, html: string, isCodeCon
return false;
}
return !isCodeContext;
// Inline-only rich HTML (a Google Docs/Word/mail single paragraph: <b>, <a href>, styled spans) whose
// text/plain carries no markdown: defer to ProseMirror's native HTML parse — it runs the clipboard HTML
// through the schema, so bold/italic/links survive as marks. Markdown-looking text still wins the
// markdown parse: a VS Code copy of markdown source arrives wrapped in syntax-color spans, and parsing
// its text/plain (not the span noise) is the point of this plugin.
if (html && !looksLikeMarkdown(text)) {
return false;
}
return true;
};
export const MarkdownPaste = Extension.create({