198 lines
5.6 KiB
TypeScript
198 lines
5.6 KiB
TypeScript
// 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<HTMLDivElement | null>(null)
|
|
|
|
const rafRef = React.useRef<number | null>(null)
|
|
const pendingIndexRef = React.useRef<number | undefined>(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<HTMLDivElement>) => {
|
|
const idx = indexFromClientX(e.clientX)
|
|
setIndexThrottled(idx)
|
|
},
|
|
[indexFromClientX, setIndexThrottled]
|
|
)
|
|
|
|
const handlePointerEnter = React.useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
const idx = indexFromClientX(e.clientX)
|
|
setIndexThrottled(idx)
|
|
},
|
|
[indexFromClientX, setIndexThrottled]
|
|
)
|
|
|
|
const handlePointerLeave = React.useCallback(() => {
|
|
setIndexThrottled(undefined)
|
|
}, [setIndexThrottled])
|
|
|
|
const handlePointerDown = React.useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={rootRef}
|
|
className={[
|
|
'relative h-7 w-full select-none touch-none cursor-col-resize',
|
|
// standardmäßig versteckt, nur bei Hover/Focus auf dem Parent-Video sichtbar
|
|
'opacity-0 transition-opacity duration-150',
|
|
'pointer-events-none',
|
|
'group-hover:opacity-100 group-focus-within:opacity-100',
|
|
'group-hover:pointer-events-auto group-focus-within:pointer-events-auto',
|
|
className,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
onPointerDown={handlePointerDown}
|
|
onMouseDown={(e) => 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}
|
|
>
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
|
{typeof markerLeftPct === 'number' ? (
|
|
<div
|
|
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
|
style={{
|
|
left: `${markerLeftPct}%`,
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
className={[
|
|
'pointer-events-none absolute bottom-[19px] z-10',
|
|
'rounded bg-black/70 px-1.5 py-0.5',
|
|
'text-[11px] leading-none text-white whitespace-nowrap',
|
|
'transition-opacity duration-100',
|
|
showLabel ? 'opacity-100' : 'opacity-0',
|
|
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
|
|
].join(' ')}
|
|
style={labelLeftStyle}
|
|
>
|
|
{label}
|
|
</div>
|
|
</div>
|
|
)
|
|
} |