hermes-agent/apps/desktop/src/components/ui/zoomable.tsx
Brooklyn Nicholson da0ed979fa 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.
2026-06-26 03:40:49 -05:00

172 lines
4.9 KiB
TypeScript

'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>
)
}