loading animation

This commit is contained in:
2026-03-08 08:03:06 +01:00
parent 9e5fada009
commit 18b097d0ae
3 changed files with 206 additions and 2 deletions

View 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}</>
}
}

View File

@@ -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>
) )
}) })

View File

@@ -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 {