diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 378dbda..7af504a 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -39,6 +39,7 @@ import { ContextMenuGroup, } from '@/components/ui/context-menu' import { CanvasMenubar } from '@/app/canvas/CanvasMenubar' +import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { FlowKeyboardShortcuts } from '@/components/base/FlowKeyboardShortcuts' import { Empty, @@ -227,7 +228,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) { ) const nodeTypes = useMemo( - () => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])), + () => + Object.fromEntries( + getRegisteredNodeTypes().map((r) => [r.id, createContextualNode(r.component)]) + ), [] ) const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), []) @@ -625,6 +629,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) { edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} colorMode={theme as ColorMode} + minZoom={0.1} + maxZoom={2} snapToGrid snapGrid={SNAP_GRID} fitView diff --git a/frontend/src/app/canvas/ContextualZoomNode.tsx b/frontend/src/app/canvas/ContextualZoomNode.tsx new file mode 100644 index 0000000..b276fa1 --- /dev/null +++ b/frontend/src/app/canvas/ContextualZoomNode.tsx @@ -0,0 +1,109 @@ +/** + * 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/nodeRegistry' +import { InputHandle, OutputHandle } from '@/components/base/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.3 * (MAX_ZOOM - MIN_ZOOM) + +/** Handle id per type for compact view (input, output). */ +const COMPACT_HANDLES: Record = { + 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 + 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 ( +
+ {handles.input && ( + + )} +
+ + {icon} + + + {id} + +
+ {handles.output && ( + + )} +
+ ) +} + +/** + * 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. + */ +export function createContextualNode

( + Inner: React.ComponentType

+): React.ComponentType

{ + function ContextualZoomNode(props: P) { + const { zoom } = useViewport() + const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD + + if (showCompact) { + const p = props as NodeProps + return ( + + ) + } + + return + } + + ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})` + return ContextualZoomNode as React.ComponentType

+}