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]))