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 }
|
{ 'is-dark': themeStore.isDark, 'is-compact': compact }
|
||||||
]"
|
]"
|
||||||
v-html="renderedMarkdown"
|
v-html="renderedMarkdown"
|
||||||
|
@click="handleSvgAction"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -48,6 +49,101 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
@ -336,6 +432,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.svg-inline-render {
|
.svg-inline-render {
|
||||||
|
position: relative;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -345,11 +442,56 @@ watch(
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
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 {
|
&.is-dark .svg-inline-render {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-radius: 4px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -10,6 +10,12 @@ const run = () => {
|
|||||||
assert.ok(!result.includes('```svg'), 'Should NOT contain raw fence marker')
|
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('before'), 'Should preserve content before block')
|
||||||
assert.ok(result.includes('after'), 'Should preserve content after 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')
|
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.equal(lines.length, 1, 'Should be compressed to single line')
|
||||||
assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper')
|
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('<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')
|
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 result = renderSvgBlocks('```svg\n<svg id="1"/>\n```\ntext\n```svg\n<svg id="2"/>\n```')
|
||||||
const matches = result.match(/svg-inline-render/g)
|
const matches = result.match(/svg-inline-render/g)
|
||||||
assert.equal(matches ? matches.length : 0, 2, 'Should convert both SVG blocks')
|
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')
|
assert.ok(result.includes('text'), 'Should preserve text between blocks')
|
||||||
console.log('T7 Multiple SVG blocks: PASS')
|
console.log('T7 Multiple SVG blocks: PASS')
|
||||||
}
|
}
|
||||||
@ -129,11 +138,28 @@ const run = () => {
|
|||||||
{
|
{
|
||||||
const result = renderSvgBlocks('```svg\n<svg><!-- comment --><circle/></svg>\n```')
|
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-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')
|
assert.ok(result.includes('<!-- comment -->'), 'Should preserve HTML comments')
|
||||||
console.log('T15 SVG with HTML comments: PASS')
|
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()
|
run()
|
||||||
@ -44,7 +44,13 @@ export function renderSvgBlocks(markdown) {
|
|||||||
.replace(/>\s+</g, '><')
|
.replace(/>\s+</g, '><')
|
||||||
.replace(/\s{2,}/g, ' ')
|
.replace(/\s{2,}/g, ' ')
|
||||||
.trim()
|
.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++
|
i++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user