mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: timeline auto-scroll for moving clips
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 { 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) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user