Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: prefer markdown conversion when clipboard HTML contains raw syntax

When copying from apps like GitHub or text editors, the clipboard often
includes both text/html and text/plain. The HTML may wrap raw markdown
text in basic tags without converting [text](url) to <a> links.

Previously the paste handler always deferred to TipTap for HTML
clipboard content. Now it checks whether the HTML contains unrendered
markdown link syntax or task list patterns. If so, the plain text
markdown conversion path is used instead, producing proper TipTap
link marks and task items.

+57 -4
+8 -3
src/docs/main.ts
··· 52 52 import { exportDocx } from './docx-export.js'; 53 53 import { importDocx, isValidDocx } from './docx-import.js'; 54 54 import { markdownToHtml } from './markdown-parser.js'; 55 - import { looksLikeMarkdown } from './markdown-paste.js'; 55 + import { looksLikeMarkdown, htmlContainsRawMarkdown } from './markdown-paste.js'; 56 56 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 57 57 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 58 58 import { VersionManager, computeWordCount } from '../lib/version-history.js'; ··· 273 273 handlePaste: (_view, event) => { 274 274 const clip = event.clipboardData; 275 275 if (!clip) return false; 276 - // If clipboard has HTML, let TipTap handle it natively 277 - if (clip.types.includes('text/html')) return false; 278 276 const text = clip.getData('text/plain'); 279 277 if (!text || !looksLikeMarkdown(text)) return false; 278 + // If clipboard has HTML, only override when the HTML contains 279 + // unrendered markdown syntax (e.g. literal [text](url) not in <a> tags). 280 + // Well-rendered HTML (from rich text editors) is left to TipTap. 281 + if (clip.types.includes('text/html')) { 282 + const clipHtml = clip.getData('text/html'); 283 + if (!htmlContainsRawMarkdown(clipHtml)) return false; 284 + } 280 285 const html = markdownToHtml(text); 281 286 if (!html) return false; 282 287 event.preventDefault();
+20
src/docs/markdown-paste.ts
··· 4 4 * Conservatively detects whether pasted plain text is markdown. 5 5 * Requires 2+ signals or 1 strong signal (heading, code block, table) 6 6 * to avoid false positives on normal text. 7 + * 8 + * Also detects when clipboard HTML contains unrendered markdown link 9 + * syntax, indicating the source didn't convert markdown to rich text. 7 10 */ 8 11 9 12 const PATTERNS: readonly RegExp[] = [ ··· 45 48 46 49 return false; 47 50 } 51 + 52 + /** 53 + * Check whether clipboard HTML contains unrendered markdown link syntax. 54 + * When apps copy content, they may include both text/html and text/plain. 55 + * If the HTML contains literal `[text](url)` strings (not rendered as <a> tags), 56 + * the plain text markdown path should be preferred. 57 + */ 58 + // Looser task list pattern for HTML context (no start-of-line anchor) 59 + const RAW_TASK_LIST_IN_HTML = /- \[([ xX])\]\s/; 60 + 61 + export function htmlContainsRawMarkdown(html: string): boolean { 62 + if (!html) return false; 63 + // Strip actual <a> tags and their content to avoid false positives, 64 + // then check if raw markdown link syntax remains in the text content. 65 + const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gi, ''); 66 + return MARKDOWN_LINK_RE.test(stripped) || RAW_TASK_LIST_IN_HTML.test(stripped); 67 + }
+29 -1
tests/markdown-paste.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { looksLikeMarkdown } from '../src/docs/markdown-paste.js'; 2 + import { looksLikeMarkdown, htmlContainsRawMarkdown } from '../src/docs/markdown-paste.js'; 3 3 4 4 describe('looksLikeMarkdown', () => { 5 5 // --- Strong solo signals (should return true with just 1) --- ··· 108 108 expect(looksLikeMarkdown(' - Indented item\n - Another\n> Quote')).toBe(true); 109 109 }); 110 110 }); 111 + 112 + describe('htmlContainsRawMarkdown', () => { 113 + it('detects raw markdown links in HTML', () => { 114 + expect(htmlContainsRawMarkdown('<p>[Example](https://example.com)</p>')).toBe(true); 115 + }); 116 + 117 + it('detects raw task lists in HTML', () => { 118 + expect(htmlContainsRawMarkdown('<li>- [ ] Buy milk</li>')).toBe(true); 119 + }); 120 + 121 + it('ignores properly rendered <a> tags', () => { 122 + expect(htmlContainsRawMarkdown('<p>Check <a href="https://example.com">Example</a></p>')).toBe(false); 123 + }); 124 + 125 + it('detects raw links alongside rendered links', () => { 126 + expect(htmlContainsRawMarkdown( 127 + '<p><a href="https://a.com">A</a> and [B](https://b.com)</p>' 128 + )).toBe(true); 129 + }); 130 + 131 + it('returns false for empty input', () => { 132 + expect(htmlContainsRawMarkdown('')).toBe(false); 133 + }); 134 + 135 + it('returns false for plain HTML without markdown', () => { 136 + expect(htmlContainsRawMarkdown('<p>Hello world</p>')).toBe(false); 137 + }); 138 + });