refactor: improvements
This commit is contained in:
@@ -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,13 +248,13 @@ 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 sourceNode = nodes.find((n) => n.id === src)
|
const currentNodes = nodesRef.current
|
||||||
const targetNode = nodes.find((n) => n.id === tgt)
|
const sourceNode = currentNodes.find((n) => n.id === src)
|
||||||
|
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
|
||||||
@@ -262,9 +263,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
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,6 +654,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
|
<ViewportDisplayProvider>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad />
|
||||||
<FlowKeyboardShortcuts />
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
46
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal file
46
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
|
|||||||
Reference in New Issue
Block a user