From da0ed979facd5a87b9912edb3c45412a71a242f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 03:29:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20zoomable=20primitive=20?= =?UTF-8?q?=E2=80=94=20open=20full,=20pan/zoom,=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/embeds/mermaid-embed.tsx | 24 ++- .../desktop/src/components/ui/use-zoom-pan.ts | 97 ++++++++++ apps/desktop/src/components/ui/zoomable.tsx | 172 ++++++++++++++++++ apps/desktop/src/lib/icons.ts | 10 +- apps/desktop/src/lib/svg-image.ts | 56 ++++++ 5 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/ui/use-zoom-pan.ts create mode 100644 apps/desktop/src/components/ui/zoomable.tsx create mode 100644 apps/desktop/src/lib/svg-image.ts diff --git a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx index eb77d0beb5e..88684727056 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx +++ b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx @@ -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 } + // 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 ( -
+ copySvgAsPng(svg)} + overlay={ +
+ } + > +
+ ) } diff --git a/apps/desktop/src/components/ui/use-zoom-pan.ts b/apps/desktop/src/components/ui/use-zoom-pan.ts new file mode 100644 index 00000000000..86759297d64 --- /dev/null +++ b/apps/desktop/src/components/ui/use-zoom-pan.ts @@ -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({ 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 + } +} diff --git a/apps/desktop/src/components/ui/zoomable.tsx b/apps/desktop/src/components/ui/zoomable.tsx new file mode 100644 index 00000000000..0c00c0217bf --- /dev/null +++ b/apps/desktop/src/components/ui/zoomable.tsx @@ -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 + /** 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 ( + <> +
+ {/* The whole content is the trigger — click anywhere to open, like an image. */} + + + + +
+ {open && ( + + {overlay ?? children} + + )} + + ) +} + +function ZoomPanViewer({ + children, + onCopy, + onOpenChange, + open +}: { + children: ReactNode + onCopy?: () => Promise | void + onOpenChange: (open: boolean) => void + open: boolean +}) { + const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan() + + useEffect(() => { + if (open) { + reset() + } + }, [open, reset]) + + return ( + + +
+
+
+ {children} +
+
+
+ onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} /> +
+
+ ) +} + +function Toolbar({ + onClose, + onCopy, + reset, + zoomIn, + zoomOut +}: { + onClose: () => void + onCopy?: () => Promise | 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 ( +
+ + + + + + + + + + {onCopy && ( + <> + + void copy()}> + {copied ? : } + + + )} + + + + +
+ ) +} + +function Divider() { + return +} + +function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) { + return ( + + ) +} diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index a1d4df8014f..ec30bc79c6f 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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' diff --git a/apps/desktop/src/lib/svg-image.ts b/apps/desktop/src/lib/svg-image.ts new file mode 100644 index 00000000000..77c2b0756e4 --- /dev/null +++ b/apps/desktop/src/lib/svg-image.ts @@ -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 { + 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 { + try { + const blob = await svgToPngBlob(svg) + + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + } catch { + await navigator.clipboard.writeText(svg) + } +}