mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user