mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
e36d9862ec
commit
da0ed979fa
5 changed files with 353 additions and 6 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
97
apps/desktop/src/components/ui/use-zoom-pan.ts
Normal file
97
apps/desktop/src/components/ui/use-zoom-pan.ts
Normal 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
|
||||
}
|
||||
}
|
||||
172
apps/desktop/src/components/ui/zoomable.tsx
Normal file
172
apps/desktop/src/components/ui/zoomable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
56
apps/desktop/src/lib/svg-image.ts
Normal file
56
apps/desktop/src/lib/svg-image.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue