Files
zui/frontend/src/app/canvas/ContextualZoomNode.tsx
2026-03-12 11:21:10 +01:00

125 lines
4.1 KiB
TypeScript

/**
* Contextual zoom: when zoomed out past a threshold, nodes render as compact
* icon-only views using each type's menuIcon from the node registry.
* @see https://reactflow.dev/examples/interaction/contextual-zoom
*/
import React from 'react'
import { useViewport } from '@xyflow/react'
import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { cn } from '@/lib/utils'
const MIN_ZOOM = 0.1
const MAX_ZOOM = 2
/** When zoom <= this value, show compact icon view (bottom ~30% of zoom range). */
export const CONTEXTUAL_ZOOM_THRESHOLD = MIN_ZOOM + 0.1 * (MAX_ZOOM - MIN_ZOOM)
/** Handle id per type for compact view (input, output). */
const COMPACT_HANDLES: Record<string, { input?: string; output?: string }> = {
config: { input: 'ain', output: 'out' },
render: { input: 'ain' },
variable: { output: 'out' },
function: { input: 'in', output: 'out' },
data: { output: 'out' },
}
type NodeProps = {
id: string
type?: string
data?: Record<string, unknown>
selected?: boolean
width?: number
height?: number
[key: string]: unknown
}
function CompactNodeView({ id, type, selected, width, height }: NodeProps) {
const descriptor = type ? getNodeType(type) : undefined
const icon = descriptor?.menuIcon ?? null
const handles = type ? COMPACT_HANDLES[type] ?? { output: 'out' } : {}
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = width ?? defaultStyle.width
const h = height ?? defaultStyle.height
return (
<div
className={cn(
'flex flex-col rounded-md border bg-card text-card-foreground transition-[border-color,box-shadow] duration-200',
'border-input hover:ring-1',
selected &&
'border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]'
)}
style={{
width: w,
height: h,
minWidth: w,
minHeight: h,
}}
data-selected={selected}
>
{handles.input && (
<InputHandle id={handles.input} nodeId={id} />
)}
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-3 pb-3 pt-2">
<span className="flex shrink-0 items-center justify-center [&>svg]:h-14 [&>svg]:w-14" aria-hidden>
{icon}
</span>
<span className="truncate text-center text-lg font-medium text-foreground" title={id}>
{id}
</span>
</div>
{handles.output && (
<OutputHandle id={handles.output} />
)}
</div>
)
}
/**
* Wraps a node component so that when the viewport zoom is at or below
* CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view.
* Inner is always mounted (hidden when compact) so switching zoom does not
* remount and re-trigger effects (e.g. RenderingNode fetch).
*/
export function createContextualNode<P extends NodeProps>(
Inner: React.ComponentType<P>
): React.ComponentType<P> {
function ContextualZoomNode(props: P) {
const { zoom } = useViewport()
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
const p = props as NodeProps
const type = p.type
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = p.width ?? defaultStyle.width
const h = p.height ?? defaultStyle.height
return (
<div
style={
showCompact
? { width: w, height: h, minWidth: w, minHeight: h, position: 'relative' }
: { display: 'contents' }
}
>
{showCompact && (
<CompactNodeView
id={p.id}
type={type}
data={p.data}
selected={p.selected}
width={p.width}
height={p.height}
/>
)}
<div style={{ display: showCompact ? 'none' : undefined }} aria-hidden={showCompact}>
<Inner {...props} />
</div>
</div>
)
}
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`
return ContextualZoomNode as React.ComponentType<P>
}