From d154937ac2fdf5877095c4a0f1076cba04d7c3d1 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 26 Jul 2025 22:25:57 +0200 Subject: [PATCH] feat: lock playhead to frame (instead of floating) and add autoscroll to timeline --- .../src/components/editor/snap-indicator.tsx | 29 +++- .../src/components/editor/timeline/index.tsx | 5 +- .../editor/timeline/timeline-playhead.tsx | 75 ++++++++- apps/web/src/hooks/use-timeline-playhead.ts | 153 ++++++++++++++++-- 4 files changed, 237 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 302a52bb..053d119e 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -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; trackLabelsRef?: React.RefObject; + tracksScrollRef: React.RefObject; } 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 (
{/* Timeline Header with Ruler */}
{/* Track Labels Header */} -
+
{/* Empty space */} . @@ -651,7 +652,7 @@ export function Timeline() { {tracks.length > 0 && (
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index d0aea7fa..b33166c0 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -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(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 (
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 008f61a4..ac59a8cd 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -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(null); + const lastMouseXRef = useRef(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,