feat: timeline auto-scroll for moving clips
This commit is contained in:
parent
a7d90f7eac
commit
886cf81bd0
|
|
@ -878,6 +878,8 @@ export function Timeline() {
|
|||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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) ---
|
||||
|
|
|
|||
Loading…
Reference in New Issue