diff --git a/components/ui/TimeRangePicker.tsx b/components/ui/TimeRangePicker.tsx new file mode 100644 index 0000000..d6301bd --- /dev/null +++ b/components/ui/TimeRangePicker.tsx @@ -0,0 +1,355 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +function formatTime(seconds: number, includeHours: boolean): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (includeHours || h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function parseTimeInput(value: string, totalSeconds: number): number { + const parts = value.split(':').map(Number); + if (parts.some(isNaN)) return totalSeconds; + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + return parts[0] || 0; +} + +export interface TimeRangePickerProps { + duration: number; + startTime: number; + endTime: number; + onChange: (startTime: number, endTime: number) => void; + minRange?: number; + className?: string; +} + +export function TimeRangePicker({ + duration, + startTime, + endTime, + onChange, + minRange = 5, + className, +}: TimeRangePickerProps) { + const trackRef = React.useRef(null); + const startThumbRef = React.useRef(null); + const endThumbRef = React.useRef(null); + const [activeThumb, setActiveThumb] = React.useState<'start' | 'end' | null>(null); + const [editing, setEditing] = React.useState<'start' | 'end' | null>(null); + const [editValue, setEditValue] = React.useState(''); + + const clampedStart = React.useMemo( + () => Math.max(0, Math.min(startTime, duration)), + [startTime, duration] + ); + const clampedEnd = React.useMemo( + () => Math.max(0, Math.min(endTime, duration)), + [endTime, duration] + ); + + const startPercent = duration > 0 ? (clampedStart / duration) * 100 : 0; + const endPercent = duration > 0 ? (clampedEnd / duration) * 100 : 0; + + const includeHours = duration >= 3600; + + const thumbId = React.useId(); + const startSliderId = `${thumbId}-start`; + const endSliderId = `${thumbId}-end`; + + function clampPair(newStart: number, newEnd: number): [number, number] { + let s = Math.max(0, Math.min(newStart, duration)); + let e = Math.max(0, Math.min(newEnd, duration)); + if (e - s < minRange) { + if (newStart !== clampedStart) { + s = Math.max(0, e - minRange); + } else { + e = Math.min(duration, s + minRange); + } + } + return [s, e]; + } + + function pointerPosition(clientX: number): number { + const el = trackRef.current; + if (!el) return 0; + const rect = el.getBoundingClientRect(); + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + } + + function handleTrackPointerDown(e: React.PointerEvent) { + const frac = pointerPosition(e.clientX); + const clickTime = frac * duration; + const distToStart = Math.abs(clickTime - clampedStart); + const distToEnd = Math.abs(clickTime - clampedEnd); + if (distToStart <= distToEnd) { + const [s, e] = clampPair(clickTime, clampedEnd); + onChange(s, e); + startThumbRef.current?.focus(); + } else { + const [s, e] = clampPair(clampedStart, clickTime); + onChange(s, e); + endThumbRef.current?.focus(); + } + } + + function handleThumbPointerDown(thumb: 'start' | 'end') { + return (e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + setActiveThumb(thumb); + + function onMove(ev: PointerEvent) { + const frac = pointerPosition(ev.clientX); + const clickTime = frac * duration; + if (thumb === 'start') { + const [s, e] = clampPair(clickTime, clampedEnd); + onChange(s, e); + } else { + const [s, e] = clampPair(clampedStart, clickTime); + onChange(s, e); + } + } + + function onUp() { + setActiveThumb(null); + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + } + + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }; + } + + function handleKeyDown(thumb: 'start' | 'end') { + return (e: React.KeyboardEvent) => { + let step = 1; + if (e.shiftKey) step = 5; + + let newStart = clampedStart; + let newEnd = clampedEnd; + + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + if (thumb === 'start') { + newStart = Math.min(clampedEnd - minRange, clampedStart + step); + } else { + newEnd = Math.min(duration, clampedEnd + step); + } + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + if (thumb === 'start') { + newStart = Math.max(0, clampedStart - step); + } else { + newEnd = Math.max(clampedStart + minRange, clampedEnd - step); + } + } else if (e.key === 'Tab') { + return; + } else { + return; + } + + const [s, e] = clampPair(newStart, newEnd); + onChange(s, e); + }; + } + + function handleInputChange(thumb: 'start' | 'end', raw: string) { + const totalSeconds = parseTimeInput(raw, duration); + if (thumb === 'start') { + const [s, e] = clampPair(totalSeconds, clampedEnd); + onChange(s, e); + } else { + const [s, e] = clampPair(clampedStart, totalSeconds); + onChange(s, e); + } + } + + function startInputValue(): string { + if (includeHours || clampedStart >= 3600) { + const h = Math.floor(clampedStart / 3600); + const m = Math.floor((clampedStart % 3600) / 60); + const s = Math.floor(clampedStart % 60); + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + const m = Math.floor(clampedStart / 60); + const s = Math.floor(clampedStart % 60); + return `${m}:${s.toString().padStart(2, '0')}`; + } + + function endInputValue(): string { + if (includeHours || clampedEnd >= 3600) { + const h = Math.floor(clampedEnd / 3600); + const m = Math.floor((clampedEnd % 3600) / 60); + const s = Math.floor(clampedEnd % 60); + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + const m = Math.floor(clampedEnd / 60); + const s = Math.floor(clampedEnd % 60); + return `${m}:${s.toString().padStart(2, '0')}`; + } + + return ( +
+