diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-paste.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-paste.test.ts index 0e353be1..5b0655fa 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-paste.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-paste.test.ts @@ -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**', '**x**', false)).toBe(true); }); + it.each([ + ['# heading', '# heading'], + ['- item one\n- item two', '- item one
- item two
'], + ['| a | b |', '| a | b |'], + ['```\ncode\n```', '```'], + ['> quoted', '> quoted'], + ['see [docs](https://x.dev)', 'see [docs](https://x.dev)'], + ['1. first', '1. first'], + ])('parses markdown-looking text %s despite an inline-only HTML wrapper', (text, html) => { + expect(shouldParseMarkdownOnPaste(text, html, false)).toBe(true); + }); + + it.each([ + ['hello world', 'hello world'], // Google Docs bold paragraph + ['see the docs', 'see the docs'], // inline link + ['plain sentence', 'plain sentence'], // 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('

'); }); + + it('defers inline-formatted HTML with markdown-free text (plugin inserts nothing)', () => { + const html = pasteHtml('hello bold world', 'hello bold world'); + + expect(html).not.toContain('hello bold world'); + }); }); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-paste.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-paste.ts index 9560bb07..955f1631 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-paste.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-paste.ts @@ -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: , , 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({