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

Configure Feed

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

Merge pull request 'fix: prefer markdown conversion when clipboard HTML has raw syntax' (#276) from fix/markdown-paste-html-fallback into main

scott c4cda942 2c176dfd

+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 + });