summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-28 23:59:40 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-28 23:59:40 +0200
commit886cf81bd0078f8d70f89fa5290052f5b23a9a12 (patch)
tree085e1f56431fc062ac7154bd06a2744bf1e47b4a /apps/web/src/hooks
parenta7d90f7eac90966920c750134d7de9b611b2c38f (diff)
downloadopencut-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.ts101
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts80
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) ---