Skip to content

Commit 454a308

Browse files
committed
Merge branch 'staging'
2 parents 2d834a4 + 886cf81 commit 454a308

File tree

4 files changed

+131
-70
lines changed

4 files changed

+131
-70
lines changed

apps/web/src/components/editor/timeline/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,8 @@ export function Timeline() {
878878
track={track}
879879
zoomLevel={zoomLevel}
880880
onSnapPointChange={handleSnapPointChange}
881+
rulerScrollRef={rulerScrollRef}
882+
tracksScrollRef={tracksScrollRef}
881883
/>
882884
</div>
883885
</ContextMenuTrigger>

apps/web/src/components/editor/timeline/timeline-track.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,20 @@ import {
2424
} from "@/constants/timeline-constants";
2525
import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
2626
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
27+
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
2728

2829
export function TimelineTrackContent({
2930
track,
3031
zoomLevel,
3132
onSnapPointChange,
33+
rulerScrollRef,
34+
tracksScrollRef,
3235
}: {
3336
track: TimelineTrack;
3437
zoomLevel: number;
3538
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
39+
rulerScrollRef: React.RefObject<HTMLDivElement>;
40+
tracksScrollRef: React.RefObject<HTMLDivElement>;
3641
}) {
3742
const { mediaFiles } = useMediaStore();
3843
const {
@@ -54,7 +59,7 @@ export function TimelineTrackContent({
5459
rippleEditingEnabled,
5560
} = useTimelineStore();
5661

57-
const { currentTime } = usePlaybackStore();
62+
const { currentTime, duration } = usePlaybackStore();
5863

5964
// Initialize snapping hook
6065
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
@@ -126,12 +131,15 @@ export function TimelineTrackContent({
126131
y: number;
127132
} | null>(null);
128133

134+
const lastMouseXRef = useRef(0);
135+
129136
// Set up mouse event listeners for drag
130137
useEffect(() => {
131138
if (!dragState.isDragging) return;
132139

133140
const handleMouseMove = (e: MouseEvent) => {
134141
if (!timelineRef.current) return;
142+
lastMouseXRef.current = e.clientX;
135143

136144
// On first mouse move during drag, ensure the element is selected
137145
if (dragState.elementId && dragState.trackId) {
@@ -402,6 +410,14 @@ export function TimelineTrackContent({
402410
onSnapPointChange,
403411
]);
404412

413+
useEdgeAutoScroll({
414+
isActive: dragState.isDragging,
415+
getMouseClientX: () => lastMouseXRef.current,
416+
rulerScrollRef,
417+
tracksScrollRef,
418+
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
419+
});
420+
405421
const handleElementMouseDown = (
406422
e: React.MouseEvent,
407423
element: TimelineElementType
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useEffect, useRef } from "react";
2+
3+
interface UseEdgeAutoScrollParams {
4+
isActive: boolean;
5+
getMouseClientX: () => number;
6+
rulerScrollRef: React.RefObject<HTMLDivElement>;
7+
tracksScrollRef: React.RefObject<HTMLDivElement>;
8+
contentWidth: number;
9+
edgeThreshold?: number;
10+
maxScrollSpeed?: number;
11+
}
12+
13+
// Provides smooth edge auto-scrolling for horizontal timeline interactions.
14+
export function useEdgeAutoScroll({
15+
isActive,
16+
getMouseClientX,
17+
rulerScrollRef,
18+
tracksScrollRef,
19+
contentWidth,
20+
edgeThreshold = 100,
21+
maxScrollSpeed = 15,
22+
}: UseEdgeAutoScrollParams): void {
23+
const rafRef = useRef<number | null>(null);
24+
25+
useEffect(() => {
26+
if (!isActive) {
27+
if (rafRef.current) {
28+
cancelAnimationFrame(rafRef.current);
29+
rafRef.current = null;
30+
}
31+
return;
32+
}
33+
34+
const step = () => {
35+
const rulerViewport = rulerScrollRef.current;
36+
const tracksViewport = tracksScrollRef.current;
37+
if (!rulerViewport || !tracksViewport) {
38+
rafRef.current = requestAnimationFrame(step);
39+
return;
40+
}
41+
42+
const viewportRect = rulerViewport.getBoundingClientRect();
43+
const mouseX = getMouseClientX();
44+
const mouseXRelative = mouseX - viewportRect.left;
45+
46+
const viewportWidth = rulerViewport.clientWidth;
47+
const intrinsicContentWidth = rulerViewport.scrollWidth;
48+
const effectiveContentWidth = Math.max(
49+
contentWidth,
50+
intrinsicContentWidth
51+
);
52+
const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
53+
54+
let scrollSpeed = 0;
55+
56+
if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
57+
const edgeDistance = Math.max(0, mouseXRelative);
58+
const intensity = 1 - edgeDistance / edgeThreshold;
59+
scrollSpeed = -maxScrollSpeed * intensity;
60+
} else if (
61+
mouseXRelative > viewportWidth - edgeThreshold &&
62+
rulerViewport.scrollLeft < scrollMax
63+
) {
64+
const edgeDistance = Math.max(
65+
0,
66+
viewportWidth - edgeThreshold - mouseXRelative
67+
);
68+
const intensity = 1 - edgeDistance / edgeThreshold;
69+
scrollSpeed = maxScrollSpeed * intensity;
70+
}
71+
72+
if (scrollSpeed !== 0) {
73+
const newScrollLeft = Math.max(
74+
0,
75+
Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
76+
);
77+
rulerViewport.scrollLeft = newScrollLeft;
78+
tracksViewport.scrollLeft = newScrollLeft;
79+
}
80+
81+
rafRef.current = requestAnimationFrame(step);
82+
};
83+
84+
rafRef.current = requestAnimationFrame(step);
85+
86+
return () => {
87+
if (rafRef.current) {
88+
cancelAnimationFrame(rafRef.current);
89+
rafRef.current = null;
90+
}
91+
};
92+
}, [
93+
isActive,
94+
getMouseClientX,
95+
rulerScrollRef,
96+
tracksScrollRef,
97+
contentWidth,
98+
edgeThreshold,
99+
maxScrollSpeed,
100+
]);
101+
}

apps/web/src/hooks/use-timeline-playhead.ts

Lines changed: 11 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { snapTimeToFrame } from "@/constants/timeline-constants";
22
import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
33
import { usePlaybackStore } from "@/stores/playback-store";
44
import { useState, useEffect, useCallback, useRef } from "react";
5+
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
56

67
interface UseTimelinePlayheadProps {
78
currentTime: number;
@@ -31,9 +32,6 @@ export function useTimelinePlayhead({
3132
// Ruler drag detection state
3233
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
3334
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
34-
35-
// Auto-scroll state during dragging
36-
const autoScrollRef = useRef<number | null>(null);
3735
const lastMouseXRef = useRef<number>(0);
3836

3937
const playheadPosition =
@@ -117,59 +115,13 @@ export function useTimelinePlayhead({
117115
[duration, zoomLevel, seek, rulerRef]
118116
);
119117

120-
// Auto-scroll function during dragging
121-
const performAutoScroll = useCallback(() => {
122-
const rulerViewport = rulerScrollRef.current;
123-
const tracksViewport = tracksScrollRef.current;
124-
125-
if (!rulerViewport || !tracksViewport || !isScrubbing) return;
126-
127-
const viewportRect = rulerViewport.getBoundingClientRect();
128-
const mouseX = lastMouseXRef.current;
129-
const mouseXRelative = mouseX - viewportRect.left;
130-
131-
const edgeThreshold = 100; // pixels from edge to start scrolling
132-
const maxScrollSpeed = 15; // max pixels per frame
133-
const viewportWidth = rulerViewport.clientWidth;
134-
135-
// Calculate timeline content boundaries
136-
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
137-
const scrollMax = Math.max(0, timelineContentWidth - viewportWidth);
138-
139-
let scrollSpeed = 0;
140-
141-
// Check if near left edge (and can scroll left)
142-
if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
143-
const edgeDistance = Math.max(0, mouseXRelative);
144-
const intensity = 1 - edgeDistance / edgeThreshold;
145-
scrollSpeed = -maxScrollSpeed * intensity;
146-
}
147-
// Check if near right edge (and can scroll right, and haven't reached timeline end)
148-
else if (
149-
mouseXRelative > viewportWidth - edgeThreshold &&
150-
rulerViewport.scrollLeft < scrollMax
151-
) {
152-
const edgeDistance = Math.max(
153-
0,
154-
viewportWidth - edgeThreshold - mouseXRelative
155-
);
156-
const intensity = 1 - edgeDistance / edgeThreshold;
157-
scrollSpeed = maxScrollSpeed * intensity;
158-
}
159-
160-
if (scrollSpeed !== 0) {
161-
const newScrollLeft = Math.max(
162-
0,
163-
Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
164-
);
165-
rulerViewport.scrollLeft = newScrollLeft;
166-
tracksViewport.scrollLeft = newScrollLeft;
167-
}
168-
169-
if (isScrubbing) {
170-
autoScrollRef.current = requestAnimationFrame(performAutoScroll);
171-
}
172-
}, [isScrubbing, rulerScrollRef, tracksScrollRef, duration, zoomLevel]);
118+
useEdgeAutoScroll({
119+
isActive: isScrubbing,
120+
getMouseClientX: () => lastMouseXRef.current,
121+
rulerScrollRef,
122+
tracksScrollRef,
123+
contentWidth: duration * 50 * zoomLevel,
124+
});
173125

174126
// Mouse move/up event handlers
175127
useEffect(() => {
@@ -188,12 +140,6 @@ export function useTimelinePlayhead({
188140
if (scrubTime !== null) seek(scrubTime); // finalize seek
189141
setScrubTime(null);
190142

191-
// Stop auto-scrolling
192-
if (autoScrollRef.current) {
193-
cancelAnimationFrame(autoScrollRef.current);
194-
autoScrollRef.current = null;
195-
}
196-
197143
// Handle ruler click vs drag
198144
if (isDraggingRuler) {
199145
setIsDraggingRuler(false);
@@ -208,16 +154,12 @@ export function useTimelinePlayhead({
208154
window.addEventListener("mousemove", onMouseMove);
209155
window.addEventListener("mouseup", onMouseUp);
210156

211-
// Start auto-scrolling
212-
autoScrollRef.current = requestAnimationFrame(performAutoScroll);
157+
// Edge auto-scroll is handled by useEdgeAutoScroll
213158

214159
return () => {
215160
window.removeEventListener("mousemove", onMouseMove);
216161
window.removeEventListener("mouseup", onMouseUp);
217-
if (autoScrollRef.current) {
218-
cancelAnimationFrame(autoScrollRef.current);
219-
autoScrollRef.current = null;
220-
}
162+
// nothing to cleanup for edge auto scroll
221163
};
222164
}, [
223165
isScrubbing,
@@ -226,7 +168,7 @@ export function useTimelinePlayhead({
226168
handleScrub,
227169
isDraggingRuler,
228170
hasDraggedRuler,
229-
performAutoScroll,
171+
// edge auto scroll hook is independent
230172
]);
231173

232174
// --- Playhead auto-scroll effect (only during playback) ---

0 commit comments

Comments
 (0)