125 lines
4.1 KiB
TypeScript
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>
|
|
}
|