Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions src/features/markdown/render/diagram/Diagram.tsx
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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 (
<figure className="diagram" data-line={line}>
<figcaption className="code-block-header">
Expand All @@ -67,16 +84,33 @@ export function Diagram({ code, line }: { code: string; line?: number }) {
>
<Maximize2 className="size-3.5" />
</button>
<button
type="button"
className={actionClass}
disabled={!svg}
onClick={() => svg && downloadFile('diagram.svg', svg, 'image/svg+xml')}
aria-label="Download SVG"
title="Download SVG"
>
<Download className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className={actionClass}
disabled={!svg}
aria-label="Download diagram"
title="Download"
/>
}
>
<Download className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => void downloadPng()}>
<FileImage />
Image (.png)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => svg && downloadFile('diagram.svg', svg, 'image/svg+xml')}
>
<FileCode2 />
Vector (.svg)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CopyButton value={code} label="Copy source" />
</span>
</figcaption>
Expand Down
11 changes: 8 additions & 3 deletions src/features/markdown/render/diagram/PanZoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -48,7 +51,7 @@ export function PanZoom({ svg }: { svg: string }) {
<div className="relative h-full overflow-hidden">
<div
ref={surface}
className="grid h-full cursor-grab touch-none place-items-center desk-grid active:cursor-grabbing"
className="relative h-full cursor-grab touch-none desk-grid active:cursor-grabbing"
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
Expand All @@ -57,7 +60,9 @@ export function PanZoom({ svg }: { svg: string }) {
onDoubleClick={() => setTransform(IDENTITY)}
>
<div
className="[&_svg]:h-auto [&_svg]:max-h-[78vh] [&_svg]:max-w-[86vw]"
// Mermaid sets an inline `max-width` at the diagram's natural size; lift it so the
// SVG fills the box and its viewBox (preserveAspectRatio: meet) does the fitting.
className="absolute inset-x-6 inset-y-20 md:inset-x-16 [&_svg]:size-full [&_svg]:max-w-none!"
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: 'center',
Expand Down
17 changes: 15 additions & 2 deletions src/features/markdown/render/diagram/mermaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,20 @@ function enqueue<T>(task: () => Promise<T>) {
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

Expand All @@ -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,
Comment thread
AdiRishi marked this conversation as resolved.
flowchart: { curve: 'basis', padding: 14 },
suppressErrorRendering: true,
})
Expand Down
68 changes: 68 additions & 0 deletions src/features/markdown/render/diagram/png.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string>((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<Blob>((resolve, reject) =>
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Could not create an image.'))),
'image/png',
),
)
}
Loading