diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-08-28 23:59:40 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-08-28 23:59:40 +0200 |
| commit | 886cf81bd0078f8d70f89fa5290052f5b23a9a12 (patch) | |
| tree | 085e1f56431fc062ac7154bd06a2744bf1e47b4a /apps/web/src/hooks | |
| parent | a7d90f7eac90966920c750134d7de9b611b2c38f (diff) | |
| download | opencut-886cf81bd0078f8d70f89fa5290052f5b23a9a12.tar.gz | |
feat: timeline auto-scroll for moving clips
Diffstat (limited to 'apps/web/src/hooks')
| -rw-r--r-- | apps/web/src/hooks/use-edge-auto-scroll.ts | 101 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-playhead.ts | 80 |
2 files changed, 112 insertions, 69 deletions
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<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ 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<number | null>(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<number | null>(null); const lastMouseXRef = useRef<number>(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) --- |
