feat: timeline auto-scroll for moving clips

This commit is contained in:
Maze Winther
2025-08-28 23:59:40 +02:00
parent a7d90f7eac
commit 886cf81bd0
4 changed files with 131 additions and 70 deletions
@@ -878,6 +878,8 @@ export function Timeline() {
track={track} track={track}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange} onSnapPointChange={handleSnapPointChange}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
/> />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -24,15 +24,20 @@ import {
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
export function TimelineTrackContent({ export function TimelineTrackContent({
track, track,
zoomLevel, zoomLevel,
onSnapPointChange, onSnapPointChange,
rulerScrollRef,
tracksScrollRef,
}: { }: {
track: TimelineTrack; track: TimelineTrack;
zoomLevel: number; zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}) { }) {
const { mediaFiles } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { const {
@@ -54,7 +59,7 @@ export function TimelineTrackContent({
rippleEditingEnabled, rippleEditingEnabled,
} = useTimelineStore(); } = useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime, duration } = usePlaybackStore();
// Initialize snapping hook // Initialize snapping hook
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
@@ -126,12 +131,15 @@ export function TimelineTrackContent({
y: number; y: number;
} | null>(null); } | null>(null);
const lastMouseXRef = useRef(0);
// Set up mouse event listeners for drag // Set up mouse event listeners for drag
useEffect(() => { useEffect(() => {
if (!dragState.isDragging) return; if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return; if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
// On first mouse move during drag, ensure the element is selected // On first mouse move during drag, ensure the element is selected
if (dragState.elementId && dragState.trackId) { if (dragState.elementId && dragState.trackId) {
@@ -402,6 +410,14 @@ export function TimelineTrackContent({
onSnapPointChange, onSnapPointChange,
]); ]);
useEdgeAutoScroll({
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const handleElementMouseDown = ( const handleElementMouseDown = (
e: React.MouseEvent, e: React.MouseEvent,
element: TimelineElementType element: TimelineElementType
+101
View File
@@ -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,
]);
}
+11 -69
View File
@@ -2,6 +2,7 @@ import { snapTimeToFrame } from "@/constants/timeline-constants";
import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
interface UseTimelinePlayheadProps { interface UseTimelinePlayheadProps {
currentTime: number; currentTime: number;
@@ -31,9 +32,6 @@ export function useTimelinePlayhead({
// Ruler drag detection state // Ruler drag detection state
const [isDraggingRuler, setIsDraggingRuler] = useState(false); const [isDraggingRuler, setIsDraggingRuler] = useState(false);
const [hasDraggedRuler, setHasDraggedRuler] = useState(false); const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
// Auto-scroll state during dragging
const autoScrollRef = useRef<number | null>(null);
const lastMouseXRef = useRef<number>(0); const lastMouseXRef = useRef<number>(0);
const playheadPosition = const playheadPosition =
@@ -117,59 +115,13 @@ export function useTimelinePlayhead({
[duration, zoomLevel, seek, rulerRef] [duration, zoomLevel, seek, rulerRef]
); );
// Auto-scroll function during dragging useEdgeAutoScroll({
const performAutoScroll = useCallback(() => { isActive: isScrubbing,
const rulerViewport = rulerScrollRef.current; getMouseClientX: () => lastMouseXRef.current,
const tracksViewport = tracksScrollRef.current; rulerScrollRef,
tracksScrollRef,
if (!rulerViewport || !tracksViewport || !isScrubbing) return; contentWidth: duration * 50 * zoomLevel,
});
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]);
// Mouse move/up event handlers // Mouse move/up event handlers
useEffect(() => { useEffect(() => {
@@ -188,12 +140,6 @@ export function useTimelinePlayhead({
if (scrubTime !== null) seek(scrubTime); // finalize seek if (scrubTime !== null) seek(scrubTime); // finalize seek
setScrubTime(null); setScrubTime(null);
// Stop auto-scrolling
if (autoScrollRef.current) {
cancelAnimationFrame(autoScrollRef.current);
autoScrollRef.current = null;
}
// Handle ruler click vs drag // Handle ruler click vs drag
if (isDraggingRuler) { if (isDraggingRuler) {
setIsDraggingRuler(false); setIsDraggingRuler(false);
@@ -208,16 +154,12 @@ export function useTimelinePlayhead({
window.addEventListener("mousemove", onMouseMove); window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp); window.addEventListener("mouseup", onMouseUp);
// Start auto-scrolling // Edge auto-scroll is handled by useEdgeAutoScroll
autoScrollRef.current = requestAnimationFrame(performAutoScroll);
return () => { return () => {
window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp); window.removeEventListener("mouseup", onMouseUp);
if (autoScrollRef.current) { // nothing to cleanup for edge auto scroll
cancelAnimationFrame(autoScrollRef.current);
autoScrollRef.current = null;
}
}; };
}, [ }, [
isScrubbing, isScrubbing,
@@ -226,7 +168,7 @@ export function useTimelinePlayhead({
handleScrub, handleScrub,
isDraggingRuler, isDraggingRuler,
hasDraggedRuler, hasDraggedRuler,
performAutoScroll, // edge auto scroll hook is independent
]); ]);
// --- Playhead auto-scroll effect (only during playback) --- // --- Playhead auto-scroll effect (only during playback) ---