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}