import { useState, useCallback, useEffect } from 'react'; import { AlignedSegment } from '../services/transcriptionApi'; import { SegmentWithTrack } from '../utils/trackUtils'; import { useTranscriptionStore } from '../stores/transcriptionStore'; interface UseTimelineDragControlsOptions { segmentsWithTracks: SegmentWithTrack[]; displaySegments: AlignedSegment[]; geometryUtils: { timeToX: (time: number) => number; trackToY: (track: number) => number; canvasXToTime: (canvasX: number) => number; clientXToCanvasX: (clientX: number, canvasRef: React.RefObject) => number; timelineWidth: number; }; canvasRef: React.RefObject; containerRef: React.RefObject; mediaDuration: number; constants: { TRACK_HEIGHT: number; TIMELINE_PADDING: number; }; } type DragType = 'move' | 'resize-start' | 'resize-end'; export const useTimelineDragControls = ({ segmentsWithTracks, displaySegments, geometryUtils, canvasRef, containerRef, mediaDuration, constants, }: UseTimelineDragControlsOptions) => { const { TRACK_HEIGHT, TIMELINE_PADDING } = constants; const { timeToX, trackToY, canvasXToTime, clientXToCanvasX, timelineWidth } = geometryUtils; // Get store actions const { seekToTime, selectedSegmentIndex, setSelectedSegmentIndex, updateSegmentTiming, finalizeSegmentPositioning, } = useTranscriptionStore(); // Drag state const [isDragging, setIsDragging] = useState(false); const [isTimelineDragging, setIsTimelineDragging] = useState(false); const [dragType, setDragType] = useState(null); const [dragStartX, setDragStartX] = useState(0); const [dragStartTime, setDragStartTime] = useState(0); const [dragSegmentIndex, setDragSegmentIndex] = useState(null); const [hoveredSegment, setHoveredSegment] = useState(null); // Find segment at a specific time const findSegmentAtTime = useCallback((time: number) => { for (let i = 0; i < displaySegments.length; i++) { const segment = displaySegments[i]; if (time >= segment.start && time <= segment.end) { return i; } } return null; }, [displaySegments]); // Seek to position using centralized store function const seekToPosition = useCallback((clientX: number) => { const actualCanvasX = clientXToCanvasX(clientX, canvasRef); const clickTime = canvasXToTime(actualCanvasX); const clampedTime = Math.max(0, Math.min(clickTime, mediaDuration)); seekToTime(clampedTime); // Auto-select segment at the current time position const segmentAtTime = findSegmentAtTime(clampedTime); if (segmentAtTime !== null) { setSelectedSegmentIndex(segmentAtTime); } }, [clientXToCanvasX, canvasRef, canvasXToTime, mediaDuration, seekToTime, findSegmentAtTime, setSelectedSegmentIndex]); // Find segment at position const findSegmentAtPosition = useCallback((canvasX: number, canvasY: number) => { for (let i = 0; i < segmentsWithTracks.length; i++) { const segment = segmentsWithTracks[i]; const segmentX = timeToX(segment.start); // Use actual time-based width for hit detection, with minimum 8px for very short segments const actualWidth = timeToX(segment.end) - segmentX; const segmentWidth = Math.max(actualWidth, 8); const segmentY = trackToY(segment.track); if (canvasX >= segmentX && canvasX <= segmentX + segmentWidth && canvasY >= segmentY && canvasY <= segmentY + TRACK_HEIGHT) { // Find the original segment index in displaySegments const originalIndex = displaySegments.findIndex(s => s.start === segment.start && s.end === segment.end && s.text === segment.text ); return { index: originalIndex, segment }; } } return null; }, [segmentsWithTracks, displaySegments, timeToX, trackToY, TRACK_HEIGHT]); // Check if position is on resize handle const getResizeHandle = useCallback((canvasX: number, canvasY: number, segmentIndex: number) => { if (selectedSegmentIndex !== segmentIndex) return null; // Find the segment in segmentsWithTracks that corresponds to the original segment index const originalSegment = displaySegments[segmentIndex]; const segmentWithTrack = segmentsWithTracks.find(s => s.start === originalSegment.start && s.end === originalSegment.end && s.text === originalSegment.text ); if (!segmentWithTrack) return null; const segmentX = timeToX(segmentWithTrack.start); // Use actual time-based width for resize handle detection, with minimum for very short segments const actualWidth = timeToX(segmentWithTrack.end) - segmentX; const segmentWidth = Math.max(actualWidth, 16); // Minimum 16px to ensure handles are accessible const segmentY = trackToY(segmentWithTrack.track); const handleWidth = 8; if (canvasX >= segmentX && canvasX <= segmentX + handleWidth && canvasY >= segmentY && canvasY <= segmentY + TRACK_HEIGHT) { return 'resize-start'; } if (canvasX >= segmentX + segmentWidth - handleWidth && canvasX <= segmentX + segmentWidth && canvasY >= segmentY && canvasY <= segmentY + TRACK_HEIGHT) { return 'resize-end'; } return null; }, [selectedSegmentIndex, segmentsWithTracks, displaySegments, timeToX, trackToY, TRACK_HEIGHT]); // Edge-based auto-scroll during user interactions const handleEdgeScroll = useCallback((clientX: number) => { const container = containerRef.current; if (!container) return; // Use container's bounding rect to get the visible viewport area const containerRect = container.getBoundingClientRect(); const containerWidth = container.clientWidth; const edgeThreshold = 80; // Increased from 50px to 80px for better UX const scrollSpeed = 8; // Slightly reduced for smoother scrolling // Calculate mouse position relative to the visible container viewport const mouseX = clientX - containerRect.left; // Check if mouse is near the edges or outside the container const isLeftOfContainer = mouseX < 0; const isRightOfContainer = mouseX > containerWidth; const isNearLeftEdge = mouseX > 20 && mouseX < edgeThreshold; // Start 20px from edge, trigger within 80px const isNearRightEdge = mouseX > containerWidth - edgeThreshold && mouseX < containerWidth - 20; // End 20px from edge // Scroll left if near left edge or dragging to the left of container if ((isNearLeftEdge || isLeftOfContainer) && container.scrollLeft > 0) { let adjustedScrollSpeed = scrollSpeed; if (isLeftOfContainer) { // Faster scrolling when outside container const distanceOutside = Math.abs(mouseX); adjustedScrollSpeed = Math.min(scrollSpeed * 2, scrollSpeed + distanceOutside * 0.1); } else { // Variable speed based on distance from edge when inside const distanceFromEdge = mouseX - 20; const scrollMultiplier = 1 - (distanceFromEdge / (edgeThreshold - 20)); adjustedScrollSpeed = Math.max(2, scrollSpeed * scrollMultiplier); } container.scrollLeft = Math.max(0, container.scrollLeft - adjustedScrollSpeed); } // Scroll right if near right edge or dragging to the right of container else if (isNearRightEdge || isRightOfContainer) { let adjustedScrollSpeed = scrollSpeed; if (isRightOfContainer) { // Faster scrolling when outside container const distanceOutside = mouseX - containerWidth; adjustedScrollSpeed = Math.min(scrollSpeed * 2, scrollSpeed + distanceOutside * 0.1); } else { // Variable speed based on distance from edge when inside const distanceFromEdge = (containerWidth - 20) - mouseX; const scrollMultiplier = 1 - (distanceFromEdge / (edgeThreshold - 20)); adjustedScrollSpeed = Math.max(2, scrollSpeed * scrollMultiplier); } const maxScrollLeft = Math.max(0, timelineWidth - containerWidth); container.scrollLeft = Math.min(maxScrollLeft, container.scrollLeft + adjustedScrollSpeed); } }, [timelineWidth, containerRef]); // Handle mouse events const handleMouseMove = useCallback((event: React.MouseEvent) => { // Completely disable hover detection and cursor updates during any drag operation if (isDragging || isTimelineDragging) { return; } const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const actualCanvasX = clientXToCanvasX(event.clientX, canvasRef); const y = event.clientY - rect.top; // Find hovered segment const foundSegment = findSegmentAtPosition(actualCanvasX, y); setHoveredSegment(foundSegment?.index ?? null); // Update cursor if (canvas) { let cursor = 'default'; if (foundSegment) { const resizeHandle = getResizeHandle(actualCanvasX, y, foundSegment.index); if (resizeHandle) { cursor = 'ew-resize'; } else { cursor = 'move'; } } canvas.style.cursor = cursor; } }, [isDragging, isTimelineDragging, findSegmentAtPosition, getResizeHandle, clientXToCanvasX, canvasRef]); const handleMouseDown = useCallback((event: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const actualCanvasX = clientXToCanvasX(event.clientX, canvasRef); const y = event.clientY - rect.top; // Check if clicking on a segment const foundSegment = findSegmentAtPosition(actualCanvasX, y); if (foundSegment) { // Check for resize handles first const resizeHandle = getResizeHandle(actualCanvasX, y, foundSegment.index); if (resizeHandle) { // Start resize drag event.preventDefault(); setIsDragging(true); setDragType(resizeHandle as DragType); setDragStartX(event.clientX); setDragSegmentIndex(foundSegment.index); // Only set selected segment if it's not already selected // This prevents changing selection during drag operations if (selectedSegmentIndex !== foundSegment.index) { setSelectedSegmentIndex(foundSegment.index); } const segment = foundSegment.segment; setDragStartTime(resizeHandle === 'resize-end' ? segment.end : segment.start); // Update media time using centralized store function seekToTime(resizeHandle === 'resize-end' ? segment.end : segment.start); } else { // Start move drag event.preventDefault(); setIsDragging(true); setDragType('move'); setDragStartX(event.clientX); setDragSegmentIndex(foundSegment.index); // Only set selected segment if it's not already selected // This prevents changing selection during drag operations if (selectedSegmentIndex !== foundSegment.index) { setSelectedSegmentIndex(foundSegment.index); } setDragStartTime(foundSegment.segment.start); // Update media time to mouse position using centralized store function const clickTime = canvasXToTime(actualCanvasX); seekToTime(clickTime); } } else { // Clicking outside of any segment - deselect the selected segment if (selectedSegmentIndex !== null) { setSelectedSegmentIndex(null); } // Timeline click - start timeline drag event.preventDefault(); setIsTimelineDragging(true); seekToPosition(event.clientX); } }, [ findSegmentAtPosition, getResizeHandle, selectedSegmentIndex, setSelectedSegmentIndex, seekToPosition, canvasXToTime, clientXToCanvasX, canvasRef, seekToTime ]); // Global mouse move handler useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { // Handle edge-based scrolling during any drag operation if (isDragging || isTimelineDragging) { handleEdgeScroll(e.clientX); // Clear hover state during drag operations to prevent visual confusion if (hoveredSegment !== null) { setHoveredSegment(null); } } if (isDragging && dragType && dragSegmentIndex !== null) { const deltaX = e.clientX - dragStartX; const timelineWidthPx = timelineWidth - TIMELINE_PADDING * 2; const deltaTime = (deltaX / timelineWidthPx) * mediaDuration; const segment = displaySegments[dragSegmentIndex]; let newStart = segment.start; let newEnd = segment.end; switch (dragType) { case 'move': const newStartTime = Math.max(0, dragStartTime + deltaTime); const duration = segment.end - segment.start; newStart = Math.min(newStartTime, mediaDuration - duration); newEnd = newStart + duration; // Update media time to follow mouse using centralized store function const actualCanvasX = clientXToCanvasX(e.clientX, canvasRef); const mouseTime = canvasXToTime(actualCanvasX); seekToTime(mouseTime); break; case 'resize-start': newStart = Math.max(0, Math.min(dragStartTime + deltaTime, segment.end - 0.1)); seekToTime(newStart); break; case 'resize-end': newEnd = Math.min(mediaDuration, Math.max(dragStartTime + deltaTime, segment.start + 0.1)); seekToTime(newEnd); break; } updateSegmentTiming(dragSegmentIndex, newStart, newEnd, true); // deferSorting=true during drag } else if (isTimelineDragging) { seekToPosition(e.clientX); } }; const handleGlobalMouseUp = () => { // If we were dragging a segment, finalize its positioning (re-sort segments) if (isDragging && dragSegmentIndex !== null) { finalizeSegmentPositioning(); } setIsDragging(false); setIsTimelineDragging(false); setDragType(null); setDragSegmentIndex(null); }; if (isDragging || isTimelineDragging) { document.addEventListener('mousemove', handleGlobalMouseMove); document.addEventListener('mouseup', handleGlobalMouseUp); return () => { document.removeEventListener('mousemove', handleGlobalMouseMove); document.removeEventListener('mouseup', handleGlobalMouseUp); }; } }, [ isDragging, isTimelineDragging, dragType, dragSegmentIndex, dragStartX, dragStartTime, displaySegments, mediaDuration, timelineWidth, TIMELINE_PADDING, updateSegmentTiming, seekToPosition, canvasXToTime, clientXToCanvasX, canvasRef, seekToTime, handleEdgeScroll, hoveredSegment ]); return { // State isDragging, isTimelineDragging, dragSegmentIndex, hoveredSegment, // Actions seekToPosition, findSegmentAtTime, // Mouse handlers handleMouseMove, handleMouseDown, // Utilities findSegmentAtPosition, getResizeHandle, }; };