feat: contextual zoom

This commit is contained in:
2026-03-11 00:30:10 +01:00
parent 7c1039f4c6
commit 2873f13875
2 changed files with 116 additions and 1 deletions

View File

@@ -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

View File

@@ -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<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.
*/
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
if (showCompact) {
const p = props as NodeProps
return (
<CompactNodeView
id={p.id}
type={p.type}
data={p.data}
selected={p.selected}
width={p.width}
height={p.height}
/>
)
}
return <Inner {...props} />
}
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`
return ContextualZoomNode as React.ComponentType<P>
}