omniasr-transcriptions / frontend /src /hooks /useTimelineDragControls.ts
jeanma's picture
Omnilingual ASR transcription demo
ae238b3 verified
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,
};
};