diff --git a/src/features/markdown/render/diagram/Diagram.tsx b/src/features/markdown/render/diagram/Diagram.tsx index a0a2b45..4371dd2 100644 --- a/src/features/markdown/render/diagram/Diagram.tsx +++ b/src/features/markdown/render/diagram/Diagram.tsx @@ -1,14 +1,22 @@ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog' -import { Download, Maximize2, TriangleAlert, X } from 'lucide-react' +import { Download, FileCode2, FileImage, Maximize2, TriangleAlert, X } from 'lucide-react' import { use, useEffect, useState } from 'react' +import { toast } from 'sonner' import { useTheme } from '@/features/theme/ThemeProvider' import { downloadFile } from '@/lib/download' import { CopyButton } from '@/ui/copy-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/ui/dropdown-menu' import { DocumentContext } from '../context' -import { renderMermaid } from './mermaid' +import { diagramBackground, renderMermaid } from './mermaid' import { PanZoom } from './PanZoom' +import { svgToPng } from './png' type DiagramState = | { status: 'loading'; svg: string | null } @@ -47,6 +55,15 @@ export function Diagram({ code, line }: { code: string; line?: number }) { const svg = state.svg + async function downloadPng() { + try { + const flat = await renderMermaid(code, resolved, look, { htmlLabels: false }) + downloadFile('diagram.png', await svgToPng(flat, diagramBackground(resolved))) + } catch { + toast.error('This diagram could not be exported as PNG.') + } + } + return (
@@ -67,16 +84,33 @@ export function Diagram({ code, line }: { code: string; line?: number }) { > - + + + } + > + + + + void downloadPng()}> + + Image (.png) + + svg && downloadFile('diagram.svg', svg, 'image/svg+xml')} + > + + Vector (.svg) + + +
diff --git a/src/features/markdown/render/diagram/PanZoom.tsx b/src/features/markdown/render/diagram/PanZoom.tsx index 1de79bf..a9d7fe0 100644 --- a/src/features/markdown/render/diagram/PanZoom.tsx +++ b/src/features/markdown/render/diagram/PanZoom.tsx @@ -5,7 +5,10 @@ type Transform = { x: number; y: number; scale: number } const IDENTITY: Transform = { x: 0, y: 0, scale: 1 } const clamp = (value: number) => Math.min(8, Math.max(0.2, value)) -/** Drag to pan, wheel/pinch to zoom around the pointer. */ +/** + * Drag to pan, wheel/pinch to zoom around the pointer. 100% is "fit": the SVG is + * stretched to the viewer and its viewBox scales the drawing to fill it. + */ export function PanZoom({ svg }: { svg: string }) { const [transform, setTransform] = useState(IDENTITY) const drag = useRef<{ x: number; y: number; origin: Transform } | null>(null) @@ -48,7 +51,7 @@ export function PanZoom({ svg }: { svg: string }) {
setTransform(IDENTITY)} >
(task: () => Promise) { return run } -export function renderMermaid(code: string, theme: ResolvedTheme, look: DiagramLook) { - const key = `${theme}|${look}|${code}` +/** The page color behind a diagram, for exports that need an opaque background. */ +export const diagramBackground = (theme: ResolvedTheme) => PALETTES[theme].background + +/** + * Renders a diagram to an SVG string. `htmlLabels: false` draws labels as SVG text + * instead of `foreignObject` HTML, which images and canvases can rasterize safely. + */ +export function renderMermaid( + code: string, + theme: ResolvedTheme, + look: DiagramLook, + { htmlLabels = true } = {}, +) { + const key = `${theme}|${look}|${htmlLabels}|${code}` const cached = cache.get(key) if (cached) return cached @@ -84,6 +96,7 @@ export function renderMermaid(code: string, theme: ResolvedTheme, look: DiagramL handDrawnSeed: 7, fontFamily: 'Hanken Grotesk Variable, ui-sans-serif, sans-serif', themeVariables: { ...PALETTES[theme], fontSize: '15px' }, + htmlLabels, flowchart: { curve: 'basis', padding: 14 }, suppressErrorRendering: true, }) diff --git a/src/features/markdown/render/diagram/png.ts b/src/features/markdown/render/diagram/png.ts new file mode 100644 index 0000000..51cf309 --- /dev/null +++ b/src/features/markdown/render/diagram/png.ts @@ -0,0 +1,68 @@ +/** + * Rasterizes a Mermaid SVG to PNG. The SVG is drawn into a canvas as an image, so + * it can't reach the page's fonts: Hanken Grotesk is embedded as a data URL, and + * the diagram should be rendered without HTML labels (`foreignObject` taints the + * canvas in Safari). + */ +import hankenGroteskWoff2 from '@fontsource-variable/hanken-grotesk/files/hanken-grotesk-latin-wght-normal.woff2?url' + +let fontCss: Promise | null = null + +function embeddedFontCss() { + fontCss ??= fetch(hankenGroteskWoff2) + .then((response) => response.blob()) + .then((blob) => blobToDataUrl(blob)) + .then( + (url) => + `@font-face{font-family:'Hanken Grotesk Variable';font-weight:100 900;src:url(${url}) format('woff2')}`, + ) + .catch(() => '') + return fontCss +} + +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener('load', () => resolve(reader.result as string)) + reader.addEventListener('error', () => reject(reader.error)) + reader.readAsDataURL(blob) + }) +} + +export async function svgToPng(svg: string, background: string, scale = 2) { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml') + const root = doc.documentElement + const [, , width = 0, height = 0] = (root.getAttribute('viewBox') ?? '') + .split(/[\s,]+/) + .map(Number) + if (!width || !height) throw new Error('This diagram has no size to export.') + + // Mermaid sizes the SVG with `width="100%"` and an inline max-width; give it real pixels. + root.setAttribute('width', String(width)) + root.setAttribute('height', String(height)) + root.style.removeProperty('max-width') + const style = doc.createElementNS('http://www.w3.org/2000/svg', 'style') + style.textContent = await embeddedFontCss() + root.prepend(style) + + const source = new XMLSerializer().serializeToString(root) + const image = new Image() + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}` + await image.decode() + + const canvas = document.createElement('canvas') + canvas.width = Math.ceil(width * scale) + canvas.height = Math.ceil(height * scale) + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not create an image.') + context.fillStyle = background + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(image, 0, 0, canvas.width, canvas.height) + + return new Promise((resolve, reject) => + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('Could not create an image.'))), + 'image/png', + ), + ) +}