loading animation
This commit is contained in:
177
src/components/graph/NodeStatusIndicator.tsx
Normal file
177
src/components/graph/NodeStatusIndicator.tsx
Normal file
@@ -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<SVGPathElement>(null)
|
||||||
|
const gradientRef = useRef<SVGLinearGradientElement>(null)
|
||||||
|
const rafRef = useRef<number>(0)
|
||||||
|
const startTimeRef = useRef<number>(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 (
|
||||||
|
<div className="relative w-full h-full overflow-visible">
|
||||||
|
{children}
|
||||||
|
{hasSize && (
|
||||||
|
<svg
|
||||||
|
className="absolute pointer-events-none z-10"
|
||||||
|
style={{ top: -2, left: -2, width: w, height: h }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
ref={gradientRef}
|
||||||
|
id={gradientId}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||||
|
<stop offset="1" stopColor="hsl(var(--primary))" stopOpacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
ref={pathRef}
|
||||||
|
className="fill-none stroke-[2]"
|
||||||
|
d={pathD}
|
||||||
|
stroke={`url(#${gradientId})`}
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${segmentLen} ${gapLen}`,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!hasSize && (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes node-status-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="absolute -inset-[2px] rounded-md border-[1.5px] border-primary pointer-events-none z-10"
|
||||||
|
style={{ opacity: 0.8, animation: 'node-status-pulse 2s ease-in-out infinite' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error state: red/destructive border around the node */
|
||||||
|
function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-md ring-2 ring-destructive', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */
|
||||||
|
function SuccessStatusBorder({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="rounded-md">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeStatusIndicator({
|
||||||
|
status,
|
||||||
|
variant = 'border',
|
||||||
|
children,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: NodeStatusIndicatorProps) {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return variant === 'border' ? (
|
||||||
|
<BorderLoadingIndicator width={width} height={height}>{children}</BorderLoadingIndicator>
|
||||||
|
) : (
|
||||||
|
<>{children}</>
|
||||||
|
)
|
||||||
|
case 'error':
|
||||||
|
return <ErrorStatusBorder>{children}</ErrorStatusBorder>
|
||||||
|
case 'success':
|
||||||
|
return <SuccessStatusBorder>{children}</SuccessStatusBorder>
|
||||||
|
default:
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '../../
|
|||||||
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
import { NodeHeaderTitle } from './NodeHeaderTitle'
|
||||||
import { NodeMenubar } from './NodeMenubar'
|
import { NodeMenubar } from './NodeMenubar'
|
||||||
|
import { NodeStatusIndicator } from './NodeStatusIndicator'
|
||||||
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { InputHandle, OutputHandle } from './NodeHandles'
|
import { InputHandle, OutputHandle } from './NodeHandles'
|
||||||
@@ -74,6 +75,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const runIdRef = useRef(0)
|
const runIdRef = useRef(0)
|
||||||
|
const loadingStartedAtRef = useRef<number | null>(null)
|
||||||
|
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
const ctx = useContext(FlowContext)
|
const ctx = useContext(FlowContext)
|
||||||
@@ -206,6 +209,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -341,12 +345,30 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
setSvgContent(null)
|
setSvgContent(null)
|
||||||
setError({ kind: 'render', message: msg })
|
setError({ kind: 'render', message: msg })
|
||||||
} finally {
|
} 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()
|
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).
|
// 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.
|
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
|
||||||
}, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
|
}, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
|
||||||
@@ -388,7 +410,10 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
img.src = dataUrl
|
img.src = dataUrl
|
||||||
}, [id, svgContent])
|
}, [id, svgContent])
|
||||||
|
|
||||||
|
const status = loading ? 'loading' : error ? 'error' : svgContent ? 'success' : 'initial'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
|
||||||
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
|
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
|
||||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||||
|
|
||||||
@@ -467,6 +492,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
</NodeFooterEdgeIndicators>
|
</NodeFooterEdgeIndicators>
|
||||||
</BaseNodeFooter>
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
|
</NodeStatusIndicator>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ body {
|
|||||||
/* Node specific overrides */
|
/* Node specific overrides */
|
||||||
.react-flow__node {
|
.react-flow__node {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__node .react-flow__handle {
|
.react-flow__node .react-flow__handle {
|
||||||
|
|||||||
Reference in New Issue
Block a user