···5252import { exportDocx } from './docx-export.js';
5353import { importDocx, isValidDocx } from './docx-import.js';
5454import { markdownToHtml } from './markdown-parser.js';
5555-import { looksLikeMarkdown } from './markdown-paste.js';
5555+import { looksLikeMarkdown, htmlContainsRawMarkdown } from './markdown-paste.js';
5656import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js';
5757import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js';
5858import { VersionManager, computeWordCount } from '../lib/version-history.js';
···273273 handlePaste: (_view, event) => {
274274 const clip = event.clipboardData;
275275 if (!clip) return false;
276276- // If clipboard has HTML, let TipTap handle it natively
277277- if (clip.types.includes('text/html')) return false;
278276 const text = clip.getData('text/plain');
279277 if (!text || !looksLikeMarkdown(text)) return false;
278278+ // If clipboard has HTML, only override when the HTML contains
279279+ // unrendered markdown syntax (e.g. literal [text](url) not in <a> tags).
280280+ // Well-rendered HTML (from rich text editors) is left to TipTap.
281281+ if (clip.types.includes('text/html')) {
282282+ const clipHtml = clip.getData('text/html');
283283+ if (!htmlContainsRawMarkdown(clipHtml)) return false;
284284+ }
280285 const html = markdownToHtml(text);
281286 if (!html) return false;
282287 event.preventDefault();
+20
src/docs/markdown-paste.ts
···44 * Conservatively detects whether pasted plain text is markdown.
55 * Requires 2+ signals or 1 strong signal (heading, code block, table)
66 * to avoid false positives on normal text.
77+ *
88+ * Also detects when clipboard HTML contains unrendered markdown link
99+ * syntax, indicating the source didn't convert markdown to rich text.
710 */
811912const PATTERNS: readonly RegExp[] = [
···45484649 return false;
4750}
5151+5252+/**
5353+ * Check whether clipboard HTML contains unrendered markdown link syntax.
5454+ * When apps copy content, they may include both text/html and text/plain.
5555+ * If the HTML contains literal `[text](url)` strings (not rendered as <a> tags),
5656+ * the plain text markdown path should be preferred.
5757+ */
5858+// Looser task list pattern for HTML context (no start-of-line anchor)
5959+const RAW_TASK_LIST_IN_HTML = /- \[([ xX])\]\s/;
6060+6161+export function htmlContainsRawMarkdown(html: string): boolean {
6262+ if (!html) return false;
6363+ // Strip actual <a> tags and their content to avoid false positives,
6464+ // then check if raw markdown link syntax remains in the text content.
6565+ const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gi, '');
6666+ return MARKDOWN_LINK_RE.test(stripped) || RAW_TASK_LIST_IN_HTML.test(stripped);
6767+}
+29-1
tests/markdown-paste.test.ts
···11import { describe, it, expect } from 'vitest';
22-import { looksLikeMarkdown } from '../src/docs/markdown-paste.js';
22+import { looksLikeMarkdown, htmlContainsRawMarkdown } from '../src/docs/markdown-paste.js';
3344describe('looksLikeMarkdown', () => {
55 // --- Strong solo signals (should return true with just 1) ---
···108108 expect(looksLikeMarkdown(' - Indented item\n - Another\n> Quote')).toBe(true);
109109 });
110110});
111111+112112+describe('htmlContainsRawMarkdown', () => {
113113+ it('detects raw markdown links in HTML', () => {
114114+ expect(htmlContainsRawMarkdown('<p>[Example](https://example.com)</p>')).toBe(true);
115115+ });
116116+117117+ it('detects raw task lists in HTML', () => {
118118+ expect(htmlContainsRawMarkdown('<li>- [ ] Buy milk</li>')).toBe(true);
119119+ });
120120+121121+ it('ignores properly rendered <a> tags', () => {
122122+ expect(htmlContainsRawMarkdown('<p>Check <a href="https://example.com">Example</a></p>')).toBe(false);
123123+ });
124124+125125+ it('detects raw links alongside rendered links', () => {
126126+ expect(htmlContainsRawMarkdown(
127127+ '<p><a href="https://a.com">A</a> and [B](https://b.com)</p>'
128128+ )).toBe(true);
129129+ });
130130+131131+ it('returns false for empty input', () => {
132132+ expect(htmlContainsRawMarkdown('')).toBe(false);
133133+ });
134134+135135+ it('returns false for plain HTML without markdown', () => {
136136+ expect(htmlContainsRawMarkdown('<p>Hello world</p>')).toBe(false);
137137+ });
138138+});