refactor: improvements

This commit is contained in:
2026-03-12 18:14:21 +01:00
parent 51c96641f0
commit a0bf9c6b70
4 changed files with 123 additions and 29 deletions

View File

@@ -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) {
</div>
)}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad />
<FlowKeyboardShortcuts />
<ReactFlow
<ViewportDisplayProvider>
<FlowFitViewOnLoad />
<FlowKeyboardShortcuts />
<ReactFlow
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
nodes={nodesForFlow}
edges={edgesForFlow}
@@ -684,6 +684,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
fitView
onInit={onInit}
nodeDragThreshold={1}
onlyRenderVisibleElements
nodeOrigin={[0, 0]}
nodesDraggable
nodesConnectable
elementsSelectable
@@ -698,12 +700,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<div role="group" aria-label="Canvas controls: zoom and fit view">
<Controls />
</div>
{showMinimap && (
{showMinimap && nodes.length > 5 && (
<div role="region" aria-label="Minimap: overview of the graph">
<MiniMap />
</div>
)}
</ReactFlow>
</ViewportDisplayProvider>
</ReactFlowProvider>
</div>
</ContextMenuTrigger>

View File

@@ -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<P extends NodeProps>(
Inner: React.ComponentType<P>
): React.ComponentType<P> {
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 }

View File

@@ -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<ViewportDisplayMode>('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<ViewportDisplayMode>(() =>
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 (
<ViewportDisplayContext.Provider value={displayMode}>
{children}
</ViewportDisplayContext.Provider>
)
}

View File

@@ -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>): 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<Set<string>>(connectionPathNodeIdsRaw)
const connectionPathNodeIdsKeyRef = useRef<string>('')
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<Set<string>>(
connectionPathPausedSegmentNodeIdsRaw
)
const connectionPathPausedSegmentNodeIdsKeyRef = useRef<string>('')
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<Set<string>>(
connectionPathActiveSegmentNodeIdsRaw
)
const connectionPathActiveSegmentNodeIdsKeyRef = useRef<string>('')
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]))