// frontend\src\components\ui\PreviewScrubber.tsx 'use client' import * as React from 'react' type Props = { imageCount: number activeIndex?: number onActiveIndexChange: (index: number | undefined) => void onIndexClick?: (index: number) => void className?: string stepSeconds?: number } function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)) } function formatClock(totalSeconds: number) { const s = Math.max(0, Math.floor(totalSeconds)) const m = Math.floor(s / 60) const sec = s % 60 return `${m}:${String(sec).padStart(2, '0')}` } export default function PreviewScrubber({ imageCount, activeIndex, onActiveIndexChange, onIndexClick, className, stepSeconds = 0, }: Props) { const rootRef = React.useRef(null) const rafRef = React.useRef(null) const pendingIndexRef = React.useRef(undefined) const flushPending = React.useCallback(() => { rafRef.current = null onActiveIndexChange(pendingIndexRef.current) }, [onActiveIndexChange]) const setIndexThrottled = React.useCallback( (index: number | undefined) => { pendingIndexRef.current = index if (rafRef.current != null) return rafRef.current = window.requestAnimationFrame(flushPending) }, [flushPending] ) React.useEffect(() => { return () => { if (rafRef.current != null) { window.cancelAnimationFrame(rafRef.current) rafRef.current = null } } }, []) const indexFromClientX = React.useCallback( (clientX: number) => { if (!rootRef.current || imageCount < 1) return undefined const rect = rootRef.current.getBoundingClientRect() if (rect.width <= 0) return undefined const x = clientX - rect.left const ratio = clamp(x / rect.width, 0, 0.999999) const idx = clamp(Math.floor(ratio * imageCount), 0, imageCount - 1) return idx }, [imageCount] ) const handlePointerMove = React.useCallback( (e: React.PointerEvent) => { const idx = indexFromClientX(e.clientX) setIndexThrottled(idx) }, [indexFromClientX, setIndexThrottled] ) const handlePointerEnter = React.useCallback( (e: React.PointerEvent) => { const idx = indexFromClientX(e.clientX) setIndexThrottled(idx) }, [indexFromClientX, setIndexThrottled] ) const handlePointerLeave = React.useCallback(() => { setIndexThrottled(undefined) }, [setIndexThrottled]) const handlePointerDown = React.useCallback( (e: React.PointerEvent) => { e.stopPropagation() const idx = indexFromClientX(e.clientX) setIndexThrottled(idx) try { e.currentTarget.setPointerCapture?.(e.pointerId) } catch {} }, [indexFromClientX, setIndexThrottled] ) const handleClick = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation() if (!onIndexClick) return const idx = indexFromClientX(e.clientX) if (typeof idx === 'number') onIndexClick(idx) }, [indexFromClientX, onIndexClick] ) if (!imageCount || imageCount < 1) return null const markerLeftPct = typeof activeIndex === 'number' ? ((activeIndex + 0.5) / imageCount) * 100 : undefined const label = typeof activeIndex === 'number' ? stepSeconds > 0 ? formatClock(activeIndex * stepSeconds) : `${activeIndex + 1}/${imageCount}` : '' const showLabel = typeof activeIndex === 'number' const labelLeftStyle = typeof markerLeftPct === 'number' ? { left: `clamp(24px, ${markerLeftPct}%, calc(100% - 24px))`, transform: 'translateX(-50%)', } : undefined return (
e.stopPropagation()} onClick={handleClick} title={showLabel ? label : `Preview scrubber (${imageCount})`} role="slider" aria-label="Preview scrubber" aria-valuemin={1} aria-valuemax={imageCount} aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined} >
{typeof markerLeftPct === 'number' ? (
) : null}
{label}
) }