diff options
| author | Karan Singh <karansingh5112002@gmail.com> | 2025-06-27 00:30:32 +0530 |
|---|---|---|
| committer | Karan Singh <karansingh5112002@gmail.com> | 2025-06-27 00:30:32 +0530 |
| commit | 17ef81007495ccf6c02c77c77815b7413d9d127a (patch) | |
| tree | 3ee6f73092e9a25fcda30e0b9d3fe45d590810f0 | |
| parent | b10bb8ed55330d98cbbb88d7e139b943a9f95f00 (diff) | |
| download | opencut-17ef81007495ccf6c02c77c77815b7413d9d127a.tar.gz | |
feat: Improve timeline playhead smooth scrolling and sync
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 92 |
1 files changed, 82 insertions, 10 deletions
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ae6b98d3..46aaf13f 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -100,6 +100,28 @@ export function Timeline() { const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
+ // Dynamic timeline width calculation based on playhead position and duration
+ const dynamicTimelineWidth = Math.max(
+ (duration || 0) * 50 * zoomLevel, // Base width from duration
+ (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer
+ timelineRef.current?.clientWidth || 1000 // Minimum width
+ );
+
+ // Scroll synchronization and auto-scroll to playhead
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+
+ // New refs for direct playhead DOM manipulation
+ const rulerPlayheadRef = useRef<HTMLDivElement>(null);
+ const tracksPlayheadRef = useRef<HTMLDivElement>(null);
+
+ // Refs to store initial mouse and scroll positions for drag calculations
+ const initialMouseXRef = useRef(0);
+ const initialTimelineScrollLeftRef = useRef(0);
+
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
@@ -562,6 +584,61 @@ export function Timeline() { };
}, [isInTimeline]);
+ // --- 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;
+ if (!rulerViewport || !tracksViewport) return;
+ 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);
+ return () => {
+ rulerViewport.removeEventListener('scroll', handleRulerScroll);
+ tracksViewport.removeEventListener('scroll', handleTracksScroll);
+ };
+ }, [duration, zoomLevel]);
+
+ // --- Playhead auto-scroll 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;
+ if (!rulerViewport || !tracksViewport) return;
+ const playheadPx = playheadPosition * 50 * zoomLevel;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMin = 0;
+ const scrollMax = rulerViewport.scrollWidth - viewportWidth;
+ // Center the playhead if it's not visible (60px buffer)
+ const desiredScroll = Math.max(
+ scrollMin,
+ Math.min(scrollMax, playheadPx - viewportWidth / 2)
+ );
+ if (
+ playheadPx < rulerViewport.scrollLeft + 60 ||
+ playheadPx > rulerViewport.scrollLeft + viewportWidth - 60
+ ) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ // Keep both scrolls in sync
+ if (rulerViewport.scrollLeft !== tracksViewport.scrollLeft) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = Math.max(rulerViewport.scrollLeft, tracksViewport.scrollLeft);
+ }
+ }, [playheadPosition, duration, zoomLevel]);
+
return (
<div
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
@@ -743,11 +820,11 @@ export function Timeline() { {/* Timeline Ruler */}
<div className="flex-1 relative overflow-hidden">
- <ScrollArea className="w-full">
+ <ScrollArea className="w-full" ref={rulerScrollRef}>
<div
className="relative h-12 bg-muted/30 cursor-pointer"
style={{
- width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
+ width: `${dynamicTimelineWidth}px`,
}}
onClick={(e) => {
// Calculate the clicked time position and seek to it
@@ -876,17 +953,12 @@ export function Timeline() { {/* Timeline Tracks Content */}
<div className="flex-1 relative overflow-hidden">
- <div
- className="w-full h-full overflow-hidden flex"
- ref={timelineRef}
- style={{ position: "relative" }}
- >
- {/* Timeline grid and clips area (with left margin for sifdebar) */}
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`,
- width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
+ width: `${dynamicTimelineWidth}px`,
}}
onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown}
@@ -957,7 +1029,7 @@ export function Timeline() { </div>
)}
</div>
- </div>
+ </ScrollArea>
</div>
</div>
</div>
|
