2025-06-29 09:21:29 +08:00

220 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRef, useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns';
interface Segment {
start: number;
end: number;
active: boolean;
}
interface Marker {
time: number;
label?: string;
active?: boolean;
}
interface TimelineSliderProps {
minTime: number;
maxTime: number;
value: number;
mode?: 'default' | 'segments' | 'ticks';
segments?: Segment[];
markers?: Marker[];
onChange: (value: number) => void;
}
export default function TimelineSlider({
minTime,
maxTime,
value,
mode = 'default',
segments = [],
markers = [],
onChange
}: TimelineSliderProps) {
const sliderRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const [internalValue, setInternalValue] = useState(value);
const [showTooltip, setShowTooltip] = useState(false);
// 同步外部value到内部状态只在非拖拽状态下
useEffect(() => {
if (!dragging) {
setInternalValue(value);
}
}, [value, dragging]);
const formattedValue = format(new Date(internalValue), 'HH:mm:ss');
const percentage = ((internalValue - minTime) / (maxTime - minTime)) * 100;
const getSnapValue = useCallback((val: number): number => {
if (mode === 'ticks' && markers.length > 0) {
let closest = markers[0].time;
let minDiff = Math.abs(val - markers[0].time);
for (const marker of markers) {
const diff = Math.abs(val - marker.time);
if (diff < minDiff) {
minDiff = diff;
closest = marker.time;
}
}
return closest;
} else if (mode === 'segments' && segments.length > 0) {
let closestMidpoint: number | null = null;
let minDiff = Infinity;
for (const segment of segments) {
if (segment.active) {
const midpoint = (segment.start + segment.end) / 2;
const diff = Math.abs(val - midpoint);
if (diff < minDiff) {
minDiff = diff;
closestMidpoint = midpoint;
}
}
}
if (closestMidpoint !== null) {
return closestMidpoint;
}
}
return val;
}, [mode, markers, segments]);
// 使用ref来跟踪上次通知的值避免频繁调用onChange
const lastNotifiedValue = useRef(value);
// 指针移动处理函数
const pointerMoveHandler = useCallback((event: PointerEvent) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
setInternalValue(newValue);
// 节流调用onChange避免过于频繁的更新
const snapped = getSnapValue(newValue);
if (Math.abs(snapped - lastNotifiedValue.current) > 100) { // 100ms的节流
lastNotifiedValue.current = snapped;
onChange(snapped);
}
}, [minTime, maxTime, getSnapValue, onChange]);
// 指针释放处理函数
const pointerUpHandler = useCallback(() => {
setDragging(false);
setShowTooltip(false);
window.removeEventListener('pointermove', pointerMoveHandler);
window.removeEventListener('pointerup', pointerUpHandler);
// 最终确保调用onChange通知最新的值
setInternalValue(currentValue => {
const snapped = getSnapValue(currentValue);
lastNotifiedValue.current = snapped;
onChange(snapped);
return snapped;
});
}, [getSnapValue, onChange, pointerMoveHandler]);
// 指针按下处理函数
const onPointerDown = (event: React.PointerEvent) => {
event.stopPropagation();
event.preventDefault();
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
setDragging(true);
setShowTooltip(true);
setInternalValue(newValue);
// 立即通知父组件值的变化
const snapped = getSnapValue(newValue);
lastNotifiedValue.current = snapped;
onChange(snapped);
window.addEventListener('pointermove', pointerMoveHandler);
window.addEventListener('pointerup', pointerUpHandler);
};
const getSegmentStyle = (segment: Segment) => {
const startPercent = ((segment.start - minTime) / (maxTime - minTime)) * 100;
const endPercent = ((segment.end - minTime) / (maxTime - minTime)) * 100;
const widthPercent = endPercent - startPercent;
return {
position: 'absolute' as const,
left: `${startPercent}%`,
width: `${widthPercent}%`,
height: '100%',
backgroundColor: segment.active ? '#4ADE80' : 'transparent',
borderLeft: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
};
};
const getMarkerStyle = (marker: Marker) => {
const pos = ((marker.time - minTime) / (maxTime - minTime)) * 100;
return {
position: 'absolute' as const,
left: `${pos}%`,
top: '50%',
width: '2px',
height: '100%',
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
transform: 'translate(-50%, -50%)'
};
};
return (
<div
ref={sliderRef}
className="relative h-10 select-none"
style={{ touchAction: 'none' }}
onPointerDown={onPointerDown}
>
{/* 底部轨道 */}
<div
className="absolute top-1/2 left-0 right-0 h-1.5 bg-gray-200 dark:bg-gray-700 transform -translate-y-1/2 rounded-sm"
>
{/* Segments 模式 */}
{mode === 'segments' && segments.map((segment, index) => (
<div
key={index}
style={getSegmentStyle(segment)}
/>
))}
{/* Ticks 模式 */}
{mode === 'ticks' && markers.map((marker, index) => (
<div
key={index}
style={getMarkerStyle(marker)}
/>
))}
</div>
{/* 可拖动的滑块 */}
<div
className="absolute top-1/2 w-5 h-5 bg-green-400 border-2 border-white rounded-full shadow-lg cursor-pointer z-10"
style={{
left: `${percentage}%`,
transform: 'translate(-50%, -50%)',
}}
onPointerDown={onPointerDown}
>
{/* Tooltip */}
{showTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black dark:bg-gray-800 bg-opacity-70 dark:bg-opacity-90 text-white text-xs rounded whitespace-nowrap">
{formattedValue}
</div>
)}
</div>
</div>
);
}