diff --git a/web/src/components/common/MarkdownPreview.vue b/web/src/components/common/MarkdownPreview.vue index 54917163..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 index 41c88b44..4c0fe878 100644 --- a/web/src/utils/__tests__/svgRenderer.test.js +++ b/web/src/utils/__tests__/svgRenderer.test.js @@ -10,6 +10,12 @@ const run = () => { 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') } @@ -30,6 +36,7 @@ const run = () => { 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') } @@ -63,6 +70,8 @@ const run = () => { 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') } @@ -129,11 +138,28 @@ const run = () => { { 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') } - console.log('\nAll 15 tests passed!') + // 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('\s+<') .replace(/\s{2,}/g, ' ') .trim() - output.push(`
${singleLine}
`) + const actionsHtml = [ + `
`, + ``, + ``, + `
` + ].join('') + output.push(`
${actionsHtml}${singleLine}
`) i++ break }