feat(desktop): zoomable primitive — open full, pan/zoom, copy

Add a content-agnostic Zoomable primitive (useZoomPan hook + overlay viewer):
click to open full-screen, wheel-zoom toward the cursor, drag to pan, toolbar
zoom/reset, and an optional copy action. Wire Mermaid diagrams into it with
copy-as-PNG; reusable for other inline content later.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 03:29:25 -05:00
parent e36d9862ec
commit da0ed979fa
5 changed files with 353 additions and 6 deletions

View file

@ -3,6 +3,8 @@
import mermaid from 'mermaid'
import { useEffect, useState } from 'react'
import { Zoomable } from '@/components/ui/zoomable'
import { copySvgAsPng } from '@/lib/svg-image'
import { cn } from '@/lib/utils'
import type { RichFenceProps } from './types'
@ -88,10 +90,24 @@ export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
return <SourcePreview code={code} muted />
}
// Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The
// overlay keeps the diagram's natural width (capped to the viewport) so it
// renders before any zoom; the inline version stays capped at 33dvh.
return (
<div
className="overflow-auto p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
<Zoomable
label="Open diagram"
onCopy={() => copySvgAsPng(svg)}
overlay={
<div
className="[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80vh] [&_svg]:max-w-[85vw]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
}
>
<div
className="overflow-hidden p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</Zoomable>
)
}

View file

