diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 49769989..c994d707 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -878,6 +878,8 @@ export function Timeline() { track={track} zoomLevel={zoomLevel} onSnapPointChange={handleSnapPointChange} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} /> diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index ad3ca9b7..42c78aa4 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -24,15 +24,20 @@ import { } from "@/constants/timeline-constants"; import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; +import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll"; export function TimelineTrackContent({ track, zoomLevel, onSnapPointChange, + rulerScrollRef, + tracksScrollRef, }: { track: TimelineTrack; zoomLevel: number; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; }) { const { mediaFiles } = useMediaStore(); const { @@ -54,7 +59,7 @@ export function TimelineTrackContent({ rippleEditingEnabled, } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); + const { currentTime, duration } = usePlaybackStore(); // Initialize snapping hook const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ @@ -126,12 +131,15 @@ export function TimelineTrackContent({ y: number; } | null>(null); + const lastMouseXRef = useRef(0); + // Set up mouse event listeners for drag useEffect(() => { if (!dragState.isDragging) return; const handleMouseMove = (e: MouseEvent) => { if (!timelineRef.current) return; + lastMouseXRef.current = e.clientX; // On first mouse move during drag, ensure the element is selected if (dragState.elementId && dragState.trackId) { @@ -402,6 +410,14 @@ export function TimelineTrackContent({ onSnapPointChange, ]); + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); + const handleElementMouseDown = ( e: React.MouseEvent, element: TimelineElementType diff --git a/apps/web/src/hooks/use-edge-auto-scroll.ts b/apps/web/src/hooks/use-edge-auto-scroll.ts new file mode 100644 index 00000000..d3cf8127 --- /dev/null +++ b/apps/web/src/hooks/use-edge-auto-scroll.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; + +interface UseEdgeAutoScrollParams { + isActive: boolean; + getMouseClientX: () => number; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + contentWidth: number; + edgeThreshold?: number; + maxScrollSpeed?: number; +} + +// Provides smooth edge auto-scrolling for horizontal timeline interactions. +export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, +}: UseEdgeAutoScrollParams): void { + const rafRef = useRef(null); + + useEffect(() => { + if (!isActive) { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + const step = () => { + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) { + rafRef.current = requestAnimationFrame(step); + return; + } + + const viewportRect = rulerViewport.getBoundingClientRect(); + const mouseX = getMouseClientX(); + const mouseXRelative = mouseX - viewportRect.left; + + const viewportWidth = rulerViewport.clientWidth; + const intrinsicContentWidth = rulerViewport.scrollWidth; + const effectiveContentWidth = Math.max( + contentWidth, + intrinsicContentWidth + ); + const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth); + + let scrollSpeed = 0; + + if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) { + const edgeDistance = Math.max(0, mouseXRelative); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = -maxScrollSpeed * intensity; + } else if ( + mouseXRelative > viewportWidth - edgeThreshold && + rulerViewport.scrollLeft < scrollMax + ) { + const edgeDistance = Math.max( + 0, + viewportWidth - edgeThreshold - mouseXRelative + ); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = maxScrollSpeed * intensity; + } + + if (scrollSpeed !== 0) { + const newScrollLeft = Math.max( + 0, + Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed) + ); + rulerViewport.scrollLeft = newScrollLeft; + tracksViewport.scrollLeft = newScrollLeft; + } + + rafRef.current = requestAnimationFrame(step); + }; + + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold, + maxScrollSpeed, + ]); +} diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 97086abb..57ce085a 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -2,6 +2,7 @@ import { snapTimeToFrame } from "@/constants/timeline-constants"; import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useState, useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll"; interface UseTimelinePlayheadProps { currentTime: number; @@ -31,9 +32,6 @@ export function useTimelinePlayhead({ // Ruler drag detection state const [isDraggingRuler, setIsDraggingRuler] = useState(false); const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - - // Auto-scroll state during dragging - const autoScrollRef = useRef(null); const lastMouseXRef = useRef(0); const playheadPosition = @@ -117,59 +115,13 @@ export function useTimelinePlayhead({ [duration, zoomLevel, seek, rulerRef] ); - // Auto-scroll function during dragging - const performAutoScroll = useCallback(() => { - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - - if (!rulerViewport || !tracksViewport || !isScrubbing) return; - - const viewportRect = rulerViewport.getBoundingClientRect(); - const mouseX = lastMouseXRef.current; - const mouseXRelative = mouseX - viewportRect.left; - - const edgeThreshold = 100; // pixels from edge to start scrolling - const maxScrollSpeed = 15; // max pixels per frame - const viewportWidth = rulerViewport.clientWidth; - - // Calculate timeline content boundaries - const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 - const scrollMax = Math.max(0, timelineContentWidth - viewportWidth); - - let scrollSpeed = 0; - - // Check if near left edge (and can scroll left) - if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) { - const edgeDistance = Math.max(0, mouseXRelative); - const intensity = 1 - edgeDistance / edgeThreshold; - scrollSpeed = -maxScrollSpeed * intensity; - } - // Check if near right edge (and can scroll right, and haven't reached timeline end) - else if ( - mouseXRelative > viewportWidth - edgeThreshold && - rulerViewport.scrollLeft < scrollMax - ) { - const edgeDistance = Math.max( - 0, - viewportWidth - edgeThreshold - mouseXRelative - ); - const intensity = 1 - edgeDistance / edgeThreshold; - scrollSpeed = maxScrollSpeed * intensity; - } - - if (scrollSpeed !== 0) { - const newScrollLeft = Math.max( - 0, - Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed) - ); - rulerViewport.scrollLeft = newScrollLeft; - tracksViewport.scrollLeft = newScrollLeft; - } - - if (isScrubbing) { - autoScrollRef.current = requestAnimationFrame(performAutoScroll); - } - }, [isScrubbing, rulerScrollRef, tracksScrollRef, duration, zoomLevel]); + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => lastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * 50 * zoomLevel, + }); // Mouse move/up event handlers useEffect(() => { @@ -188,12 +140,6 @@ export function useTimelinePlayhead({ if (scrubTime !== null) seek(scrubTime); // finalize seek setScrubTime(null); - // Stop auto-scrolling - if (autoScrollRef.current) { - cancelAnimationFrame(autoScrollRef.current); - autoScrollRef.current = null; - } - // Handle ruler click vs drag if (isDraggingRuler) { setIsDraggingRuler(false); @@ -208,16 +154,12 @@ export function useTimelinePlayhead({ window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); - // Start auto-scrolling - autoScrollRef.current = requestAnimationFrame(performAutoScroll); + // Edge auto-scroll is handled by useEdgeAutoScroll return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); - if (autoScrollRef.current) { - cancelAnimationFrame(autoScrollRef.current); - autoScrollRef.current = null; - } + // nothing to cleanup for edge auto scroll }; }, [ isScrubbing, @@ -226,7 +168,7 @@ export function useTimelinePlayhead({ handleScrub, isDraggingRuler, hasDraggedRuler, - performAutoScroll, + // edge auto scroll hook is independent ]); // --- Playhead auto-scroll effect (only during playback) ---