Compare commits
4 Commits
ea6bdeb05f
...
3ef30bd58c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ef30bd58c | |||
| 42676b2674 | |||
| fddc6488ea | |||
| 1232d148e6 |
@@ -66,6 +66,7 @@ import {
|
||||
getNodeType,
|
||||
isConnectionAllowed,
|
||||
} from '@/lib/nodeRegistry'
|
||||
import { getPathNodeIds } from '@/lib/graphPath'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -77,6 +78,8 @@ import {
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
|
||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||
@@ -264,6 +267,75 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState<string[]>([])
|
||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState<string[]>([])
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearPathUpdateSession = useCallback(() => {
|
||||
setConnectionPathUpdatingNodeIds([])
|
||||
setConnectionPathTriggerNodeIds([])
|
||||
}, [])
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
pathUpdateStartTimeRef.current = Date.now()
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.delete(nodeId)
|
||||
if (ref.size > 0) {
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
const startedAt = pathUpdateStartTimeRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
|
||||
if (remaining === 0) {
|
||||
clearPathUpdateSession()
|
||||
} else {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, remaining)
|
||||
}
|
||||
}, [clearPathUpdateSession])
|
||||
|
||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||
const pathTriggerScheduledRef = useRef(false)
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
pathTriggerBatchRef.current.add(nodeId)
|
||||
if (pathTriggerScheduledRef.current) return
|
||||
pathTriggerScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
pathTriggerScheduledRef.current = false
|
||||
const batch = new Set(pathTriggerBatchRef.current)
|
||||
pathTriggerBatchRef.current = new Set()
|
||||
if (batch.size === 0) return
|
||||
setConnectionPathTriggerNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
batch.forEach((id) => next.add(id))
|
||||
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
|
||||
@@ -471,6 +543,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const connectionPathNodeIds = useMemo(
|
||||
() => getPathNodeIds(edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds]
|
||||
)
|
||||
|
||||
const flowContextValue = useMemo(
|
||||
() => ({
|
||||
nodes,
|
||||
@@ -485,6 +562,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
}),
|
||||
[
|
||||
nodes,
|
||||
@@ -499,6 +582,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ function CompactNodeView({ id, type, selected, width, height }: NodeProps) {
|
||||
/**
|
||||
* Wraps a node component so that when the viewport zoom is at or below
|
||||
* CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view.
|
||||
* Inner is always mounted (hidden when compact) so switching zoom does not
|
||||
* remount and re-trigger effects (e.g. RenderingNode fetch).
|
||||
*/
|
||||
export function createContextualNode<P extends NodeProps>(
|
||||
Inner: React.ComponentType<P>
|
||||
@@ -86,22 +88,35 @@ export function createContextualNode<P extends NodeProps>(
|
||||
function ContextualZoomNode(props: P) {
|
||||
const { zoom } = useViewport()
|
||||
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
|
||||
const p = props as NodeProps
|
||||
const type = p.type
|
||||
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
|
||||
const w = p.width ?? defaultStyle.width
|
||||
const h = p.height ?? defaultStyle.height
|
||||
|
||||
if (showCompact) {
|
||||
const p = props as NodeProps
|
||||
return (
|
||||
<CompactNodeView
|
||||
id={p.id}
|
||||
type={p.type}
|
||||
data={p.data}
|
||||
selected={p.selected}
|
||||
width={p.width}
|
||||
height={p.height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Inner {...props} />
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
showCompact
|
||||
? { width: w, height: h, minWidth: w, minHeight: h, position: 'relative' }
|
||||
: { display: 'contents' }
|
||||
}
|
||||
>
|
||||
{showCompact && (
|
||||
<CompactNodeView
|
||||
id={p.id}
|
||||
type={type}
|
||||
data={p.data}
|
||||
selected={p.selected}
|
||||
width={p.width}
|
||||
height={p.height}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: showCompact ? 'none' : undefined }} aria-hidden={showCompact}>
|
||||
<Inner {...props} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`
|
||||
|
||||
@@ -9,9 +9,11 @@ import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
export function AnimatedEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -25,6 +27,7 @@ export function AnimatedEdge({
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
|
||||
@@ -32,6 +35,8 @@ export function AnimatedEdge({
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
|
||||
const isOnUpdatingPath = pathNodeIds.has(source) && pathNodeIds.has(target)
|
||||
|
||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -88,7 +93,7 @@ export function AnimatedEdge({
|
||||
strokeWidth: EDGE_STROKE_WIDTH,
|
||||
...style,
|
||||
}}
|
||||
className="animated-edge-path"
|
||||
className={`animated-edge-path${isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`}
|
||||
interactionWidth={interactionWidth}
|
||||
/>
|
||||
{label != null && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/react";
|
||||
|
||||
import { useConnectionPathRole } from "@/lib/flowContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
@@ -34,6 +35,7 @@ export function BaseNode({
|
||||
resizeConstraints,
|
||||
...props
|
||||
}: BaseNodeProps) {
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
dimensions.width > 0 &&
|
||||
@@ -62,9 +64,13 @@ export function BaseNode({
|
||||
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
|
||||
"hover:ring-1",
|
||||
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
|
||||
connectionPathRole === "trigger" && "connection-path-trigger",
|
||||
connectionPathRole === "updating" && "connection-path-updating",
|
||||
connectionPathRole === "on-path" && "connection-path-on-path",
|
||||
className,
|
||||
)}
|
||||
data-selected={selected}
|
||||
data-path-role={connectionPathRole ?? undefined}
|
||||
style={appliedStyle}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
|
||||
@@ -55,6 +55,8 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn
|
||||
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
|
||||
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
|
||||
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
|
||||
const { aiConnection } = usePlatform()
|
||||
@@ -88,6 +90,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
|
||||
|
||||
updateData({ error: undefined, loading: true })
|
||||
startConnectionPathUpdate?.(id)
|
||||
setRunning(true)
|
||||
try {
|
||||
const res = await fetch('/api/agent', {
|
||||
@@ -107,16 +110,19 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
|
||||
outputMarkdown: undefined,
|
||||
})
|
||||
endConnectionPathUpdate?.(id)
|
||||
return
|
||||
}
|
||||
const markdown = (json as { markdown?: string }).markdown ?? ''
|
||||
updateData({ loading: false, error: undefined, outputMarkdown: markdown })
|
||||
endConnectionPathUpdate?.(id)
|
||||
} catch (err: unknown) {
|
||||
updateData({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Agent request failed',
|
||||
outputMarkdown: undefined,
|
||||
})
|
||||
endConnectionPathUpdate?.(id)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
@@ -131,6 +137,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
<BaseNode
|
||||
className="min-w-[360px] min-h-[320px]"
|
||||
dimensions={dimensions}
|
||||
nodeId={id}
|
||||
selected={selected}
|
||||
handles={
|
||||
<>
|
||||
|
||||
@@ -50,6 +50,8 @@ type ViewMode = 'preview' | 'raw'
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
|
||||
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
|
||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
|
||||
@@ -195,6 +197,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
|
||||
})
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
return
|
||||
}
|
||||
if (incomingIds.length === 0) {
|
||||
@@ -202,6 +205,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setResolvedContent(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -212,6 +216,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const run = async () => {
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
startConnectionPathUpdate?.(id)
|
||||
setError(null)
|
||||
try {
|
||||
if (srcNode?.type === 'agent') {
|
||||
@@ -223,6 +228,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setRenderedContent(html)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
return
|
||||
}
|
||||
const configIdsUsed = new Set<string>()
|
||||
@@ -497,6 +503,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setResolvedContent(null)
|
||||
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -524,10 +531,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
if (remaining > 0) {
|
||||
minLoadingTimeoutRef.current = setTimeout(() => {
|
||||
minLoadingTimeoutRef.current = null
|
||||
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
}
|
||||
}, remaining)
|
||||
} else {
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -537,6 +548,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||
setLoading(false)
|
||||
endConnectionPathUpdate?.(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,6 +561,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
clearTimeout(minLoadingTimeoutRef.current)
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
endConnectionPathUpdate?.(id)
|
||||
}
|
||||
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
|
||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
|
||||
@@ -674,7 +687,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
}, [configTypeId, configType.language])
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
||||
<NodeStatusIndicator status={status} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
|
||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
|
||||
<BaseNodeHeaderRow
|
||||
icon={<Sparkles className="size-4" />}
|
||||
|
||||
@@ -84,7 +84,7 @@ function VariableNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} nodeId={id} selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
|
||||
@@ -72,6 +72,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
@@ -80,8 +81,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
) as AppNode[]
|
||||
)
|
||||
addConnectionPathTrigger?.(id)
|
||||
},
|
||||
[id, setNodes]
|
||||
[id, setNodes, addConnectionPathTrigger]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { Connection } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
|
||||
|
||||
export type FlowActions = {
|
||||
pasteAtViewportCenter: () => void
|
||||
fitView: () => void
|
||||
@@ -25,8 +28,38 @@ export type FlowContextValue = {
|
||||
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
|
||||
fullscreenNodeId: string | null
|
||||
setFullscreenNodeId: (id: string | null) => void
|
||||
/** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
/** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */
|
||||
connectionPathTriggerNodeIds: string[]
|
||||
/** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */
|
||||
addConnectionPathTrigger: (nodeId: string) => void
|
||||
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
|
||||
connectionPathNodeIds: Set<string>
|
||||
/** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */
|
||||
startConnectionPathUpdate: (nodeId: string) => void
|
||||
/** Call when a path update ends for this node. If min duration not reached, animation continues until then. */
|
||||
endConnectionPathUpdate: (nodeId: string) => void
|
||||
}
|
||||
|
||||
const FlowContext = React.createContext<FlowContextValue | null>(null)
|
||||
|
||||
export default FlowContext
|
||||
|
||||
/**
|
||||
* Returns this node's role in the current path update for styling (pushing vs receiving).
|
||||
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
|
||||
*/
|
||||
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
|
||||
const ctx = React.useContext(FlowContext)
|
||||
return useMemo(() => {
|
||||
if (!nodeId) return null
|
||||
const triggers = ctx?.connectionPathTriggerNodeIds
|
||||
const updating = ctx?.connectionPathUpdatingNodeIds
|
||||
const path = ctx?.connectionPathNodeIds
|
||||
if (!path?.has(nodeId)) return null
|
||||
if (triggers?.includes(nodeId)) return 'trigger'
|
||||
if (updating?.includes(nodeId)) return 'updating'
|
||||
return 'on-path'
|
||||
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
|
||||
}
|
||||
|
||||
67
frontend/src/lib/graphPath.ts
Normal file
67
frontend/src/lib/graphPath.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
|
||||
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
|
||||
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
|
||||
*/
|
||||
|
||||
export type GraphEdge = { source: string; target: string }
|
||||
|
||||
/** Nodes reachable from seedIds by following edges forward (source → target). */
|
||||
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
||||
const out = new Set<string>(seedIds)
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
if (out.has(e.source) && !out.has(e.target)) {
|
||||
out.add(e.target)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Nodes that can reach any seed by following edges backward (target → source). */
|
||||
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
||||
const out = new Set<string>(seedIds)
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
if (out.has(e.target) && !out.has(e.source)) {
|
||||
out.add(e.source)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* All node ids that lie on the path of an update.
|
||||
* - If triggerNodeIds is non-empty: path = nodes that are both downstream of a trigger and
|
||||
* upstream of an updating node (only the chain that actually triggered the update).
|
||||
* - Otherwise: path = upstream ∪ updating ∪ downstream of updating (legacy full upstream/downstream).
|
||||
* An edge should show the update animation iff both its source and target are in this set.
|
||||
*/
|
||||
export function getPathNodeIds(
|
||||
edges: GraphEdge[],
|
||||
updatingNodeIds: string[],
|
||||
triggerNodeIds?: string[]
|
||||
): Set<string> {
|
||||
if (updatingNodeIds.length === 0) return new Set()
|
||||
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
|
||||
if (triggerNodeIds != null && triggerNodeIds.length > 0) {
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
|
||||
const path = new Set<string>()
|
||||
upstream.forEach((id) => {
|
||||
if (downstreamOfTrigger.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
|
||||
const path = new Set<string>(upstream)
|
||||
downstream.forEach((id) => path.add(id))
|
||||
return path
|
||||
}
|
||||
@@ -24,9 +24,11 @@ body {
|
||||
.react-flow__pane {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.react-flow.react-flow--panning .react-flow__pane {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.react-flow.react-flow--selecting .react-flow__pane {
|
||||
cursor: crosshair;
|
||||
}
|
||||
@@ -133,26 +135,40 @@ body {
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
/* Animated React Flow edges: thicker stroke + path animation */
|
||||
/* Animated React Flow edges: ant trail always on; color shows updating path */
|
||||
.react-flow__edge path.animated-edge-path,
|
||||
.animated-edge-path {
|
||||
stroke-width: 4;
|
||||
stroke: hsl(var(--foreground) / 0.6);
|
||||
stroke: hsl(var(--foreground) / 0.5);
|
||||
fill: none;
|
||||
stroke-dasharray: 8 6;
|
||||
animation: edge-flow 0.6s linear infinite;
|
||||
stroke-dashoffset: 0;
|
||||
animation: edge-flow 1.2s linear infinite;
|
||||
will-change: stroke-dashoffset;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
transition: stroke 0.4s ease-out;
|
||||
}
|
||||
|
||||
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
|
||||
.animated-edge-path.animated-edge-path--updating {
|
||||
stroke: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
@keyframes edge-flow {
|
||||
from {
|
||||
0% {
|
||||
stroke-dashoffset: 14;
|
||||
}
|
||||
|
||||
to {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Connection path roles: pushing (trigger) — classes on BaseNode root. Updating nodes have no extra border so rendering doesn’t flash. */
|
||||
.connection-path-trigger {
|
||||
box-shadow: inset 3px 0 0 hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* NodeResizer: snappy drag; no border/background on handles; keep resize cursors. */
|
||||
.react-flow__resize-control {
|
||||
touch-action: none;
|
||||
@@ -174,14 +190,37 @@ body {
|
||||
}
|
||||
|
||||
/* Resize cursor per position (keep cursor style change on hover). */
|
||||
.react-flow__resize-control.top.left { cursor: nw-resize; }
|
||||
.react-flow__resize-control.top.right { cursor: ne-resize; }
|
||||
.react-flow__resize-control.bottom.left { cursor: sw-resize; }
|
||||
.react-flow__resize-control.bottom.right { cursor: se-resize; }
|
||||
.react-flow__resize-control.top { cursor: n-resize; }
|
||||
.react-flow__resize-control.right { cursor: e-resize; }
|
||||
.react-flow__resize-control.bottom { cursor: s-resize; }
|
||||
.react-flow__resize-control.left { cursor: w-resize; }
|
||||
.react-flow__resize-control.top.left {
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.top.right {
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.bottom.left {
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.bottom.right {
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.top {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.right {
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.bottom {
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.left {
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
/* Small helper for monospace pre output */
|
||||
pre {
|
||||
|
||||
Reference in New Issue
Block a user