feat(markdown): add hover download buttons on rendered SVGs

Attach SVG and PNG (×2) download buttons to every rendered SVG (TikZ,
Mermaid, …) once post-render finishes. Exports always use the original
colors with a white background baked in, regardless of theme.
This commit is contained in:
Julien Calixte
2026-05-16 12:17:04 +02:00
parent f3708b2f9d
commit 9d1b6552cb
4 changed files with 203 additions and 6 deletions

View File

@@ -174,7 +174,7 @@ export const useShikiji = (): Promise<void> => {
let mermaidInitialized = false
export const runMermaid = (querySelector: string) => {
export const runMermaid = (querySelector: string): Promise<void> => {
if (!mermaidInitialized) {
mermaidInitialized = true
mermaid.initialize({
@@ -184,7 +184,7 @@ export const runMermaid = (querySelector: string) => {
})
}
mermaid.run({
return mermaid.run({
querySelector
})
}

View File

@@ -6,6 +6,7 @@ import {
runTikz,
useShikiji
} from "@/hooks/useMarkdown.hook"
import { attachSvgDownloads } from "@/utils/svgDownload"
interface MarkdownPostRenderOptions {
onReady?: () => void
@@ -31,13 +32,16 @@ export const useMarkdownPostRender = (
options.onReady?.()
const scope = scopeSelector()
const wantsTikz = !!options.tikz
const wantsMermaid = !!options.mermaid?.()
if (options.tikz) {
void runTikz(`${scope} .tikz`)
const renderJobs: Promise<unknown>[] = []
if (wantsTikz) {
renderJobs.push(runTikz(`${scope} .tikz`))
}
if (options.mermaid?.()) {
runMermaid(`${scope} .mermaid`)
if (wantsMermaid) {
renderJobs.push(runMermaid(`${scope} .mermaid`))
}
if (options.shikiji?.()) {
@@ -48,6 +52,12 @@ export const useMarkdownPostRender = (
if (imagesSha) {
useImages(imagesSha)
}
if (wantsTikz || wantsMermaid) {
await Promise.allSettled(renderJobs)
await nextTick()
attachSvgDownloads(document.querySelector(scope))
}
},
{ immediate: true }
)

View File

@@ -219,6 +219,47 @@ pre.tikz svg {
font-style: italic;
}
.svg-download-host {
position: relative;
display: inline-block;
max-width: 100%;
}
.svg-download-buttons {
position: absolute;
top: 0.25rem;
right: 0.25rem;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 150ms ease-in-out;
pointer-events: none;
z-index: 2;
}
.svg-download-host:hover .svg-download-buttons,
.svg-download-host:focus-within .svg-download-buttons {
opacity: 1;
pointer-events: auto;
}
.svg-download-button {
font-size: 0.7rem;
padding-inline: 0.4rem;
height: 1.5rem;
min-height: 1.5rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.svg-download-button .icon-tabler-download {
width: 0.9rem;
height: 0.9rem;
max-width: none;
filter: none;
}
.tikz-error {
border-left: 3px solid var(--color-error);
padding: 0.5rem 0.75rem;

146
src/utils/svgDownload.ts Normal file
View File

@@ -0,0 +1,146 @@
const ATTACHED_FLAG = "svgDownloadAttached"
const SVG_NS = "http://www.w3.org/2000/svg"
const TABLER_DOWNLOAD_ICON = `<svg xmlns="${SVG_NS}" class="icon icon-tabler icon-tabler-download" width="14" height="14" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 11l5 5l5 -5"/><path d="M12 4l0 12"/></svg>`
const getSvgPixelSize = (svg: SVGSVGElement): { width: number; height: number } => {
const viewBox = svg.viewBox?.baseVal
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { width: viewBox.width, height: viewBox.height }
}
const rect = svg.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height }
}
return { width: 800, height: 600 }
}
const buildExportableSvgString = (svg: SVGSVGElement): string => {
const clone = svg.cloneNode(true) as SVGSVGElement
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", SVG_NS)
if (!clone.getAttribute("xmlns:xlink"))
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
const { width, height } = getSvgPixelSize(svg)
if (!clone.getAttribute("viewBox")) {
clone.setAttribute("viewBox", `0 0 ${width} ${height}`)
}
clone.setAttribute("width", String(width))
clone.setAttribute("height", String(height))
const bg = document.createElementNS(SVG_NS, "rect")
bg.setAttribute("x", "0")
bg.setAttribute("y", "0")
bg.setAttribute("width", "100%")
bg.setAttribute("height", "100%")
bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild)
return new XMLSerializer().serializeToString(clone)
}
const triggerBlobDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
const downloadAsSvg = (svg: SVGSVGElement, filename: string): void => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n${buildExportableSvgString(svg)}`
const blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" })
triggerBlobDownload(blob, filename)
}
const downloadAsPng = async (
svg: SVGSVGElement,
filename: string,
scale = 2
): Promise<void> => {
const { width, height } = getSvgPixelSize(svg)
const svgString = buildExportableSvgString(svg)
const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" })
const svgUrl = URL.createObjectURL(svgBlob)
try {
const img = new Image()
img.crossOrigin = "anonymous"
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error("Failed to load SVG for PNG export"))
img.src = svgUrl
})
const canvas = document.createElement("canvas")
canvas.width = Math.max(1, Math.round(width * scale))
canvas.height = Math.max(1, Math.round(height * scale))
const ctx = canvas.getContext("2d")
if (!ctx) throw new Error("Canvas 2D context unavailable")
ctx.fillStyle = "#ffffff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const pngBlob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
)
if (!pngBlob) throw new Error("Failed to encode PNG")
triggerBlobDownload(pngBlob, filename)
} finally {
URL.revokeObjectURL(svgUrl)
}
}
const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => {
const btn = document.createElement("button")
btn.type = "button"
btn.className = "btn btn-xs btn-neutral svg-download-button"
btn.title = title
btn.setAttribute("aria-label", title)
btn.innerHTML = `${TABLER_DOWNLOAD_ICON}<span>${label}</span>`
btn.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
onClick()
})
return btn
}
export const attachSvgDownloads = (scope: ParentNode | null | undefined): void => {
if (!scope) return
const svgs = scope.querySelectorAll<SVGSVGElement>("svg")
let index = 0
svgs.forEach((svg) => {
if (svg.closest(".svg-download-button")) return
if (svg.closest(".svg-download-host")) return
const host = document.createElement("span")
host.className = "svg-download-host"
if (svg.dataset) host.dataset[ATTACHED_FLAG] = "true"
const parent = svg.parentNode
if (!parent) return
parent.insertBefore(host, svg)
host.appendChild(svg)
const toolbar = document.createElement("div")
toolbar.className = "svg-download-buttons"
const n = index++
const baseName = `diagram-${n + 1}`
toolbar.appendChild(
makeButton("SVG", "Download SVG", () =>
downloadAsSvg(svg, `${baseName}.svg`)
)
)
toolbar.appendChild(
makeButton("PNG ×2", "Download PNG (2×)", () => {
void downloadAsPng(svg, `${baseName}.png`, 2)
})
)
host.appendChild(toolbar)
})
}