feat: ant path rendering on cahnges
This commit is contained in:
@@ -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'})`
|
||||
|
||||
Reference in New Issue
Block a user