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({