From 9d1b6552cb47a498407180a0a7bac68760cec312 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 16 May 2026 12:17:04 +0200 Subject: [PATCH] feat(markdown): add hover download buttons on rendered SVGs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useMarkdown.hook.ts | 4 +- src/hooks/useMarkdownPostRender.hook.ts | 18 ++- src/styles/app.css | 41 +++++++ src/utils/svgDownload.ts | 146 ++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/utils/svgDownload.ts diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts index beb2088..933d081 100644 --- a/src/hooks/useMarkdown.hook.ts +++ b/src/hooks/useMarkdown.hook.ts @@ -174,7 +174,7 @@ export const useShikiji = (): Promise => { let mermaidInitialized = false -export const runMermaid = (querySelector: string) => { +export const runMermaid = (querySelector: string): Promise => { if (!mermaidInitialized) { mermaidInitialized = true mermaid.initialize({ @@ -184,7 +184,7 @@ export const runMermaid = (querySelector: string) => { }) } - mermaid.run({ + return mermaid.run({ querySelector }) } diff --git a/src/hooks/useMarkdownPostRender.hook.ts b/src/hooks/useMarkdownPostRender.hook.ts index 9e1a486..63bd111 100644 --- a/src/hooks/useMarkdownPostRender.hook.ts +++ b/src/hooks/useMarkdownPostRender.hook.ts @@ -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[] = [] + 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 } ) diff --git a/src/styles/app.css b/src/styles/app.css index 3a22a92..1a2d38a 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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; diff --git a/src/utils/svgDownload.ts b/src/utils/svgDownload.ts new file mode 100644 index 0000000..fe12f34 --- /dev/null +++ b/src/utils/svgDownload.ts @@ -0,0 +1,146 @@ +const ATTACHED_FLAG = "svgDownloadAttached" +const SVG_NS = "http://www.w3.org/2000/svg" + +const TABLER_DOWNLOAD_ICON = `` + +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 = `\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 => { + 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((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((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}${label}` + 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("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) + }) +}