feat: lock playhead to frame (instead of floating) and add autoscroll to timeline

This commit is contained in:
Maze Winther 2025-07-26 22:25:57 +02:00
parent 906f95399d
commit d154937ac2
4 changed files with 237 additions and 25 deletions

View File

@ -3,6 +3,7 @@
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
import { useState, useEffect } from "react";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
@ -11,6 +12,7 @@ interface SnapIndicatorProps {
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}
export function SnapIndicator({
@ -20,7 +22,29 @@ export function SnapIndicator({
tracks,
timelineRef,
trackLabelsRef,
tracksScrollRef,
}: SnapIndicatorProps) {
const [scrollLeft, setScrollLeft] = useState(0);
// Track scroll position to lock snap indicator to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!tracksViewport) return;
const handleScroll = () => {
setScrollLeft(tracksViewport.scrollLeft);
};
// Set initial scroll position
setScrollLeft(tracksViewport.scrollLeft);
tracksViewport.addEventListener("scroll", handleScroll);
return () => tracksViewport.removeEventListener("scroll", handleScroll);
}, [tracksScrollRef]);
if (!isVisible || !snapPoint) {
return null;
}
@ -34,9 +58,10 @@ export function SnapIndicator({
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
// Calculate position locked to timeline content (accounting for scroll)
const timelinePosition =
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return (
<div

View File

@ -537,12 +537,13 @@ export function Timeline() {
tracks={tracks}
timelineRef={timelineRef}
trackLabelsRef={trackLabelsRef}
tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
{/* Track Labels Header */}
<div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
<div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
{/* Empty space */}
<span className="text-sm font-medium text-muted-foreground opacity-0">
.
@ -651,7 +652,7 @@ export function Timeline() {
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel"
data-track-labels
>
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>

View File

@ -1,11 +1,8 @@
"use client";
import { useRef } from "react";
import { useRef, useState, useEffect } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
@ -39,6 +36,8 @@ export function TimelinePlayhead({
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const [scrollLeft, setScrollLeft] = useState(0);
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
@ -50,6 +49,25 @@ export function TimelinePlayhead({
playheadRef,
});
// Track scroll position to lock playhead to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!tracksViewport) return;
const handleScroll = () => {
setScrollLeft(tracksViewport.scrollLeft);
};
// Set initial scroll position
setScrollLeft(tracksViewport.scrollLeft);
tracksViewport.addEventListener("scroll", handleScroll);
return () => tracksViewport.removeEventListener("scroll", handleScroll);
}, [tracksScrollRef]);
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
@ -59,9 +77,51 @@ export function TimelinePlayhead({
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
// Calculate position locked to timeline content (accounting for scroll)
const timelinePosition =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
// Get the timeline content width and viewport width for right boundary
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const viewportWidth = tracksViewport?.clientWidth || 1000;
// Constrain playhead to never appear outside the timeline area
const leftBoundary = trackLabelsWidth;
const rightBoundary = Math.min(
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
trackLabelsWidth + viewportWidth // Don't go beyond viewport
);
const leftPosition = Math.max(
leftBoundary,
Math.min(rightBoundary, rawLeftPosition)
);
// Debug logging when playhead might go outside
if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
console.log(
"PLAYHEAD VISUAL DEBUG:",
JSON.stringify({
playheadPosition,
timelinePosition,
trackLabelsWidth,
scrollLeft,
rawLeftPosition,
constrainedLeftPosition: leftPosition,
leftBoundary,
rightBoundary,
timelineContentWidth,
viewportWidth,
zoomLevel,
})
);
}
return (
<div
@ -72,7 +132,6 @@ export function TimelinePlayhead({
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
zIndex: 100,
}}
onMouseDown={handlePlayheadMouseDown}
>

View File

@ -1,6 +1,7 @@
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
import { useState, useEffect, useCallback, useRef } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
@ -31,6 +32,10 @@ export function useTimelinePlayhead({
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 =
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
@ -70,21 +75,110 @@ export function useTimelinePlayhead({
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left;
const rawX = e.clientX - rect.left;
// Get the timeline content width based on duration and zoom
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
// Constrain x to be within the timeline content bounds
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
// Use frame snapping for playhead scrubbing
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
// Debug logging
if (rawX < 0 || x !== rawX) {
console.log(
"PLAYHEAD DEBUG:",
JSON.stringify({
mouseX: e.clientX,
rulerLeft: rect.left,
rawX,
constrainedX: x,
timelineContentWidth,
rawTime,
finalTime: time,
duration,
zoomLevel,
playheadPx: time * 50 * zoomLevel,
})
);
}
setScrubTime(time);
seek(time); // update video preview in real time
// Store mouse position for auto-scrolling
lastMouseXRef.current = e.clientX;
},
[duration, zoomLevel, seek, rulerRef]
);
// Auto-scroll function during dragging
const performAutoScroll = useCallback(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
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]);
// Mouse move/up event handlers
useEffect(() => {
if (!isScrubbing) return;
const onMouseMove = (e: MouseEvent) => {
handleScrub(e);
// Mark that we've dragged if ruler drag is active
@ -92,11 +186,18 @@ export function useTimelinePlayhead({
setHasDraggedRuler(true);
}
};
const onMouseUp = (e: MouseEvent) => {
setIsScrubbing(false);
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);
@ -107,11 +208,20 @@ export function useTimelinePlayhead({
setHasDraggedRuler(false);
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
// Start auto-scrolling
autoScrollRef.current = requestAnimationFrame(performAutoScroll);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
if (autoScrollRef.current) {
cancelAnimationFrame(autoScrollRef.current);
autoScrollRef.current = null;
}
};
}, [
isScrubbing,
@ -120,10 +230,16 @@ export function useTimelinePlayhead({
handleScrub,
isDraggingRuler,
hasDraggedRuler,
performAutoScroll,
]);
// --- Playhead auto-scroll effect ---
// --- Playhead auto-scroll effect (only during playback) ---
useEffect(() => {
const { isPlaying } = usePlaybackStore.getState();
// Only auto-scroll during playback, not during manual interactions
if (!isPlaying || isScrubbing) return;
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
@ -131,22 +247,33 @@ export function useTimelinePlayhead({
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
// Center the playhead if it's not visible (100px buffer)
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2)
);
if (
playheadPx < rulerViewport.scrollLeft + 100 ||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
) {
// Only auto-scroll if playhead is completely out of view (no buffer)
const needsScroll =
playheadPx < rulerViewport.scrollLeft ||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
if (needsScroll) {
// Center the playhead in the viewport
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2)
);
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
}
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
}, [
playheadPosition,
duration,
zoomLevel,
rulerScrollRef,
tracksScrollRef,
isScrubbing,
]);
return {
playheadPosition,