diff --git a/src/components/graph/NodeStatusIndicator.tsx b/src/components/graph/NodeStatusIndicator.tsx new file mode 100644 index 0000000..d3d023f --- /dev/null +++ b/src/components/graph/NodeStatusIndicator.tsx @@ -0,0 +1,177 @@ +import { useEffect, useId, useRef } from 'react' +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export type NodeStatus = 'loading' | 'success' | 'error' | 'initial' + +export type NodeStatusVariant = 'overlay' | 'border' + +export type NodeStatusIndicatorProps = { + status?: NodeStatus + variant?: NodeStatusVariant + children: ReactNode + /** Optional: node width/height so the spinner can match the border exactly */ + width?: number + height?: number +} + +const R = 6 // rounded corner radius (0.375rem ≈ 6px) + +const DURATION_MS = 2400 + +/** Ease-out with overshoot: 0→70%→85%→100% keyframe values */ +function dashOffsetAtProgress(progress: number, pathLength: number): number { + if (progress <= 0.7) return ((pathLength + 30) * progress) / 0.7 + if (progress <= 0.85) + return pathLength + 30 + (25 * (progress - 0.7)) / 0.15 + return pathLength + 55 - (5 * (progress - 0.85)) / 0.15 +} + +/** One solid segment (half path length) moving along the border, with gradient opacity 0→1 along the segment */ +function BorderLoadingIndicator({ + children, + width, + height, +}: { + children: ReactNode + width?: number + height?: number +}) { + const hasSize = width != null && height != null && width > 0 && height > 0 + const w = hasSize ? width + 4 : 0 + const h = hasSize ? height + 4 : 0 + const pathD = hasSize + ? `M ${R + 2} ${2} L ${w - R - 2} ${2} Q ${w - 2} ${2} ${w - 2} ${R + 2} L ${w - 2} ${h - R - 2} Q ${w - 2} ${h - 2} ${w - R - 2} ${h - 2} L ${R + 2} ${h - 2} Q ${2} ${h - 2} ${2} ${h - R - 2} L ${2} ${R + 2} Q ${2} ${2} ${R + 2} ${2} Z` + : '' + const pathLength = hasSize ? 2 * (w + h) - 8 * R + 2 * Math.PI * R : 0 + const segmentLen = pathLength * 0.5 + const gapLen = pathLength * 0.5 + 80 + + const gradientId = useId().replace(/:/g, '-') + const pathRef = useRef(null) + const gradientRef = useRef(null) + const rafRef = useRef(0) + const startTimeRef = useRef(0) + + useEffect(() => { + if (!hasSize || pathLength <= 0) return + const pathEl = pathRef.current + const gradientEl = gradientRef.current + if (!pathEl || !gradientEl) return + + const totalLen = pathEl.getTotalLength() + + const tick = () => { + const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS + const progress = Math.min(1, elapsed / DURATION_MS) + const dashOffset = dashOffsetAtProgress(progress, totalLen) + + pathEl.style.strokeDashoffset = String(dashOffset) + + const startLen = dashOffset % totalLen + const endLen = (dashOffset + segmentLen) % totalLen + const startPt = pathEl.getPointAtLength(startLen) + const endPt = pathEl.getPointAtLength(endLen) + gradientEl.setAttribute('x1', String(startPt.x)) + gradientEl.setAttribute('y1', String(startPt.y)) + gradientEl.setAttribute('x2', String(endPt.x)) + gradientEl.setAttribute('y2', String(endPt.y)) + + rafRef.current = requestAnimationFrame(tick) + } + + startTimeRef.current = performance.now() + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, [hasSize, pathLength, segmentLen]) + + return ( +
+ {children} + {hasSize && ( + + + + + + + + + + )} + {!hasSize && ( + <> + +
+ + )} +
+ ) +} + +/** Error state: red/destructive border around the node */ +function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +/** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */ +function SuccessStatusBorder({ children }: { children: ReactNode }) { + return
{children}
+} + +export function NodeStatusIndicator({ + status, + variant = 'border', + children, + width, + height, +}: NodeStatusIndicatorProps) { + switch (status) { + case 'loading': + return variant === 'border' ? ( + {children} + ) : ( + <>{children} + ) + case 'error': + return {children} + case 'success': + return {children} + default: + return <>{children} + } +} diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index 99c22b0..56c0ea3 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -13,6 +13,7 @@ import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../ import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators' import { NodeHeaderTitle } from './NodeHeaderTitle' import { NodeMenubar } from './NodeMenubar' +import { NodeStatusIndicator } from './NodeStatusIndicator' import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { Sparkles } from 'lucide-react' import { InputHandle, OutputHandle } from './NodeHandles' @@ -74,6 +75,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const runIdRef = useRef(0) + const loadingStartedAtRef = useRef(null) + const minLoadingTimeoutRef = useRef | null>(null) const { theme } = useTheme() const ctx = useContext(FlowContext) @@ -206,6 +209,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: let cancelled = false const run = async () => { + loadingStartedAtRef.current = Date.now() setLoading(true) setError(null) try { @@ -341,12 +345,30 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: setSvgContent(null) setError({ kind: 'render', message: msg }) } finally { - if (!cancelled && thisRunId === runIdRef.current) setLoading(false) + if (!cancelled && thisRunId === runIdRef.current) { + const startedAt = loadingStartedAtRef.current ?? 0 + const elapsed = Date.now() - startedAt + const remaining = Math.max(0, 1000 - elapsed) + if (remaining > 0) { + minLoadingTimeoutRef.current = setTimeout(() => { + minLoadingTimeoutRef.current = null + if (!cancelled && thisRunId === runIdRef.current) setLoading(false) + }, remaining) + } else { + setLoading(false) + } + } } } run() - return () => { cancelled = true } + return () => { + cancelled = true + if (minLoadingTimeoutRef.current != null) { + clearTimeout(minLoadingTimeoutRef.current) + minLoadingTimeoutRef.current = null + } + } // Only re-run when inputs that affect the resolved diagram change (signatures + source + theme). // Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders. }, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) @@ -388,7 +410,10 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: img.src = dataUrl }, [id, svgContent]) + const status = loading ? 'loading' : error ? 'error' : svgContent ? 'success' : 'initial' + return ( + }> } title={} /> @@ -467,6 +492,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: + ) }) diff --git a/src/styles.css b/src/styles.css index 33b4bee..bcdda4e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -22,6 +22,7 @@ body { /* Node specific overrides */ .react-flow__node { display: block; + overflow: visible; } .react-flow__node .react-flow__handle {