Spaces:
Running
on
A100
Running
on
A100
| 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<HTMLCanvasElement>) => number; | |
| timelineWidth: number; | |
| }; | |
| canvasRef: React.RefObject<HTMLCanvasElement>; | |
| containerRef: React.RefObject<HTMLDivElement>; | |
| 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<DragType | null>(null); | |
| const [dragStartX, setDragStartX] = useState(0); | |
| const [dragStartTime, setDragStartTime] = useState(0); | |
| const [dragSegmentIndex, setDragSegmentIndex] = useState<number | null>(null); | |
| const [hoveredSegment, setHoveredSegment] = useState<number | null>(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<HTMLCanvasElement>) => { | |
| // 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<HTMLCanvasElement>) => { | |
| 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, | |
| }; | |
| }; | |