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

View File

@ -878,6 +878,8 @@ export function Timeline() {
track={track}
zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
/>
</div>
</ContextMenuTrigger>

View File

@ -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<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}) {
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

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,
]);
}

View File

@ -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) ---