diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 624bb4d..ba4b1e1 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -37,6 +37,7 @@ import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent' import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath' import { CanvasMenubar } from '@/app/canvas/CanvasMenubar' import { createContextualNode } from '@/app/canvas/ContextualZoomNode' +import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext' import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts' import { Empty, @@ -247,24 +248,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) { [setEdges] ) - const isValidConnection = useCallback( - (connection: Connection | AppEdge) => { - const src = 'source' in connection ? connection.source : undefined - const tgt = 'target' in connection ? connection.target : undefined - if (typeof src !== 'string' || typeof tgt !== 'string') return false - const sourceNode = nodes.find((n) => n.id === src) - const targetNode = nodes.find((n) => n.id === tgt) - const sourceType = sourceNode?.type - const targetType = targetNode?.type - if (!sourceType || !targetType) return false - if (sourceType === 'render' && targetType === 'config') { - const targetData = targetNode?.data as { configType?: string } | undefined - if (targetData?.configType !== 'markdown') return false - } - return isConnectionAllowed(sourceType, targetType, src, tgt) - }, - [nodes] - ) + const isValidConnection = useCallback((connection: Connection | AppEdge) => { + const src = 'source' in connection ? connection.source : undefined + const tgt = 'target' in connection ? connection.target : undefined + if (typeof src !== 'string' || typeof tgt !== 'string') return false + const currentNodes = nodesRef.current + const sourceNode = currentNodes.find((n) => n.id === src) + const targetNode = currentNodes.find((n) => n.id === tgt) + const sourceType = sourceNode?.type + const targetType = targetNode?.type + if (!sourceType || !targetType) return false + if (sourceType === 'render' && targetType === 'config') { + const targetData = targetNode?.data as { configType?: string } | undefined + if (targetData?.configType !== 'markdown') return false + } + return isConnectionAllowed(sourceType, targetType, src, tgt) + }, []) const onConnectStart = useCallback( ( @@ -655,9 +654,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) { )} - - - + + + - {showMinimap && ( + {showMinimap && nodes.length > 5 && (
)}
+
diff --git a/frontend/src/app/canvas/ContextualZoomNode.tsx b/frontend/src/app/canvas/ContextualZoomNode.tsx index 3b1822e..426aac2 100644 --- a/frontend/src/app/canvas/ContextualZoomNode.tsx +++ b/frontend/src/app/canvas/ContextualZoomNode.tsx @@ -4,9 +4,9 @@ * @see https://reactflow.dev/examples/interaction/contextual-zoom */ -import React from 'react' -import { useViewport } from '@xyflow/react' +import React, { useContext } from 'react' import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry' +import { ViewportDisplayContext } from '@/app/canvas/ViewportDisplayContext' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import { cn } from '@/lib/utils' @@ -86,8 +86,8 @@ export function createContextualNode

( Inner: React.ComponentType

): React.ComponentType

{ function ContextualZoomNode(props: P) { - const { zoom } = useViewport() - const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD + const displayMode = useContext(ViewportDisplayContext) + const showCompact = displayMode === 'compact' const p = props as NodeProps const type = p.type const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 } diff --git a/frontend/src/app/canvas/ViewportDisplayContext.tsx b/frontend/src/app/canvas/ViewportDisplayContext.tsx new file mode 100644 index 0000000..ce98809 --- /dev/null +++ b/frontend/src/app/canvas/ViewportDisplayContext.tsx @@ -0,0 +1,46 @@ +/** + * Single subscriber for viewport zoom: one component (ViewportDisplayProvider) + * subscribes to useViewport() and provides displayMode so contextual nodes + * don't each subscribe to viewport and re-render on every pan/zoom. + */ + +import React, { useLayoutEffect, useRef, useState } from 'react' +import { useViewport } from '@xyflow/react' +import { CONTEXTUAL_ZOOM_THRESHOLD } from '@/app/canvas/ContextualZoomNode' + +export type ViewportDisplayMode = 'compact' | 'full' + +const HYSTERESIS = 0.02 + +const ViewportDisplayContext = React.createContext('full') +export { ViewportDisplayContext } + +/** + * Must be rendered inside ReactFlowProvider. Subscribes to viewport once, + * maps zoom to displayMode with hysteresis, and provides it to descendants. + */ +export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) { + const { zoom } = useViewport() + const [displayMode, setDisplayMode] = useState(() => + zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full' + ) + const lastRef = useRef(displayMode) + + useLayoutEffect(() => { + const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS + const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS + let next: ViewportDisplayMode = lastRef.current + if (zoom <= low) next = 'compact' + else if (zoom >= high) next = 'full' + if (next !== lastRef.current) { + lastRef.current = next + setDisplayMode(next) + } + }, [zoom]) + + return ( + + {children} + + ) +} diff --git a/frontend/src/app/canvas/useCanvasConnectionPath.ts b/frontend/src/app/canvas/useCanvasConnectionPath.ts index 0748fce..6d0e4d5 100644 --- a/frontend/src/app/canvas/useCanvasConnectionPath.ts +++ b/frontend/src/app/canvas/useCanvasConnectionPath.ts @@ -7,6 +7,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' +/** Serialize set to a stable string for equality. */ +function setToStableKey(s: Set): string { + return Array.from(s).sort().join(',') +} + export type EdgeLike = { source: string; target: string } /** Minimum time (ms) the connection ant trail runs when a path update is in progress. */ @@ -111,7 +116,7 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP [] ) - const connectionPathNodeIds = useMemo( + const connectionPathNodeIdsRaw = useMemo( () => getPathNodeIds( edges, @@ -121,8 +126,18 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP ), [edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] ) + const connectionPathNodeIdsRef = useRef>(connectionPathNodeIdsRaw) + const connectionPathNodeIdsKeyRef = useRef('') + const connectionPathNodeIds = + setToStableKey(connectionPathNodeIdsRaw) === connectionPathNodeIdsKeyRef.current + ? connectionPathNodeIdsRef.current + : (() => { + connectionPathNodeIdsKeyRef.current = setToStableKey(connectionPathNodeIdsRaw) + connectionPathNodeIdsRef.current = connectionPathNodeIdsRaw + return connectionPathNodeIdsRaw + })() - const connectionPathPausedSegmentNodeIds = useMemo( + const connectionPathPausedSegmentNodeIdsRaw = useMemo( () => getPausedSegmentNodeIds( edges, @@ -132,12 +147,42 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP ), [edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] ) + const connectionPathPausedSegmentNodeIdsRef = useRef>( + connectionPathPausedSegmentNodeIdsRaw + ) + const connectionPathPausedSegmentNodeIdsKeyRef = useRef('') + const connectionPathPausedSegmentNodeIds = + setToStableKey(connectionPathPausedSegmentNodeIdsRaw) === + connectionPathPausedSegmentNodeIdsKeyRef.current + ? connectionPathPausedSegmentNodeIdsRef.current + : (() => { + connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey( + connectionPathPausedSegmentNodeIdsRaw + ) + connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw + return connectionPathPausedSegmentNodeIdsRaw + })() - const connectionPathActiveSegmentNodeIds = useMemo(() => { + const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => { const active = new Set(connectionPathNodeIds) connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) return active }, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds]) + const connectionPathActiveSegmentNodeIdsRef = useRef>( + connectionPathActiveSegmentNodeIdsRaw + ) + const connectionPathActiveSegmentNodeIdsKeyRef = useRef('') + const connectionPathActiveSegmentNodeIds = + setToStableKey(connectionPathActiveSegmentNodeIdsRaw) === + connectionPathActiveSegmentNodeIdsKeyRef.current + ? connectionPathActiveSegmentNodeIdsRef.current + : (() => { + connectionPathActiveSegmentNodeIdsKeyRef.current = setToStableKey( + connectionPathActiveSegmentNodeIdsRaw + ) + connectionPathActiveSegmentNodeIdsRef.current = connectionPathActiveSegmentNodeIdsRaw + return connectionPathActiveSegmentNodeIdsRaw + })() const addConnectionPathPausedNode = useCallback((nodeId: string) => { setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))