@ -0,0 +1,97 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
useCallback,
useRef,
useState
} from 'react'
interface Transform {
scale: number
x: number
y: number
}
const MIN_SCALE = 0.25
const MAX_SCALE = 8
const WHEEL_STEP = 1.1
const BUTTON_STEP = 1.25
const clamp = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
/**
* Headless pan/zoom transform. Wheel zooms toward the cursor, drag pans, buttons
* zoom toward centre. Returns the transform style plus the surface handlers, so
* any content (SVG, image, canvas) can be made pan/zoomable.
*/
export function useZoomPan() {
const [transform, setTransform] = useState<Transform>({ scale: 1, x: 0, y: 0 })
const drag = useRef<{ x: number; y: number } | null>(null)
const [panning, setPanning] = useState(false)
// Zoom toward (cx, cy), measured from the surface centre, keeping that point fixed.
const zoomAt = useCallback((factor: number, cx = 0, cy = 0) => {
setTransform(prev => {
const scale = clamp(prev.scale * factor)
const k = scale / prev.scale
return { scale, x: cx - k * (cx - prev.x), y: cy - k * (cy - prev.y) }
})
}, [])
const onWheel = useCallback(
(event: ReactWheelEvent) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const cx = event.clientX - rect.left - rect.width / 2
const cy = event.clientY - rect.top - rect.height / 2
zoomAt(event.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP, cx, cy)
},
[zoomAt]
)
const onPointerDown = useCallback((event: ReactPointerEvent) => {
event.currentTarget.setPointerCapture(event.pointerId)
setTransform(prev => {
drag.current = { x: event.clientX - prev.x, y: event.clientY - prev.y }
return prev
})
setPanning(true)
}, [])
const onPointerMove = useCallback((event: ReactPointerEvent) => {
if (!drag.current) {
return
}
const start = drag.current
setTransform(prev => ({ ...prev, x: event.clientX - start.x, y: event.clientY - start.y }))
}, [])
const endPan = useCallback(() => {
drag.current = null
setPanning(false)
}, [])
const reset = useCallback(() => setTransform({ scale: 1, x: 0, y: 0 }), [])
const zoomIn = useCallback(() => zoomAt(BUTTON_STEP), [zoomAt])
const zoomOut = useCallback(() => zoomAt(1 / BUTTON_STEP), [zoomAt])
const style: CSSProperties = {
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`
}
return {
panning,
reset,
scale: transform.scale,
stageProps: { onPointerDown, onPointerLeave: endPan, onPointerMove, onPointerUp: endPan, onWheel },
style,
zoomIn,
zoomOut
}
}

View file

@ -0,0 +1,172 @@
'use client'
import { type ReactNode, useEffect, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useZoomPan } from './use-zoom-pan'
interface ZoomableProps {
/** Inline content; also the default full-view content. */
children: ReactNode
/** Full-view content, if it should differ from the inline version. */
overlay?: ReactNode
/** Copy/export action shown in the viewer toolbar. */
onCopy?: () => Promise<void> | void
/** Accessible label for the expand affordance. */
label?: string
className?: string
}
/**
* Generic click-to-expand viewer: renders inline content with a hover "expand"
* affordance, then opens a full overlay where the content can be panned/zoomed
* (see useZoomPan) and optionally copied. Content-agnostic wrap a diagram,
* image, or any node.
*/
export function Zoomable({ children, overlay, onCopy, label = 'Open full view', className }: ZoomableProps) {
const [open, setOpen] = useState(false)
return (
<>
<div className={cn('group/zoomable relative', className)}>
{/* The whole content is the trigger — click anywhere to open, like an image. */}
<button
className="block w-full cursor-zoom-in text-left"
onClick={() => setOpen(true)}
title={label}
type="button"
>
{children}
</button>
<span
aria-hidden
className="pointer-events-none absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity group-hover/zoomable:opacity-100"
>
<Maximize className="size-4" />
</span>
</div>
{open && (
<ZoomPanViewer onCopy={onCopy} onOpenChange={setOpen} open={open}>
{overlay ?? children}
</ZoomPanViewer>
)}
</>
)
}
function ZoomPanViewer({
children,
onCopy,
onOpenChange,
open
}: {
children: ReactNode
onCopy?: () => Promise<void> | void
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan()
useEffect(() => {
if (open) {
reset()
}
}, [open, reset])
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[85vh] w-[90vw] max-w-[90vw] flex-col gap-0 overflow-hidden p-0"
showCloseButton={false}
>
<div
className={cn(
'relative flex-1 touch-none select-none overflow-hidden',
panning ? 'cursor-grabbing' : 'cursor-grab'
)}
{...stageProps}
>
<div className="absolute inset-0 grid place-items-center">
<div className="origin-center" style={style}>
{children}
</div>
</div>
</div>
<Toolbar onClose={() => onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} />
</DialogContent>
</Dialog>
)
}
function Toolbar({
onClose,
onCopy,
reset,
zoomIn,
zoomOut
}: {
onClose: () => void
onCopy?: () => Promise<void> | void
reset: () => void
zoomIn: () => void
zoomOut: () => void
}) {
const [copied, setCopied] = useState(false)
const copy = async () => {
if (!onCopy) {
return
}
await onCopy()
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
}
return (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border/70 bg-background/85 p-1 shadow-sm backdrop-blur">
<ToolbarButton label="Zoom out" onClick={zoomOut}>
<ZoomOut className="size-4" />
</ToolbarButton>
<ToolbarButton label="Reset" onClick={reset}>
<RefreshCw className="size-4" />
</ToolbarButton>
<ToolbarButton label="Zoom in" onClick={zoomIn}>
<ZoomIn className="size-4" />
</ToolbarButton>
{onCopy && (
<>
<Divider />
<ToolbarButton label={copied ? 'Copied' : 'Copy'} onClick={() => void copy()}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</ToolbarButton>
</>
)}
<Divider />
<ToolbarButton label="Close" onClick={onClose}>
<X className="size-4" />
</ToolbarButton>
</div>
)
}
function Divider() {
return <span className="mx-0.5 h-5 w-px bg-border" />
}
function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
)
}

View file

@ -56,6 +56,7 @@ import {
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
IconMaximize as Maximize,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@ -104,7 +105,9 @@ import {
IconX as X,
IconX as XIcon,
IconBolt as Zap,
IconBoltFilled as ZapFilled
IconBoltFilled as ZapFilled,
IconZoomIn as ZoomIn,
IconZoomOut as ZoomOut
} from '@tabler/icons-react'
export {
@ -165,6 +168,7 @@ export {
Lock,
LogIn,
Mail,
Maximize,
MessageCircle,
MessageSquareText,
Mic,
@ -213,7 +217,9 @@ export {
X,
XIcon,
Zap,
ZapFilled
ZapFilled,
ZoomIn,
ZoomOut
}
export type { Icon as IconComponent } from '@tabler/icons-react'

View file

@ -0,0 +1,56 @@
// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained
// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying
// the SVG markup as text where image clipboard writes aren't permitted.
function svgSize(svg: string): { height: number; width: number } {
const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
const width = parseFloat(el.getAttribute('width') || '')
const height = parseFloat(el.getAttribute('height') || '')
if (width && height) {
return { height, width }
}
const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number)
return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 }
}
export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
const { height, width } = svgSize(svg)
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width * scale))
canvas.height = Math.max(1, Math.round(height * scale))
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('no 2d context'))
return
}
ctx.scale(scale, scale)
ctx.drawImage(image, 0, 0, width, height)
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png')
}
image.onerror = () => reject(new Error('svg load failed'))
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
})
}
export async function copySvgAsPng(svg: string): Promise<void> {
try {
const blob = await svgToPngBlob(svg)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
} catch {
await navigator.clipboard.writeText(svg)
}
}