feat(frontend): add zoom and download to Mermaid graphs (#9859)

* feat(frontend): add zoom and download to Mermaid graphs

Mermaid diagrams in the AI chat could only be viewed inline with
horizontal scroll. Add a download-as-SVG button and an expand button
that opens a fullscreen modal with pan/drag and zoom (mouse wheel plus
in/out/reset controls), reusing the existing `panzoom` dependency.

Fixes WIN-2117

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): guard panzoom action against close-before-import race

Address review nit: if the modal closes before the dynamic
import('panzoom') resolves, destroy() ran while instance was still
undefined (disposing nothing) and the late .then() built a leaked
panzoom on a detached node. A disposed flag makes the cleanup airtight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-01 14:05:30 +02:00
committed by GitHub
parent 383c70523b
commit 289017bcb2
@@ -2,8 +2,10 @@
import { randomUUID } from '$lib/utils/uuid'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import { copyToClipboard } from '$lib/utils'
import { ClipboardCopy } from 'lucide-svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { copyToClipboard, download } from '$lib/utils'
import { ClipboardCopy, Download, Maximize2, Minus, Plus, RotateCcw } from 'lucide-svelte'
import type { PanZoom } from 'panzoom'
let { code }: { code: string } = $props()
@@ -18,6 +20,8 @@
// overwrite the result of a newer one (out-of-order async on rapid code/theme changes).
let renderSeq = 0
let expanded = $state(false)
async function render(source: string, dark: boolean) {
const seq = ++renderSeq
if (!source?.trim()) {
@@ -52,26 +56,143 @@
// Only show the diagram while it corresponds to the current source.
let showSvg = $derived(svg !== undefined && renderedCode === code)
function downloadSvg() {
if (svg) {
download('mermaid.svg', svg, 'image/svg+xml')
}
}
// Pan/zoom is only wired up inside the fullscreen modal so the inline preview
// stays a plain, non-interactive thumbnail.
let panzoomInstance = $state<PanZoom | undefined>(undefined)
let panzoomNode: HTMLElement | undefined = undefined
function panzoomAction(node: HTMLElement) {
let instance: PanZoom | undefined
// Guard the async import against a close-before-resolve: without it,
// destroy() (instance still undefined) disposes nothing and the late
// .then() would build a leaked panzoom on an already-detached node.
let disposed = false
panzoomNode = node
import('panzoom').then(({ default: panzoom }) => {
if (disposed) return
instance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
maxZoom: 8,
minZoom: 0.2,
zoomDoubleClickSpeed: 1,
smoothScroll: false
})
panzoomInstance = instance
})
return {
destroy() {
disposed = true
instance?.dispose()
panzoomInstance = undefined
panzoomNode = undefined
}
}
}
function zoomBy(ratio: number) {
if (!panzoomInstance || !panzoomNode) return
// smoothZoom anchors on client coordinates — zoom around the viewport center.
const rect = panzoomNode.getBoundingClientRect()
panzoomInstance.smoothZoom(rect.left + rect.width / 2, rect.top + rect.height / 2, ratio)
}
function resetZoom() {
if (!panzoomInstance) return
panzoomInstance.moveTo(0, 0)
panzoomInstance.zoomAbs(0, 0, 1)
}
</script>
{#if showSvg}
<div class="relative">
<Button
wrapperClasses="absolute top-2 right-2 z-20"
onclick={() => copyToClipboard(code)}
color="light"
size="xs2"
startIcon={{
icon: ClipboardCopy
}}
iconOnly
title="Copy to clipboard"
/>
<div class="absolute top-2 right-2 z-20 flex flex-row gap-1">
<Button
onclick={() => copyToClipboard(code)}
color="light"
size="xs2"
startIcon={{ icon: ClipboardCopy }}
iconOnly
title="Copy diagram source"
/>
<Button
onclick={downloadSvg}
color="light"
size="xs2"
startIcon={{ icon: Download }}
iconOnly
title="Download as SVG"
/>
<Button
onclick={() => (expanded = true)}
color="light"
size="xs2"
startIcon={{ icon: Maximize2 }}
iconOnly
title="Expand and zoom"
/>
</div>
<div class="p-2 flex justify-center overflow-x-auto">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
</div>
</div>
<Modal bind:open={expanded} title="Diagram" kind="X" class="sm:max-w-none w-[92vw]">
{#snippet settings()}
<div class="flex flex-row gap-1 mr-8">
<Button
onclick={() => zoomBy(1 / 1.3)}
color="light"
size="xs2"
startIcon={{ icon: Minus }}
iconOnly
title="Zoom out"
/>
<Button
onclick={() => zoomBy(1.3)}
color="light"
size="xs2"
startIcon={{ icon: Plus }}
iconOnly
title="Zoom in"
/>
<Button
onclick={resetZoom}
color="light"
size="xs2"
startIcon={{ icon: RotateCcw }}
iconOnly
title="Reset zoom"
/>
<Button
onclick={downloadSvg}
color="light"
size="xs2"
startIcon={{ icon: Download }}
iconOnly
title="Download as SVG"
/>
</div>
{/snippet}
<div
class="relative w-full h-[78vh] overflow-hidden rounded border cursor-grab bg-surface-secondary"
>
{#if expanded}
<div use:panzoomAction class="w-full h-full flex items-center justify-center">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
</div>
{/if}
</div>
</Modal>
{:else}
<!-- Fallback while loading or when rendering fails: show the raw source -->
<pre class="overflow-auto max-h-screen text-xs p-2">{code}</pre>