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:
@@ -174,7 +174,7 @@ export const useShikiji = (): Promise<void> => {
|
|||||||
|
|
||||||
let mermaidInitialized = false
|
let mermaidInitialized = false
|
||||||
|
|
||||||
export const runMermaid = (querySelector: string) => {
|
export const runMermaid = (querySelector: string): Promise<void> => {
|
||||||
if (!mermaidInitialized) {
|
if (!mermaidInitialized) {
|
||||||
mermaidInitialized = true
|
mermaidInitialized = true
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
@@ -184,7 +184,7 @@ export const runMermaid = (querySelector: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mermaid.run({
|
return mermaid.run({
|
||||||
querySelector
|
querySelector
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
runTikz,
|
runTikz,
|
||||||
useShikiji
|
useShikiji
|
||||||
} from "@/hooks/useMarkdown.hook"
|
} from "@/hooks/useMarkdown.hook"
|
||||||
|
import { attachSvgDownloads } from "@/utils/svgDownload"
|
||||||
|
|
||||||
interface MarkdownPostRenderOptions {
|
interface MarkdownPostRenderOptions {
|
||||||
onReady?: () => void
|
onReady?: () => void
|
||||||
@@ -31,13 +32,16 @@ export const useMarkdownPostRender = (
|
|||||||
options.onReady?.()
|
options.onReady?.()
|
||||||
|
|
||||||
const scope = scopeSelector()
|
const scope = scopeSelector()
|
||||||
|
const wantsTikz = !!options.tikz
|
||||||
|
const wantsMermaid = !!options.mermaid?.()
|
||||||
|
|
||||||
if (options.tikz) {
|
const renderJobs: Promise<unknown>[] = []
|
||||||
void runTikz(`${scope} .tikz`)
|
if (wantsTikz) {
|
||||||
|
renderJobs.push(runTikz(`${scope} .tikz`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.mermaid?.()) {
|
if (wantsMermaid) {
|
||||||
runMermaid(`${scope} .mermaid`)
|
renderJobs.push(runMermaid(`${scope} .mermaid`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.shikiji?.()) {
|
if (options.shikiji?.()) {
|
||||||
@@ -48,6 +52,12 @@ export const useMarkdownPostRender = (
|
|||||||
if (imagesSha) {
|
if (imagesSha) {
|
||||||
useImages(imagesSha)
|
useImages(imagesSha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wantsTikz || wantsMermaid) {
|
||||||
|
await Promise.allSettled(renderJobs)
|
||||||
|
await nextTick()
|
||||||
|
attachSvgDownloads(document.querySelector(scope))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,6 +219,47 @@ pre.tikz svg {
|
|||||||
font-style: italic;
|
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 {
|
.tikz-error {
|
||||||
border-left: 3px solid var(--color-error);
|
border-left: 3px solid var(--color-error);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|||||||
146
src/utils/svgDownload.ts
Normal file
146
src/utils/svgDownload.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user