From 289017bcb28c049c8258b2ffd7da0ec3e6ef120b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 14:05:30 +0200 Subject: [PATCH] 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/script/MermaidDisplay.svelte | 147 ++++++++++++++++-- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/script/MermaidDisplay.svelte b/frontend/src/lib/components/copilot/chat/script/MermaidDisplay.svelte index 4adf86e76d..dda1892baa 100644 --- a/frontend/src/lib/components/copilot/chat/script/MermaidDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/script/MermaidDisplay.svelte @@ -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(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) + } {#if showSvg}
-
{@html svg}
+ + + {#snippet settings()} +
+
+ {/snippet} +
+ {#if expanded} +
+ + {@html svg} +
+ {/if} +
+
{:else}
{code}