diff --git a/.gitignore b/.gitignore index bb46c8d0..ec4c35ca 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ cache .cursor .trae .codex +.sisyphus .pytest_cache *.secret* diff --git a/web/src/components/common/MarkdownPreview.vue b/web/src/components/common/MarkdownPreview.vue index 7c55cb81..c0091d85 100644 --- a/web/src/components/common/MarkdownPreview.vue +++ b/web/src/components/common/MarkdownPreview.vue @@ -6,6 +6,7 @@ { 'is-dark': themeStore.isDark, 'is-compact': compact } ]" v-html="renderedMarkdown" + @click="handleSvgAction" > @@ -48,6 +49,101 @@ watch( }, { immediate: true } ) + +// === SVG 操作按钮事件委托 === +const handleSvgAction = async (e) => { + const btn = e.target.closest('.svg-copy-btn, .svg-png-btn') + if (!btn) return + + const container = btn.closest('.svg-inline-render') + const svgEl = container?.querySelector('svg') + if (!svgEl) return + + if (btn.classList.contains('svg-copy-btn')) { + await copySvgText(svgEl, btn) + } else if (btn.classList.contains('svg-png-btn')) { + await copySvgAsPng(svgEl, btn) + } +} + +// 复制 SVG 源代码 +const copySvgText = async (svgEl, btn) => { + try { + await navigator.clipboard.writeText(svgEl.outerHTML) + showCopiedFeedback(btn) + } catch (err) { + console.error('复制 SVG 失败:', err) + } +} + +// 复制为 PNG 图片 +const copySvgAsPng = async (svgEl, btn) => { + const svgContent = svgEl.outerHTML + const blob = new Blob([svgContent], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + + try { + // 三级递进尺寸策略: + // 1) viewBox 固有坐标尺寸(最佳品质,不受 CSS 缩放影响) + let width, height + const vb = svgEl.viewBox + if (vb && vb.baseVal && vb.baseVal.width && vb.baseVal.height) { + width = vb.baseVal.width + height = vb.baseVal.height + } + + // 2) 客户端渲染尺寸(SVG 在 DOM 中一定可获取) + if (!width || !height) { + const rect = svgEl.getBoundingClientRect() + width = rect.width + height = rect.height + } + + // 3) 回退 + if (!width || !height) { width = 800; height = 600 } + + const img = await new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = reject + image.src = url + }) + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + // 不填充背景色 — Canvas 默认为全透明 + // 背景色由 SVG 自身决定 + ctx.drawImage(img, 0, 0, width, height) + + const pngBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) + if (pngBlob) { + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': pngBlob }) + ]) + showCopiedFeedback(btn) + } + } catch (err) { + console.error('复制为 PNG 失败:', err) + // fallback: 尝试复制 SVG 源码 + try { + await navigator.clipboard.writeText(svgContent) + console.log('PNG 复制失败,已回退复制 SVG 源码') + } catch {} + } finally { + URL.revokeObjectURL(url) + } +} + +// 反馈:按钮文字短暂变为「已复制」 +const showCopiedFeedback = (btn) => { + const originalText = btn.textContent + btn.textContent = '已复制' + setTimeout(() => { + btn.textContent = originalText + }, 1500) +} diff --git a/web/src/utils/__tests__/svgRenderer.test.js b/web/src/utils/__tests__/svgRenderer.test.js new file mode 100644 index 00000000..4c0fe878 --- /dev/null +++ b/web/src/utils/__tests__/svgRenderer.test.js @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { renderSvgBlocks } from '../svgRenderer.js' + +const run = () => { + // 1. Basic backtick fence + { + const result = renderSvgBlocks('before\n```svg\n\n```\nafter') + assert.ok(result.includes('svg-inline-render'), 'Should contain svg-inline-render wrapper') + assert.ok(result.includes(''), 'Should contain SVG tag') + assert.ok(!result.includes('```svg'), 'Should NOT contain raw fence marker') + assert.ok(result.includes('before'), 'Should preserve content before block') + assert.ok(result.includes('after'), 'Should preserve content after block') + // 按钮存在 + assert.ok(result.includes('svg-actions'), 'Should contain svg-actions wrapper') + assert.ok(result.includes('svg-copy-btn'), 'Should contain copy button') + assert.ok(result.includes('svg-png-btn'), 'Should contain PNG button') + assert.ok(result.includes('复制 SVG'), 'Should contain copy text') + assert.ok(result.includes('复制为 PNG'), 'Should contain PNG text') + console.log('T1 Basic backtick fence: PASS') + } + + // 2. Tilde fence + { + const result = renderSvgBlocks('~~~svg\n\n~~~') + assert.ok(result.includes('svg-inline-render'), 'Tilde fence should be converted') + assert.ok(result.includes(''), 'Should contain SVG content') + console.log('T2 Tilde fence: PASS') + } + + // 3. SVG with blank lines (compressed to single line) + { + const result = renderSvgBlocks( + '```svg\n\n\n\n\n\n\n\n\n\n```' + ) + const lines = result.split('\n') + assert.equal(lines.length, 1, 'Should be compressed to single line') + assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper') + assert.ok(result.includes(''), 'Blank lines should be removed between tags') + assert.ok(result.includes('svg-copy-btn'), 'Buttons should be inside single-line output') + console.log('T3 Blank lines compressed: PASS') + } + + // 4. Case insensitive (SVG / Svg / sVG) + { + const result = renderSvgBlocks('```SVG\n\n```') + assert.ok(result.includes('svg-inline-render'), 'Uppercase SVG should be matched') + console.log('T4 Case insensitive: PASS') + } + + // 5. Incomplete block (streaming safety) + { + const result = renderSvgBlocks('before\n```svg\n') + assert.ok(result.includes('```svg'), 'Incomplete block should keep raw fence') + assert.ok(!result.includes('svg-inline-render'), 'Incomplete block should NOT be converted') + assert.ok(result.includes('before'), 'Should preserve content before') + console.log('T5 Incomplete block (streaming safety): PASS') + } + + // 6. Non-SVG code block unchanged + { + const result = renderSvgBlocks('```python\nprint(1)\n```') + assert.ok(result.includes('```python'), 'Python fence should be preserved') + assert.ok(result.includes('```'), 'Closing fence should be preserved') + assert.ok(!result.includes('svg-inline-render'), 'Non-SVG block should NOT be converted') + console.log('T6 Non-SVG code block unchanged: PASS') + } + + // 7. Multiple consecutive SVG blocks + { + const result = renderSvgBlocks('```svg\n\n```\ntext\n```svg\n\n```') + const matches = result.match(/svg-inline-render/g) + assert.equal(matches ? matches.length : 0, 2, 'Should convert both SVG blocks') + const btnMatches = result.match(/svg-copy-btn/g) + assert.equal(btnMatches ? btnMatches.length : 0, 2, 'Each SVG block should have buttons') + assert.ok(result.includes('text'), 'Should preserve text between blocks') + console.log('T7 Multiple SVG blocks: PASS') + } + + // 8. Empty SVG code block + { + const result = renderSvgBlocks('```svg\n```') + assert.ok(result.includes('svg-inline-render'), 'Empty SVG block should still be converted') + console.log('T8 Empty SVG block: PASS') + } + + // 9. No SVG blocks (identity transformation) + { + const result = renderSvgBlocks('hello world\n\nsome text') + assert.equal(result, 'hello world\n\nsome text', 'Should be identity when no SVG blocks') + console.log('T9 Identity (no SVG blocks): PASS') + } + + // 10. SVG content with backticks inside + { + const result = renderSvgBlocks('```svg\n`code`\n```') + assert.ok(result.includes('svg-inline-render'), 'Should convert SVG with backticks in content') + assert.ok(result.includes('`code`'), 'Should preserve backticks inside SVG content') + console.log('T10 Backticks in SVG content: PASS') + } + + // 11. Fence with attributes on opening line + { + const result = renderSvgBlocks('```svg id="mySvg"\n\n```') + assert.ok(result.includes('svg-inline-render'), 'Fence with attributes should be converted') + console.log('T11 Fence with attributes: PASS') + } + + // 12. SVG block at start of content + { + const result = renderSvgBlocks('```svg\n\n```\n\nsome text after') + assert.ok(result.includes('svg-inline-render'), 'SVG at start should be converted') + assert.ok(result.includes('some text after'), 'Should preserve text after') + console.log('T12 SVG at start: PASS') + } + + // 13. SVG block at end of content + { + const result = renderSvgBlocks('some text before\n\n```svg\n\n```') + assert.ok(result.includes('svg-inline-render'), 'SVG at end should be converted') + assert.ok(result.includes('some text before'), 'Should preserve text before') + console.log('T13 SVG at end: PASS') + } + + // 14. Multiple non-consecutive SVG blocks with other content + { + const result = renderSvgBlocks( + '# Title\n\n```svg\n\n```\n\nSome text\n\n```svg\n\n```\n\n# End' + ) + const matches = result.match(/svg-inline-render/g) + assert.equal(matches ? matches.length : 0, 2, 'Should convert both non-consecutive SVG blocks') + assert.ok(result.includes('# Title'), 'Should preserve markdown headings') + assert.ok(result.includes('Some text'), 'Should preserve text between blocks') + assert.ok(result.includes('# End'), 'Should preserve trailing content') + console.log('T14 Non-consecutive SVG blocks: PASS') + } + + // 15. SVG content with HTML comments + { + const result = renderSvgBlocks('```svg\n\n```') + assert.ok(result.includes('svg-inline-render'), 'SVG with HTML comments should be converted') + assert.ok(result.includes('svg-copy-btn'), 'Buttons should be present') + assert.ok(result.includes(''), 'Should preserve HTML comments') + console.log('T15 SVG with HTML comments: PASS') + } + + // 16. Action buttons are present in complete SVG blocks + { + const result = renderSvgBlocks('```svg\n\n```') + assert.ok(result.includes('svg-actions'), 'Should contain svg-actions wrapper') + assert.ok(result.includes('svg-copy-btn'), 'Should contain copy button') + assert.ok(result.includes('svg-png-btn'), 'Should contain PNG button') + assert.ok(result.includes('type="button"'), 'Buttons should have type="button"') + assert.ok(result.includes('复制 SVG'), 'Copy button should have Chinese label') + assert.ok(result.includes('复制为 PNG'), 'PNG button should have Chinese label') + // 按钮在 svg 之前(渲染层在上方) + const actionsIdx = result.indexOf('svg-actions') + const svgIdx = result.indexOf(' { export const renderMarkdown = async (content, { theme = 'github-light' } = {}) => { try { const normalizedContent = normalizeHtmlTagQuotes(content) + const svgContent = renderSvgBlocks(normalizedContent) const themeName = normalizeTheme(theme) - const needsHighlight = hasCodeFence(normalizedContent) - const cacheKey = `${needsHighlight ? themeName : 'plain'}\u0000${normalizedContent}` + const needsHighlight = hasCodeFence(svgContent) + const cacheKey = `${needsHighlight ? themeName : 'plain'}\u0000${svgContent}` const cachedHtml = getCachedHtml(cacheKey) if (cachedHtml !== undefined) return cachedHtml if (needsHighlight) { const highlighter = await getHighlighter() - await ensureLanguages(highlighter, collectCodeFenceLanguages(normalizedContent)) + await ensureLanguages(highlighter, collectCodeFenceLanguages(svgContent)) } const md = await getRenderer(themeName, needsHighlight) - const html = DOMPurify.sanitize(md.render(normalizedContent), { + const html = DOMPurify.sanitize(md.render(svgContent), { ADD_TAGS: ['input'], ADD_ATTR: [ 'class', diff --git a/web/src/utils/svgRenderer.js b/web/src/utils/svgRenderer.js new file mode 100644 index 00000000..3b0ac6c6 --- /dev/null +++ b/web/src/utils/svgRenderer.js @@ -0,0 +1,73 @@ +/** + * SVG 渲染工具函数 + * 将 Markdown 中的 ```svg 围栏代码块转换为内联 SVG HTML + */ + +/** + * 将 Markdown 字符串中的 ```svg 围栏代码块转换为内联 SVG HTML + * + * 使用逐行扫描算法而非单一大正则,以避免嵌套围栏和内容中反引号的误匹配。 + * SVG 内容会被压缩为单行,防止 markdown-it HTML 块解析因空白行截断内容。 + * + * @param {string} markdown - 原始 Markdown 字符串 + * @returns {string} 转换后的字符串,SVG 围栏被替换为
...
+ */ +export function renderSvgBlocks(markdown) { + const lines = markdown.split('\n') + const output = [] + let i = 0 + + while (i < lines.length) { + const openMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*(\S*)/) + + if (openMatch && openMatch[3].toLowerCase() === 'svg') { + const indent = openMatch[1] + const fenceChar = openMatch[2] + const openLine = lines[i] + const svgLines = [] + i++ + + // 扫描闭合围栏 + let closed = false + while (i < lines.length) { + const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/) + if ( + closeMatch + && closeMatch[1].length <= indent.length + && closeMatch[2][0] === fenceChar[0] + && closeMatch[2].length >= fenceChar.length + ) { + closed = true + // 压缩 SVG 为单行,防止 markdown-it HTML 块解析截断 + const singleLine = svgLines + .join('') + .replace(/>\s+<') + .replace(/\s{2,}/g, ' ') + .trim() + const actionsHtml = [ + `
`, + ``, + ``, + `
` + ].join('') + output.push(`
${actionsHtml}${singleLine}
`) + i++ + break + } + svgLines.push(lines[i]) + i++ + } + + if (!closed) { + // 未闭合的围栏 — 保持原样(流式安全) + output.push(openLine) + output.push(...svgLines) + } + } else { + output.push(lines[i]) + i++ + } + } + + return output.join('\n') +} \ No newline at end of file