220 lines
6.5 KiB
TypeScript
220 lines
6.5 KiB
TypeScript
"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' : '#E5E7EB',
|
||
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 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 bg-opacity-70 text-white text-xs rounded whitespace-nowrap">
|
||
{formattedValue}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|