import { memo, useCallback, useRef } from "react"; import { useCaptionStore } from "../store"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const GROUP_COLORS = [ "#3CE6AC", "#FF6B6B", "#4ECDC4", "#FFE66D", "#A78BFA", "#F472B6", "#34D399", "#FB923C", "#60A5FA", "#C084FC", ]; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CaptionTimelineProps { pixelsPerSecond: number; onSeek?: (time: number) => void; } interface DragState { segId: string; edge: "start" | "end"; originalStart: number; originalEnd: number; startX: number; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const CaptionTimeline = memo(function CaptionTimeline({ pixelsPerSecond, onSeek, }: CaptionTimelineProps) { const model = useCaptionStore((s) => s.model); const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds); const selectSegment = useCaptionStore((s) => s.selectSegment); const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming); const splitGroup = useCaptionStore((s) => s.splitGroup); const dragRef = useRef(null); const handleEdgePointerDown = useCallback( ( e: React.PointerEvent, segId: string, edge: "start" | "end", originalStart: number, originalEnd: number, ) => { e.stopPropagation(); e.preventDefault(); (e.target as HTMLElement).setPointerCapture(e.pointerId); dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX }; }, [], ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { const drag = dragRef.current; if (!drag) return; const delta = (e.clientX - drag.startX) / pixelsPerSecond; if (drag.edge === "start") { const newStart = Math.max(0, drag.originalStart + delta); const clampedStart = Math.min(newStart, drag.originalEnd - 0.05); updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd); } else { const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta); const clampedEnd = Math.max(0, newEnd); updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd); } }, [pixelsPerSecond, updateSegmentTiming], ); const handlePointerUp = useCallback(() => { dragRef.current = null; }, []); const handleBlockClick = useCallback( (e: React.MouseEvent, segId: string) => { e.stopPropagation(); selectSegment(segId, e.shiftKey); }, [selectSegment], ); const handleBlockDoubleClick = useCallback( (e: React.MouseEvent, groupId: string, segId: string) => { e.stopPropagation(); splitGroup(groupId, segId); }, [splitGroup], ); const handleTrackClick = useCallback( (e: React.MouseEvent) => { if (!onSeek) return; const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const x = e.clientX - rect.left - 32; const time = Math.max(0, x / pixelsPerSecond); onSeek(time); }, [onSeek, pixelsPerSecond], ); if (!model) return null; return (
{model.groupOrder.map((groupId, groupIdx) => { const group = model.groups.get(groupId); if (!group) return null; const color = GROUP_COLORS[groupIdx % GROUP_COLORS.length]; return group.segmentIds.map((segId) => { const seg = model.segments.get(segId); if (!seg) return null; const left = 32 + seg.start * pixelsPerSecond; const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4); const isSelected = selectedSegmentIds.has(segId); return (
handleBlockClick(e, segId)} onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)} > {/* Left edge drag handle */}
handleEdgePointerDown(e, segId, "start", seg.start, seg.end)} /> {/* Text label */} {seg.text} {/* Right edge drag handle */}
handleEdgePointerDown(e, segId, "end", seg.start, seg.end)} />
); }); })}
); });