feat: contextual zoom
This commit is contained in:
@@ -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
|
||||
|
||||
109
frontend/src/app/canvas/ContextualZoomNode.tsx
Normal file
109
frontend/src/app/canvas/ContextualZoomNode.tsx
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user