From 1fd74904e9c249bfdb2baac537f6bea00203f8fe Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 30 Jan 2026 15:40:42 +0100 Subject: [PATCH] removed timeline anchor --- .cursor/debug.log | 0 .cursor/rules/codebase-index.mdc | 55 ++- .../editor/panels/preview/index.tsx | 14 +- .../components/editor/timeline/bookmarks.tsx | 49 +- .../components/editor/timeline/drag-line.tsx | 11 +- .../src/components/editor/timeline/index.tsx | 454 +++++++++--------- .../editor/timeline/timeline-playhead.tsx | 51 +- .../editor/timeline/timeline-ruler.tsx | 27 +- apps/web/src/constants/timeline-constants.tsx | 5 +- .../web/src/core/managers/playback-manager.ts | 16 +- .../src/hooks/actions/use-editor-actions.ts | 34 +- .../element/use-element-interaction.ts | 12 +- .../web/src/hooks/timeline/use-scroll-sync.ts | 89 +++- .../hooks/timeline/use-timeline-drag-drop.ts | 12 +- .../hooks/timeline/use-timeline-playhead.ts | 17 +- .../src/hooks/timeline/use-timeline-seek.ts | 8 + .../src/hooks/timeline/use-timeline-zoom.ts | 114 ++--- apps/web/src/lib/time.ts | 18 +- apps/web/src/lib/timeline/zoom-utils.ts | 45 +- apps/web/src/types/project.ts | 1 + 20 files changed, 560 insertions(+), 472 deletions(-) create mode 100644 .cursor/debug.log diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 00000000..e69de29b diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index 94410024..f1b23366 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -219,8 +219,8 @@ use-edge-auto-scroll.ts use-scroll-sync.ts export function useScrollSync({ - rulerScrollRef, tracksScrollRef, + rulerScrollRef, trackLabelsScrollRef, bookmarksScrollRef, }: UseScrollSyncProps) @@ -247,6 +247,7 @@ use-snap-indicator-position.ts use-timeline-drag-drop.ts export function useTimelineDragDrop({ containerRef, + headerRef, zoomLevel, }: UseTimelineDragDropProps) @@ -296,13 +297,14 @@ use-timeline-snapping.ts }: UseTimelineSnappingOptions = {}) use-timeline-zoom.ts - export function useTimelineZoom({ - containerRef, - minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, - initialZoom, - initialScrollLeft, - tracksScrollRef, - rulerScrollRef, + export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn ## apps/web/src/hooks/timeline/element @@ -313,6 +315,7 @@ use-element-interaction.ts timelineRef, tracksContainerRef, tracksScrollRef, + headerRef, snappingEnabled, onSnapPointChange, }: UseElementInteractionProps) @@ -554,6 +557,13 @@ time.ts duration: number; fps: number; }): number + export function getLastFrameTime({ + duration, + fps, + }: { + duration: number; + fps: number; + }): number ## apps/web/src/lib/actions @@ -1045,12 +1055,28 @@ track-utils.ts }): { isValid: boolean; errorMessage?: string } zoom-utils.ts - export function getTimelineZoomMin({ - duration, - containerWidth, - }: { - duration: number; - containerWidth: number | null | undefined; + export function getTimelineZoomMin({ + duration, + containerWidth, + }: { + duration: number; + containerWidth: number | null | undefined; + }): number + export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, + }: { + containerWidth: number; + zoomLevel: number; + minZoom: number; + }): number + export function getZoomPercent({ + zoomLevel, + minZoom, + }: { + zoomLevel: number; + minZoom: number; }): number ## apps/web/src/lib/transcription @@ -1460,6 +1486,7 @@ project.ts export interface TTimelineViewState { zoomLevel: number scrollLeft: number + playheadTime: number } export interface TProject { metadata: TProjectMetadata diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 53985c64..5ed9db00 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; +import { getLastFrameTime } from "@/lib/time"; function usePreviewSize() { const editor = useEditor(); @@ -77,7 +78,12 @@ function PreviewCanvas() { const render = useCallback(() => { if (ref.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); - const frame = Math.floor(time * renderer.fps); + const lastFrameTime = getLastFrameTime({ + duration: renderTree.duration, + fps: renderer.fps, + }); + const renderTime = Math.min(time, lastFrameTime); + const frame = Math.floor(renderTime * renderer.fps); if ( frame !== lastFrameRef.current || @@ -87,7 +93,11 @@ function PreviewCanvas() { lastSceneRef.current = renderTree; lastFrameRef.current = frame; renderer - .renderToCanvas({ node: renderTree, time, targetCanvas: ref.current }) + .renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: ref.current, + }) .then(() => { renderingRef.current = false; }); diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/timeline/bookmarks.tsx index 695034c2..cfcb1e23 100644 --- a/apps/web/src/components/editor/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/timeline/bookmarks.tsx @@ -1,4 +1,3 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; import { useEditor } from "@/hooks/use-editor"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { getSnappedSeekTime } from "@/lib/time"; @@ -8,7 +7,6 @@ import { HugeiconsIcon } from "@hugeicons/react"; interface TimelineBookmarksRowProps { zoomLevel: number; dynamicTimelineWidth: number; - bookmarksScrollRef: React.RefObject; handleWheel: (e: React.WheelEvent) => void; handleTimelineContentClick: (e: React.MouseEvent) => void; handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; @@ -18,7 +16,6 @@ interface TimelineBookmarksRowProps { export function TimelineBookmarksRow({ zoomLevel, dynamicTimelineWidth, - bookmarksScrollRef, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -29,30 +26,28 @@ export function TimelineBookmarksRow({ return (
- - - +
); } diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx index 987ae43a..f31c6e28 100644 --- a/apps/web/src/components/editor/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/timeline/drag-line.tsx @@ -5,17 +5,24 @@ interface DragLineProps { dropTarget: DropTarget | null; tracks: TimelineTrack[]; isVisible: boolean; + headerHeight?: number; } -export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) { +export function DragLine({ + dropTarget, + tracks, + isVisible, + headerHeight = 0, +}: DragLineProps) { if (!isVisible || !dropTarget) return null; const y = getDropLineY({ dropTarget, tracks }); + const lineTop = y + headerHeight; return (
); } diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index eb31ef58..7e2b2b66 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -37,6 +37,7 @@ import { canTracktHaveAudio, canTrackBeHidden, getTimelineZoomMin, + getTimelinePaddingPx, isMainTrack, } from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; @@ -63,14 +64,13 @@ export function Timeline() { // refs const timelineRef = useRef(null); + const timelineHeaderRef = useRef(null); const rulerRef = useRef(null); const tracksContainerRef = useRef(null); - const rulerScrollRef = useRef(null); const tracksScrollRef = useRef(null); const trackLabelsRef = useRef(null); const playheadRef = useRef(null); const trackLabelsScrollRef = useRef(null); - const bookmarksScrollRef = useRef(null); // state const [isResizing, setIsResizing] = useState(false); @@ -94,7 +94,7 @@ export function Timeline() { const timelineDuration = timeline.getTotalDuration() || 0; const minZoomLevel = getTimelineZoomMin({ duration: timelineDuration, - containerWidth: timelineRef.current?.clientWidth, + containerWidth: tracksContainerRef.current?.clientWidth, }); const savedViewState = editor.project.getTimelineViewState(); @@ -105,8 +105,9 @@ export function Timeline() { minZoom: minZoomLevel, initialZoom: savedViewState?.zoomLevel, initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, tracksScrollRef, - rulerScrollRef, + rulerScrollRef: tracksScrollRef, }); const { @@ -120,6 +121,7 @@ export function Timeline() { timelineRef, tracksContainerRef, tracksScrollRef, + headerRef: timelineHeaderRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); @@ -128,13 +130,14 @@ export function Timeline() { useTimelinePlayhead({ zoomLevel, rulerRef, - rulerScrollRef, + rulerScrollRef: tracksScrollRef, tracksScrollRef, playheadRef, }); const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, zoomLevel, }); @@ -152,11 +155,17 @@ export function Timeline() { zoomLevel, }); - const paddedDuration = - timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS; + const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const contentWidth = + timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); const dynamicTimelineWidth = Math.max( - paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - timelineRef.current?.clientWidth || 1000, + contentWidth + paddingPx, + containerWidth, ); const showSnapIndicator = @@ -172,7 +181,7 @@ export function Timeline() { } = useTimelineSeek({ playheadRef, trackLabelsRef, - rulerScrollRef, + rulerScrollRef: tracksScrollRef, tracksScrollRef, zoomLevel, duration: timeline.getTotalDuration(), @@ -182,12 +191,13 @@ export function Timeline() { }); useScrollSync({ - rulerScrollRef, tracksScrollRef, trackLabelsScrollRef, - bookmarksScrollRef, }); + const timelineHeaderHeight = + timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + return (
- -
-
-
- . -
- -
-
-
- . -
- -
-
-
- {tracks.length > 0 && ( -
- -
- {tracks.map((track) => ( -
-
- {process.env.NODE_ENV === "development" && - isMainTrack(track) && ( -
- )} - {canTracktHaveAudio(track) && ( - - editor.timeline.toggleTrackMute({ - trackId: track.id, - }) - } - /> - )} - {canTrackBeHidden(track) && ( - - editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> - )} - -
-
- ))} -
- +
+
+ .
- )} +
+ . +
+ {tracks.length > 0 && ( +
+ +
+ {tracks.map((track) => ( +
+
+ {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( +
+ )} + {canTracktHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+
+ ))} +
+ +
+ )} +
{ - if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - return; - } - handleWheel(e); - }} + className="relative flex flex-1 flex-col overflow-hidden" ref={tracksContainerRef} > - { const isDirectTarget = event.target === event.currentTarget; @@ -362,111 +330,161 @@ export function Timeline() { event.stopPropagation(); handleTracksClick(event); }} + onWheel={(event) => { + if ( + event.shiftKey || + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ) { + return; + } + handleWheel(event); + }} onScroll={() => { saveScrollPosition(); }} >
- {tracks.length === 0 ? ( -
- ) : ( - tracks.map((track, index) => ( - - -
- -
-
- - } - onClick={(e) => { - e.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - - { - e.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - - - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - - - { - e.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - - - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - - - { - e.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} - variant="destructive" - > - - Delete track - - -
- )) - )} +
+ + +
+ +
+ {tracks.length === 0 ? ( +
+ ) : ( + tracks.map((track, index) => ( + + +
+ +
+
+ + } + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + + + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + + { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + + + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + + { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + + Delete track + + +
+ )) + )} +
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index c06962e2..58d0adb0 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; @@ -10,7 +10,6 @@ interface TimelinePlayheadProps { rulerRef: React.RefObject; rulerScrollRef: React.RefObject; tracksScrollRef: React.RefObject; - trackLabelsRef?: React.RefObject; timelineRef: React.RefObject; playheadRef?: React.RefObject; isSnappingToPlayhead?: boolean; @@ -21,17 +20,14 @@ export function TimelinePlayhead({ rulerRef, rulerScrollRef, tracksScrollRef, - trackLabelsRef, timelineRef, playheadRef: externalPlayheadRef, isSnappingToPlayhead = false, }: TimelinePlayheadProps) { const editor = useEditor(); const duration = editor.timeline.getTotalDuration(); - const tracks = editor.timeline.getTracks(); const internalPlayheadRef = useRef(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; - const [scrollLeft, setScrollLeft] = useState(0); const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ zoomLevel, @@ -41,48 +37,15 @@ export function TimelinePlayhead({ playheadRef, }); - useEffect(() => { - const tracksViewport = tracksScrollRef.current; - - if (!tracksViewport) return; - - const handleScroll = () => { - setScrollLeft(tracksViewport.scrollLeft); - }; - - setScrollLeft(tracksViewport.scrollLeft); - - tracksViewport.addEventListener("scroll", handleScroll); - return () => tracksViewport.removeEventListener("scroll", handleScroll); - }, [tracksScrollRef]); - - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 4; - - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; + const timelineContainerHeight = + tracksScrollRef.current?.clientHeight ?? + timelineRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max(0, timelineContainerHeight - 4); const timelinePosition = playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft; - - const timelineContentWidth = - duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const tracksViewport = tracksScrollRef.current; - const viewportWidth = tracksViewport?.clientWidth || 1000; - - const leftBoundary = trackLabelsWidth; - const rightBoundary = Math.min( - trackLabelsWidth + timelineContentWidth - scrollLeft, - trackLabelsWidth + viewportWidth, - ); - - const leftPosition = Math.max( - leftBoundary, - Math.min(rightBoundary, rawLeftPosition), - ); + const leftPosition = timelinePosition; const handlePlayheadKeyDown = ( event: React.KeyboardEvent, diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx index eb23f57d..4742f507 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -1,4 +1,3 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; @@ -8,7 +7,6 @@ interface TimelineRulerProps { zoomLevel: number; dynamicTimelineWidth: number; rulerRef: React.RefObject; - rulerScrollRef: React.RefObject; handleWheel: (e: React.WheelEvent) => void; handleTimelineContentClick: (e: React.MouseEvent) => void; handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; @@ -19,7 +17,6 @@ export function TimelineRuler({ zoomLevel, dynamicTimelineWidth, rulerRef, - rulerScrollRef, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -59,19 +56,17 @@ export function TimelineRuler({ onMouseDown={handleRulerTrackingMouseDown} onKeyDown={() => {}} > - -
- {timelineTicks} -
-
+
+ {timelineTicks} +
); } diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index 6aeeb309..9c22a87c 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -36,18 +36,17 @@ export const TIMELINE_CONSTANTS = { PIXELS_PER_SECOND: 50, DEFAULT_ELEMENT_DURATION: 5, PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead - PADDING_TOP: 0, // px + PADDING_TOP_PX: 0, ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50], ZOOM_MIN: 0.1, ZOOM_MAX: 50, ZOOM_STEP: 0.1, - ZOOM_OUT_FACTOR: 4, - ZOOM_PLAYHEAD_ANCHOR_THRESHOLD: 0.0002, } as const; export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, + playheadTime: 0, }; export const TRACK_ICONS: Record = { diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index b8d43347..ca6f4e43 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -16,12 +16,7 @@ export class PlaybackManager { const duration = this.editor.timeline.getTotalDuration(); if (duration > 0) { - const activeProject = this.editor.project.getActive(); - const fps = activeProject.settings.fps; - const frameOffset = 1 / fps; - const endThreshold = Math.max(0, duration - frameOffset); - - if (this.currentTime >= endThreshold) { + if (this.currentTime >= duration) { this.seek({ time: 0 }); } } @@ -142,18 +137,13 @@ export class PlaybackManager { const duration = this.editor.timeline.getTotalDuration(); if (duration > 0 && newTime >= duration) { - const activeProject = this.editor.project.getActive(); - const fps = activeProject.settings.fps; - const frameOffset = 1 / fps; - const stopTime = Math.max(0, duration - frameOffset); - this.pause(); - this.currentTime = stopTime; + this.currentTime = duration; this.notify(); window.dispatchEvent( new CustomEvent("playback-seek", { - detail: { time: stopTime }, + detail: { time: duration }, }), ); } else { diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 8458979c..d1ff6c92 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -156,16 +156,16 @@ export function useEditorActions() { time: currentTime, }); - if (elementsToSplit.length === 0) return; + if (elementsToSplit.length === 0) return; - editor.timeline.splitElements({ - elements: elementsToSplit, - splitTime: currentTime, - retainSide: "right", - }); - }, - undefined, -); + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "right", + }); + }, + undefined, + ); useActionHandler( "split-right", @@ -220,9 +220,9 @@ export function useEditorActions() { useActionHandler( "duplicate-selected", () => { - editor.timeline.duplicateElements({ - elements: selectedElements, - }); + editor.timeline.duplicateElements({ + elements: selectedElements, + }); }, undefined, ); @@ -276,12 +276,12 @@ export function useEditorActions() { useActionHandler( "paste-copied", () => { - if (!clipboard?.items.length) return; + if (!clipboard?.items.length) return; - editor.timeline.pasteAtTime({ - time: editor.playback.getCurrentTime(), - clipboardItems: clipboard.items, - }); + editor.timeline.pasteAtTime({ + time: editor.playback.getCurrentTime(), + clipboardItems: clipboard.items, + }); }, undefined, ); diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 5a97c533..c92d6722 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -28,6 +28,7 @@ interface UseElementInteractionProps { timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; + headerRef?: RefObject; snappingEnabled: boolean; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } @@ -104,6 +105,7 @@ function getDragDropTarget({ tracks, tracksContainerRef, tracksScrollRef, + headerRef, zoomLevel, snappedTime, verticalDragDirection, @@ -115,6 +117,7 @@ function getDragDropTarget({ tracks: TimelineTrack[]; tracksContainerRef: RefObject; tracksScrollRef: RefObject; + headerRef?: RefObject; zoomLevel: number; snappedTime: number; verticalDragDirection?: "up" | "down" | null; @@ -131,9 +134,11 @@ function getDragDropTarget({ const elementDuration = movingElement.duration; const scrollLeft = scrollContainer.scrollLeft; + const scrollTop = scrollContainer.scrollTop; const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - containerRect.top; + const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; return computeDropTarget({ elementType: movingElement.type, @@ -165,6 +170,7 @@ export function useElementInteraction({ timelineRef, tracksContainerRef, tracksScrollRef, + headerRef, snappingEnabled, onSnapPointChange, }: UseElementInteractionProps) { @@ -368,6 +374,7 @@ export function useElementInteraction({ tracks, tracksContainerRef, tracksScrollRef, + headerRef, zoomLevel, snappedTime, verticalDragDirection, @@ -391,6 +398,7 @@ export function useElementInteraction({ timelineRef, tracksScrollRef, tracksContainerRef, + headerRef, tracks, isPendingDrag, startDrag, @@ -423,6 +431,7 @@ export function useElementInteraction({ tracks, tracksContainerRef, tracksScrollRef, + headerRef, zoomLevel, snappedTime: dragState.currentTime, verticalDragDirection: getVerticalDragDirection({ @@ -485,6 +494,7 @@ export function useElementInteraction({ editor.timeline, tracksContainerRef, tracksScrollRef, + headerRef, ]); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts index 62cd8d5b..cdd915b0 100644 --- a/apps/web/src/hooks/timeline/use-scroll-sync.ts +++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts @@ -1,15 +1,15 @@ import { useEffect, useRef } from "react"; interface UseScrollSyncProps { - rulerScrollRef: React.RefObject; + rulerScrollRef?: React.RefObject; tracksScrollRef: React.RefObject; trackLabelsScrollRef?: React.RefObject; bookmarksScrollRef?: React.RefObject; } export function useScrollSync({ - rulerScrollRef, tracksScrollRef, + rulerScrollRef, trackLabelsScrollRef, bookmarksScrollRef, }: UseScrollSyncProps) { @@ -20,23 +20,36 @@ export function useScrollSync({ const lastBookmarksSync = useRef(0); useEffect(() => { - const rulerViewport = rulerScrollRef.current; + const rulerViewport = rulerScrollRef?.current ?? null; const tracksViewport = tracksScrollRef.current; const trackLabelsViewport = trackLabelsScrollRef?.current; const bookmarksViewport = bookmarksScrollRef?.current; let handleBookmarksScroll: (() => void) | null = null; - if (!rulerViewport || !tracksViewport) return; + if (!tracksViewport) return; + + const syncScrollLeft = ({ + target, + scrollLeft, + }: { + target: HTMLDivElement | null | undefined; + scrollLeft: number; + }) => { + if (target) { + target.scrollLeft = scrollLeft; + } + }; const handleRulerScroll = () => { const now = Date.now(); if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; lastRulerSync.current = now; isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - if (bookmarksViewport) { - bookmarksViewport.scrollLeft = rulerViewport.scrollLeft; - } + tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0; + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: rulerViewport?.scrollLeft ?? 0, + }); isUpdatingRef.current = false; }; @@ -45,15 +58,23 @@ export function useScrollSync({ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; lastTracksSync.current = now; isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - if (bookmarksViewport) { - bookmarksViewport.scrollLeft = tracksViewport.scrollLeft; - } + syncScrollLeft({ + target: rulerViewport, + scrollLeft: tracksViewport.scrollLeft, + }); + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: tracksViewport.scrollLeft, + }); isUpdatingRef.current = false; }; - rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.addEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.addEventListener("scroll", handleTracksScroll); + } if (bookmarksViewport) { handleBookmarksScroll = () => { @@ -62,12 +83,18 @@ export function useScrollSync({ return; lastBookmarksSync.current = now; isUpdatingRef.current = true; - tracksViewport.scrollLeft = bookmarksViewport.scrollLeft; - rulerViewport.scrollLeft = bookmarksViewport.scrollLeft; + const nextScrollLeft = bookmarksViewport.scrollLeft; + tracksViewport.scrollLeft = nextScrollLeft; + syncScrollLeft({ + target: rulerViewport, + scrollLeft: nextScrollLeft, + }); isUpdatingRef.current = false; }; - bookmarksViewport.addEventListener("scroll", handleBookmarksScroll); + if (bookmarksViewport !== tracksViewport) { + bookmarksViewport.addEventListener("scroll", handleBookmarksScroll); + } } if (trackLabelsViewport) { @@ -95,9 +122,17 @@ export function useScrollSync({ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - if (bookmarksViewport && handleBookmarksScroll) { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { bookmarksViewport.removeEventListener( "scroll", handleBookmarksScroll, @@ -115,9 +150,17 @@ export function useScrollSync({ } return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - if (bookmarksViewport && handleBookmarksScroll) { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll); } }; diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index d0d5c8ab..01af9362 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -18,11 +18,13 @@ import type { MediaDragData, StickerDragData } from "@/types/drag"; interface UseTimelineDragDropProps { containerRef: RefObject; + headerRef?: RefObject; zoomLevel: number; } export function useTimelineDragDrop({ containerRef, + headerRef, zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); @@ -93,6 +95,8 @@ export function useTimelineDragDrop({ const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; const hasFiles = e.dataTransfer.types.includes("Files"); const isExternal = hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); @@ -116,7 +120,7 @@ export function useTimelineDragDrop({ }); const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); const target = computeDropTarget({ elementType, @@ -137,6 +141,7 @@ export function useTimelineDragDrop({ }, [ containerRef, + headerRef, tracks, currentTime, zoomLevel, @@ -416,7 +421,9 @@ export function useTimelineDragDrop({ const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); await executeFileDrop({ files: Array.from(e.dataTransfer.files), mouseX, @@ -435,6 +442,7 @@ export function useTimelineDragDrop({ executeMediaDrop, executeFileDrop, containerRef, + headerRef, ], ); diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 4b8a648e..55e04a68 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -59,8 +59,7 @@ export function useTimelinePlayhead({ 0, Math.min( duration, - clampedMouseX / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), ), ); const framesPerSecond = activeProject.settings.fps; @@ -134,7 +133,16 @@ export function useTimelinePlayhead({ const handleMouseUp = ({ event }: { event: MouseEvent }) => { setIsScrubbing(false); - if (scrubTime !== null) seek({ time: scrubTime }); + if (scrubTime !== null) { + seek({ time: scrubTime }); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, + playheadTime: scrubTime, + }, + }); + } setScrubTime(null); if (isDraggingRuler) { @@ -163,6 +171,9 @@ export function useTimelinePlayhead({ handleScrub, isDraggingRuler, hasDraggedRuler, + editor, + tracksScrollRef, + zoomLevel, ]); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts index f859df44..f5dc6ddb 100644 --- a/apps/web/src/hooks/timeline/use-timeline-seek.ts +++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts @@ -143,6 +143,13 @@ export function useTimelineSeek({ fps: projectFps, }); seek(time); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }, + }); }, [ duration, @@ -150,6 +157,7 @@ export function useTimelineSeek({ rulerScrollRef, tracksScrollRef, seek, + editor, activeProject?.settings.fps, ], ); diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 9519c410..96a4be43 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -3,6 +3,7 @@ import { type RefObject, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from "react"; @@ -14,6 +15,7 @@ interface UseTimelineZoomProps { minZoom?: number; initialZoom?: number; initialScrollLeft?: number; + initialPlayheadTime?: number; tracksScrollRef: RefObject; rulerScrollRef: RefObject; } @@ -30,11 +32,13 @@ export function useTimelineZoom({ minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, initialZoom, initialScrollLeft, + initialPlayheadTime, tracksScrollRef, rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); const hasInitializedRef = useRef(false); + const hasRestoredPlayheadRef = useRef(false); const scrollSaveTimeoutRef = useRef | null>( null, ); @@ -49,50 +53,8 @@ export function useTimelineZoom({ } return minZoom; }); - - const applyZoomAnchor = useCallback( - ({ - previousZoom, - nextZoom, - }: { - previousZoom: number; - nextZoom: number; - }) => { - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const currentTime = editor.playback.getCurrentTime(); - const zoomPercent = - (nextZoom - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom); - const shouldAnchorToPlayhead = - zoomPercent > TIMELINE_CONSTANTS.ZOOM_PLAYHEAD_ANCHOR_THRESHOLD; - - let newScrollLeft: number; - - if (shouldAnchorToPlayhead) { - const playheadPixelsBefore = - currentTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom; - const playheadViewportOffset = - playheadPixelsBefore - scrollElement.scrollLeft; - const playheadPixelsAfter = - currentTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * nextZoom; - newScrollLeft = playheadPixelsAfter - playheadViewportOffset; - } else { - const timeAtLeftEdge = - scrollElement.scrollLeft / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom); - newScrollLeft = - timeAtLeftEdge * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * nextZoom; - } - - const clampedScrollLeft = Math.max(0, newScrollLeft); - scrollElement.scrollLeft = clampedScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = clampedScrollLeft; - } - }, - [editor, minZoom, tracksScrollRef, rulerScrollRef], - ); + const previousZoomRef = useRef(zoomLevel); + const hasRestoredScrollRef = useRef(false); const handleWheel = useCallback( (event: ReactWheelEvent) => { @@ -112,21 +74,6 @@ export function useTimelineZoom({ minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier), ); - applyZoomAnchor({ - previousZoom: prev, - nextZoom, - }); - - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel: nextZoom, - scrollLeft: scrollElement.scrollLeft, - }, - }); - } - return nextZoom; }); // for horizontal scrolling (when shift is held or horizontal wheel movement), @@ -134,7 +81,7 @@ export function useTimelineZoom({ return; } }, - [minZoom, applyZoomAnchor, editor, tracksScrollRef], + [minZoom], ); useEffect(() => { @@ -153,7 +100,6 @@ export function useTimelineZoom({ }); }, [minZoom, initialZoom]); - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref const wrappedSetZoomLevel = useCallback( (zoomLevelOrUpdater: number | ((prev: number) => number)) => { setZoomLevel((prev) => { @@ -165,27 +111,30 @@ export function useTimelineZoom({ minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom), ); - applyZoomAnchor({ - previousZoom: prev, - nextZoom: clampedZoom, - }); - - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel: clampedZoom, - scrollLeft: scrollElement.scrollLeft, - }, - }); - } - return clampedZoom; }); }, - [minZoom, applyZoomAnchor, editor], + [minZoom], ); + useLayoutEffect(() => { + const previousZoom = previousZoomRef.current; + if (previousZoom === zoomLevel) return; + + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), + }, + }); + } + + previousZoomRef.current = zoomLevel; + }, [zoomLevel, editor, tracksScrollRef]); + // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref const saveScrollPosition = useCallback(() => { if (scrollSaveTimeoutRef.current) { @@ -198,6 +147,7 @@ export function useTimelineZoom({ viewState: { zoomLevel, scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), }, }); } @@ -207,6 +157,7 @@ export function useTimelineZoom({ // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable useEffect(() => { if (initialScrollLeft === undefined) return; + if (hasRestoredScrollRef.current) return; const scrollElement = tracksScrollRef.current; if (!scrollElement) return; @@ -215,6 +166,7 @@ export function useTimelineZoom({ if (rulerScrollRef.current) { rulerScrollRef.current.scrollLeft = initialScrollLeft; } + hasRestoredScrollRef.current = true; }; if (scrollElement.scrollWidth > 0) { @@ -223,6 +175,7 @@ export function useTimelineZoom({ const observer = new ResizeObserver(() => { if (scrollElement.scrollWidth > 0) { restoreScroll(); + hasRestoredScrollRef.current = true; observer.disconnect(); } }); @@ -231,6 +184,13 @@ export function useTimelineZoom({ } }, [initialScrollLeft]); + useEffect(() => { + if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { + hasRestoredPlayheadRef.current = true; + editor.playback.seek({ time: initialPlayheadTime }); + } + }, [initialPlayheadTime, editor]); + // prevent browser zoom in the timeline useEffect(() => { const preventZoom = (event: WheelEvent) => { diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index d6526ffb..b6761b0d 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -203,8 +203,18 @@ export function getSnappedSeekTime({ fps: number; }): number { const snappedTime = snapTimeToFrame({ time: rawTime, fps }); - const frameOffset = fps > 0 ? 1 / fps : 0; - const maxSeekTime = - frameOffset > 0 ? Math.max(0, duration - frameOffset) : duration; - return Math.max(0, Math.min(maxSeekTime, snappedTime)); + return Math.max(0, Math.min(duration, snappedTime)); +} + +export function getLastFrameTime({ + duration, + fps, +}: { + duration: number; + fps: number; +}): number { + if (duration <= 0) return 0; + if (fps <= 0) return duration; + const frameOffset = 1 / fps; + return Math.max(0, duration - frameOffset); } diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index 01eb8cb1..49f7fcb6 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -1,5 +1,9 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +const PADDING_MAX_RATIO = 0.75; +const PADDING_MIN_RATIO = 0.15; +const PADDING_MIN_AT_ZOOM_PERCENT = 0.2; + export function getTimelineZoomMin({ duration, containerWidth, @@ -8,13 +12,42 @@ export function getTimelineZoomMin({ containerWidth: number | null | undefined; }): number { const safeDuration = Math.max(duration, 1); - const paddedDuration = - safeDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS; const safeContainerWidth = containerWidth ?? 1000; + const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; + const availableWidth = safeContainerWidth * contentRatioAtMinZoom; const zoomToFit = - safeContainerWidth / - (paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND); - const minZoom = zoomToFit / TIMELINE_CONSTANTS.ZOOM_OUT_FACTOR; + availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND); - return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, minZoom); + return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit); +} + +export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, +}: { + containerWidth: number; + zoomLevel: number; + minZoom: number; +}): number { + const zoomPercent = getZoomPercent({ zoomLevel, minZoom }); + const paddingTransitionPercent = Math.min( + zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT, + 1, + ); + const paddingRatio = + PADDING_MAX_RATIO - + (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent; + + return containerWidth * paddingRatio; +} + +export function getZoomPercent({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { + return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom); } diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index fcab04b4..c8074c26 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -34,6 +34,7 @@ export interface TProjectSettings { export interface TTimelineViewState { zoomLevel: number; scrollLeft: number; + playheadTime: number; } export interface TProject {