feat(web): 添加 SVG 操作按钮(复制 SVG 源码/复制为 PNG)及事件委托
- svgRenderer.js 输出中嵌入 .svg-actions 操作按钮 - MarkdownPreview.vue 添加事件委托处理复制逻辑 - PNG 尺寸策略:viewBox 固有坐标 → getBoundingClientRect → 800x600 - Canvas 不强制填充背景色,由 SVG 自身决定 - 深色模式适配
This commit is contained in:
parent
4d300f82af
commit
43f2f2bb28
@ -6,6 +6,7 @@
|
||||
{ 'is-dark': themeStore.isDark, 'is-compact': compact }
|
||||
]"
|
||||
v-html="renderedMarkdown"
|
||||
@click="handleSvgAction"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@ -336,6 +432,7 @@ watch(
|
||||
}
|
||||
|
||||
.svg-inline-render {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
@ -345,11 +442,56 @@ watch(
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.svg-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
|
||||
.svg-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 4px;
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-700);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .svg-actions {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-dark .svg-inline-render {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.is-dark .svg-actions .svg-action-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--gray-300);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--gray-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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('<stop offset="0%"/><stop offset="100%"/>'), '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<svg id="1"/>\n```\ntext\n```svg\n<svg id="2"/>\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<svg><!-- comment --><circle/></svg>\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('<!-- comment -->'), '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<svg viewBox="0 0 100 50"><circle/></svg>\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('<svg ')
|
||||
assert.ok(actionsIdx < svgIdx, 'Buttons wrapper should appear before SVG element')
|
||||
console.log('T16 Action buttons structure: PASS')
|
||||
}
|
||||
|
||||
console.log('\nAll 16 tests passed!')
|
||||
}
|
||||
|
||||
run()
|
||||
@ -44,7 +44,13 @@ export function renderSvgBlocks(markdown) {
|
||||
.replace(/>\s+</g, '><')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
output.push(`<div class="svg-inline-render">${singleLine}</div>`)
|
||||
const actionsHtml = [
|
||||
`<div class="svg-actions">`,
|
||||
`<button class="svg-action-btn svg-copy-btn" type="button">复制 SVG</button>`,
|
||||
`<button class="svg-action-btn svg-png-btn" type="button">复制为 PNG</button>`,
|
||||
`</div>`
|
||||
].join('')
|
||||
output.push(`<div class="svg-inline-render">${actionsHtml}${singleLine}</div>`)
|
||||
i++
|
||||
break
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user