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 { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar' import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts' import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
import { import {
Empty, Empty,
@@ -247,24 +248,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
[setEdges] [setEdges]
) )
const isValidConnection = useCallback( const isValidConnection = useCallback((connection: Connection | AppEdge) => {
(connection: Connection | AppEdge) => { const src = 'source' in connection ? connection.source : undefined
const src = 'source' in connection ? connection.source : undefined const tgt = 'target' in connection ? connection.target : undefined
const tgt = 'target' in connection ? connection.target : undefined if (typeof src !== 'string' || typeof tgt !== 'string') return false
if (typeof src !== 'string' || typeof tgt !== 'string') return false const currentNodes = nodesRef.current
const sourceNode = nodes.find((n) => n.id === src) const sourceNode = currentNodes.find((n) => n.id === src)
const targetNode = nodes.find((n) => n.id === tgt) const targetNode = currentNodes.find((n) => n.id === tgt)
const sourceType = sourceNode?.type const sourceType = sourceNode?.type
const targetType = targetNode?.type const targetType = targetNode?.type
if (!sourceType || !targetType) return false if (!sourceType || !targetType) return false
if (sourceType === 'render' && targetType === 'config') { if (sourceType === 'render' && targetType === 'config') {
const targetData = targetNode?.data as { configType?: string } | undefined const targetData = targetNode?.data as { configType?: string } | undefined
if (targetData?.configType !== 'markdown') return false if (targetData?.configType !== 'markdown') return false
} }
return isConnectionAllowed(sourceType, targetType, src, tgt) return isConnectionAllowed(sourceType, targetType, src, tgt)
}, }, [])
[nodes]
)
const onConnectStart = useCallback( const onConnectStart = useCallback(
( (
@@ -655,9 +654,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
</div> </div>
)} )}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView> <ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad /> <ViewportDisplayProvider>
<FlowKeyboardShortcuts /> <FlowFitViewOnLoad />
<ReactFlow <FlowKeyboardShortcuts />
<ReactFlow
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined} className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
nodes={nodesForFlow} nodes={nodesForFlow}
edges={edgesForFlow} edges={edgesForFlow}
@@ -684,6 +684,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
fitView fitView
onInit={onInit} onInit={onInit}
nodeDragThreshold={1} nodeDragThreshold={1}
onlyRenderVisibleElements
nodeOrigin={[0, 0]}
nodesDraggable nodesDraggable
nodesConnectable nodesConnectable
elementsSelectable elementsSelectable
@@ -698,12 +700,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<div role="group" aria-label="Canvas controls: zoom and fit view"> <div role="group" aria-label="Canvas controls: zoom and fit view">
<Controls /> <Controls />
</div> </div>
{showMinimap && ( {showMinimap && nodes.length > 5 && (
<div role="region" aria-label="Minimap: overview of the graph"> <div role="region" aria-label="Minimap: overview of the graph">
<MiniMap /> <MiniMap />
</div> </div>
)} )}
</ReactFlow> </ReactFlow>
</ViewportDisplayProvider>
</ReactFlowProvider> </ReactFlowProvider>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>

View File

@@ -4,9 +4,9 @@
* @see https://reactflow.dev/examples/interaction/contextual-zoom * @see https://reactflow.dev/examples/interaction/contextual-zoom
*/ */
import React from 'react' import React, { useContext } from 'react'
import { useViewport } from '@xyflow/react'
import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry' import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry'
import { ViewportDisplayContext } from '@/app/canvas/ViewportDisplayContext'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -86,8 +86,8 @@ export function createContextualNode<P extends NodeProps>(
Inner: React.ComponentType<P> Inner: React.ComponentType<P>
): React.ComponentType<P> { ): React.ComponentType<P> {
function ContextualZoomNode(props: P) { function ContextualZoomNode(props: P) {
const { zoom } = useViewport() const displayMode = useContext(ViewportDisplayContext)
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD const showCompact = displayMode === 'compact'
const p = props as NodeProps const p = props as NodeProps
const type = p.type const type = p.type
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 } 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' 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 } export type EdgeLike = { source: string; target: string }
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */ /** 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( getPathNodeIds(
edges, edges,
@@ -121,8 +126,18 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
), ),
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] [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( getPausedSegmentNodeIds(
edges, edges,
@@ -132,12 +147,42 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
), ),
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] [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) const active = new Set(connectionPathNodeIds)
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
return active return active
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds]) }, [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) => { const addConnectionPathPausedNode = useCallback((nodeId: string) => {
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))