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