diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 546ee2b4..29d0a270 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -82,7 +82,7 @@ export function TimelinePlayhead({ {/* Playhead dot indicator at the top (in ruler area) */}
); diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 2be143eb..0e72399c 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -915,12 +915,12 @@ export function TimelineTrackContent({ > {track.elements.length === 0 ? (
{isDropping @@ -930,7 +930,9 @@ export function TimelineTrackContent({ : ""}
) : ( - <> +
{track.elements.map((element) => { const isSelected = selectedElements.some( (c) => c.trackId === track.id && c.elementId === element.id @@ -989,7 +991,7 @@ export function TimelineTrackContent({ /> ); })} - +
)} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ca64129f..1ea41c70 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -17,6 +17,7 @@ import { TypeIcon, Lock, LockOpen, + Plus, } from "lucide-react"; import { Tooltip, @@ -37,7 +38,7 @@ import { useProjectStore } from "@/stores/project-store"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, Fragment } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead, @@ -108,15 +109,18 @@ export function Timeline() { timelineRef.current?.clientWidth || 1000 // Minimum width ); - // Essential refs for timeline functionality - const playheadRef = useRef(null); - const tracksContainerRef = useRef(null); - - // Temporary refs for compatibility (should be removed when TimelinePlayhead is updated) + // Scroll synchronization and auto-scroll to playhead const rulerScrollRef = useRef(null); const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); - // Timeline playhead ruler handlers - temporarily keeping all refs for compatibility + // Timeline playhead ruler handlers const { handleRulerMouseDown } = useTimelinePlayheadRuler({ currentTime, duration, @@ -129,6 +133,7 @@ export function Timeline() { }); // Selection box functionality + const tracksContainerRef = useRef(null); const { selectionBox, handleMouseDown: handleSelectionMouseDown, @@ -155,7 +160,7 @@ export function Timeline() { setCurrentSnapPoint(snapPoint); }, []); - // Timeline content click to seek handler - simplified for single scroll area + // Timeline content click to seek handler const handleTimelineContentClick = useCallback( (e: React.MouseEvent) => { console.log( @@ -240,10 +245,11 @@ export function Timeline() { duration, zoomLevel, seek, + rulerScrollRef, + tracksScrollRef, clearSelectedElements, isSelecting, justFinishedSelecting, - activeProject?.fps, ] ); @@ -499,6 +505,85 @@ export function Timeline() { clearSelectedElements(); }; + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport) return; + + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); + + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll + ); + }; + } + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); + // Add wheel event listeners with passive: false to allow preventDefault useEffect(() => { const timelineContainer = timelineRef.current; @@ -525,7 +610,7 @@ export function Timeline() { onMouseLeave={() => setIsInTimeline(false)} > {/* Toolbar */} -
+
@@ -692,178 +777,204 @@ export function Timeline() { {/* Timeline Container */} - + {/* Timeline Container with Grid Layout */} +
setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + > +
+
0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}> - {/* Timeline Tracks Content */} -
- + {/* Top-Left Corner (Empty space above track labels) */} +
- - -
-
+ {/* Top Row (Sticky Ruler Header) */} +
+
+ {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; -
- {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + return ( +
+ - return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - -
- ); - }).filter(Boolean); - })()} -
-
- {tracks.map((track, index) => ( -
- -
-
- -
- {track.muted && ( - - Muted + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} - )} -
- - -
{ - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - -
-
- - toggleTrackMute(track.id)} - > - {track.muted ? "Unmute Track" : "Mute Track"} - - - Track settings (soon) - - -
-
- ))} - +
+ ); + }).filter(Boolean); + })()}
+
+ + {/* Track Rows */} + {tracks.map((track, index) => ( + + {/* Left Column (Sticky Track Labels) */} +
+
+ +
+ {track.muted && ( + + Muted + + )} +
+ + {/* Scrollable Track Content */} + + +
{ + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element" + ) + ) { + clearSelectedElements(); + } + }} + > + +
+
+ + toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + + + Track settings (soon) + + +
+
+ ))} + {/* Add Track Button - spans full width */} +
addTrack('media')} + className="col-span-1 sticky left-0 w-full flex items-center border-b border-muted bg-card/[0.99] hover:bg-card/50 transition-colors cursor-pointer z-[89]" + style={{ height: `${getTrackHeight("media")}px` }} + > +
+ +
+
- +
+ + {/* Overlay Components */} + + + + + +
+ +
); }