imp animation
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useId, useRef } from 'react'
|
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
||||||
@@ -27,7 +28,8 @@ function dashOffsetAtProgress(progress: number, pathLength: number): number {
|
|||||||
return pathLength + 55 - (5 * (progress - 0.85)) / 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 */
|
/** One solid segment (half path length) moving along the border, with gradient opacity 0→1 along the segment.
|
||||||
|
* Uses ResizeObserver so the border always matches the actual rendered node size (avoids min-width/min-height mismatch). */
|
||||||
function BorderLoadingIndicator({
|
function BorderLoadingIndicator({
|
||||||
children,
|
children,
|
||||||
width,
|
width,
|
||||||
@@ -37,29 +39,60 @@ function BorderLoadingIndicator({
|
|||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
}) {
|
}) {
|
||||||
const hasSize = width != null && height != null && width > 0 && height > 0
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const w = hasSize ? width + 4 : 0
|
const [measured, setMeasured] = useState({ w: 0, h: 0 })
|
||||||
const h = hasSize ? height + 4 : 0
|
const hasMeasured = measured.w > 0 && measured.h > 0
|
||||||
|
const fallbackW = (width != null && height != null && width > 0 && height > 0) ? width + 4 : 0
|
||||||
|
const fallbackH = (width != null && height != null && width > 0 && height > 0) ? height + 4 : 0
|
||||||
|
const w = hasMeasured ? measured.w + 4 : fallbackW
|
||||||
|
const h = hasMeasured ? measured.h + 4 : fallbackH
|
||||||
|
const hasSize = w > 0 && h > 0
|
||||||
const pathD = hasSize
|
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`
|
? `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 gradientId = useId().replace(/:/g, '-')
|
||||||
const pathRef = useRef<SVGPathElement>(null)
|
const pathRef = useRef<SVGPathElement>(null)
|
||||||
const gradientRef = useRef<SVGLinearGradientElement>(null)
|
const gradientRef = useRef<SVGLinearGradientElement>(null)
|
||||||
const rafRef = useRef<number>(0)
|
const rafRef = useRef<number>(0)
|
||||||
const startTimeRef = useRef<number>(0)
|
const startTimeRef = useRef<number>(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!hasSize || pathLength <= 0) return
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
// Observe the first child (BaseNode) so we use its actual rendered size.
|
||||||
|
const target =
|
||||||
|
container.firstElementChild instanceof HTMLElement
|
||||||
|
? container.firstElementChild
|
||||||
|
: container
|
||||||
|
const syncMeasure = () => {
|
||||||
|
const cw = (target as HTMLElement).offsetWidth
|
||||||
|
const ch = (target as HTMLElement).offsetHeight
|
||||||
|
if (cw > 0 && ch > 0) {
|
||||||
|
flushSync(() => setMeasured({ w: cw, h: ch }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncMeasure()
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry) return
|
||||||
|
const { width: cw, height: ch } = entry.contentRect
|
||||||
|
setMeasured({ w: Math.round(cw), h: Math.round(ch) })
|
||||||
|
})
|
||||||
|
ro.observe(target)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!hasSize) return
|
||||||
const pathEl = pathRef.current
|
const pathEl = pathRef.current
|
||||||
const gradientEl = gradientRef.current
|
const gradientEl = gradientRef.current
|
||||||
if (!pathEl || !gradientEl) return
|
if (!pathEl || !gradientEl) return
|
||||||
|
|
||||||
const totalLen = pathEl.getTotalLength()
|
const totalLen = pathEl.getTotalLength()
|
||||||
|
const segmentLen = totalLen * 0.5
|
||||||
|
const gapLen = totalLen * 0.5 + 80
|
||||||
|
|
||||||
|
pathEl.style.strokeDasharray = `${segmentLen} ${gapLen}`
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS
|
const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS
|
||||||
@@ -83,10 +116,10 @@ function BorderLoadingIndicator({
|
|||||||
startTimeRef.current = performance.now()
|
startTimeRef.current = performance.now()
|
||||||
rafRef.current = requestAnimationFrame(tick)
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
return () => cancelAnimationFrame(rafRef.current)
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
}, [hasSize, pathLength, segmentLen])
|
}, [hasSize, w, h])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-visible">
|
<div ref={containerRef} className="relative w-full h-full overflow-visible">
|
||||||
{children}
|
{children}
|
||||||
{hasSize && (
|
{hasSize && (
|
||||||
<svg
|
<svg
|
||||||
@@ -113,10 +146,7 @@ function BorderLoadingIndicator({
|
|||||||
className="fill-none stroke-[2]"
|
className="fill-none stroke-[2]"
|
||||||
d={pathD}
|
d={pathD}
|
||||||
stroke={`url(#${gradientId})`}
|
stroke={`url(#${gradientId})`}
|
||||||
style={{
|
style={{ strokeLinecap: 'round' }}
|
||||||
strokeDasharray: `${segmentLen} ${gapLen}`,
|
|
||||||
strokeLinecap: 'round',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user